123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- // Copyright (C) 2016 The Syncthing Authors.
- //
- // This Source Code Form is subject to the terms of the Mozilla Public
- // License, v. 2.0. If a copy of the MPL was not distributed with this file,
- // You can obtain one at https://mozilla.org/MPL/2.0/.
- package fs
- import (
- "fmt"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "unicode"
- )
- const pathSeparatorString = string(PathSeparator)
- func ExpandTilde(path string) (string, error) {
- if path == "~" {
- return getHomeDir()
- }
- path = filepath.FromSlash(path)
- if !strings.HasPrefix(path, fmt.Sprintf("~%c", PathSeparator)) {
- return path, nil
- }
- home, err := getHomeDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(home, path[2:]), nil
- }
- func getHomeDir() (string, error) {
- if runtime.GOOS == "windows" {
- // Legacy -- we prioritize this for historical reasons, whereas
- // os.UserHomeDir uses %USERPROFILE% always.
- home := filepath.Join(os.Getenv("HomeDrive"), os.Getenv("HomePath"))
- if home != "" {
- return home, nil
- }
- }
- return os.UserHomeDir()
- }
- var (
- windowsDisallowedCharacters = string([]rune{
- '<', '>', ':', '"', '|', '?', '*',
- 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
- 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
- 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
- 31,
- })
- windowsDisallowedNames = []string{"CON", "PRN", "AUX", "NUL",
- "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
- "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
- }
- )
- func WindowsInvalidFilename(name string) error {
- // None of the path components should end in space or period, or be a
- // reserved name. COM0 and LPT0 are missing from the Microsoft docs,
- // but Windows Explorer treats them as invalid too.
- // (https://docs.microsoft.com/windows/win32/fileio/naming-a-file)
- for _, part := range strings.Split(name, `\`) {
- if len(part) == 0 {
- continue
- }
- switch part[len(part)-1] {
- case ' ', '.':
- // Names ending in space or period are not valid.
- return errInvalidFilenameWindowsSpacePeriod
- }
- if windowsIsReserved(part) {
- return errInvalidFilenameWindowsReservedName
- }
- }
- // The path must not contain any disallowed characters
- if strings.ContainsAny(name, windowsDisallowedCharacters) {
- return errInvalidFilenameWindowsReservedChar
- }
- return nil
- }
- // SanitizePath takes a string that might contain all kinds of special
- // characters and makes a valid, similar, path name out of it.
- //
- // Spans of invalid characters, whitespace and/or non-UTF-8 sequences are
- // replaced by a single space. The result is always UTF-8 and contains only
- // printable characters, as determined by unicode.IsPrint.
- //
- // Invalid characters are non-printing runes, things not allowed in file names
- // in Windows, and common shell metacharacters. Even if asterisks and pipes
- // and stuff are allowed on Unixes in general they might not be allowed by
- // the filesystem and may surprise the user and cause shell oddness. This
- // function is intended for file names we generate on behalf of the user,
- // and surprising them with odd shell characters in file names is unkind.
- //
- // We include whitespace in the invalid characters so that multiple
- // whitespace is collapsed to a single space. Additionally, whitespace at
- // either end is removed.
- //
- // If the result is a name disallowed on windows, a hyphen is prepended.
- func SanitizePath(path string) string {
- var b strings.Builder
- disallowed := `<>:"'/\|?*[]{};:!@$%&^#` + windowsDisallowedCharacters
- prev := ' '
- for _, c := range path {
- if !unicode.IsPrint(c) || c == unicode.ReplacementChar ||
- strings.ContainsRune(disallowed, c) {
- c = ' '
- }
- if !(c == ' ' && prev == ' ') {
- b.WriteRune(c)
- }
- prev = c
- }
- path = strings.TrimSpace(b.String())
- if windowsIsReserved(path) {
- path = "-" + path
- }
- return path
- }
- func windowsIsReserved(part string) bool {
- upperCased := strings.ToUpper(part)
- for _, disallowed := range windowsDisallowedNames {
- if upperCased == disallowed {
- return true
- }
- if strings.HasPrefix(upperCased, disallowed+".") {
- // nul.txt.jpg is also disallowed
- return true
- }
- }
- return false
- }
- // IsParent compares paths purely lexicographically, meaning it returns false
- // if path and parent aren't both absolute or relative.
- func IsParent(path, parent string) bool {
- if parent == path {
- // Twice the same root on windows would not be caught at the end.
- return false
- }
- if filepath.IsAbs(path) != filepath.IsAbs(parent) {
- return false
- }
- if parent == "" || parent == "." {
- // The empty string is the parent of everything except the empty
- // string and ".". (Avoids panic in the last step.)
- return path != "" && path != "."
- }
- if parent == "/" {
- // The root is the parent of everything except itself, which would
- // not be caught below.
- return path != "/"
- }
- if parent[len(parent)-1] != PathSeparator {
- parent += pathSeparatorString
- }
- return strings.HasPrefix(path, parent)
- }
- func CommonPrefix(first, second string) string {
- if filepath.IsAbs(first) != filepath.IsAbs(second) {
- // Whatever
- return ""
- }
- firstParts := PathComponents(filepath.Clean(first))
- secondParts := PathComponents(filepath.Clean(second))
- isAbs := filepath.IsAbs(first) && filepath.IsAbs(second)
- count := len(firstParts)
- if len(secondParts) < len(firstParts) {
- count = len(secondParts)
- }
- common := make([]string, 0, count)
- for i := 0; i < count; i++ {
- if firstParts[i] != secondParts[i] {
- break
- }
- common = append(common, firstParts[i])
- }
- if isAbs {
- if runtime.GOOS == "windows" && isVolumeNameOnly(common) {
- // Because strings.Split strips out path separators, if we're at the volume name, we end up without a separator
- // Wedge an empty element to be joined with.
- common = append(common, "")
- } else if len(common) == 1 {
- // If isAbs on non Windows, first element in both first and second is "", hence joining that returns nothing.
- return pathSeparatorString
- }
- }
- // This should only be true on Windows when drive letters are different or when paths are relative.
- // In case of UNC paths we should end up with more than a single element hence joining is fine
- if len(common) == 0 {
- return ""
- }
- // This has to be strings.Join, because filepath.Join([]string{"", "", "?", "C:", "Audrius"}...) returns garbage
- result := strings.Join(common, pathSeparatorString)
- return filepath.Clean(result)
- }
- // PathComponents returns a list of names of parent directories and the leaf
- // item for the given native (fs.PathSeparator delimited) and clean path.
- func PathComponents(path string) []string {
- return strings.Split(path, pathSeparatorString)
- }
- func isVolumeNameOnly(parts []string) bool {
- isNormalVolumeName := len(parts) == 1 && strings.HasSuffix(parts[0], ":")
- isUNCVolumeName := len(parts) == 4 && strings.HasSuffix(parts[3], ":")
- return isNormalVolumeName || isUNCVolumeName
- }
|