util.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. // Copyright (C) 2016 The Syncthing Authors.
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this file,
  5. // You can obtain one at https://mozilla.org/MPL/2.0/.
  6. package fs
  7. import (
  8. "fmt"
  9. "os"
  10. "path/filepath"
  11. "strings"
  12. "unicode"
  13. "github.com/syncthing/syncthing/lib/build"
  14. )
  15. const pathSeparatorString = string(PathSeparator)
  16. func ExpandTilde(path string) (string, error) {
  17. if path == "~" {
  18. return getHomeDir()
  19. }
  20. path = filepath.FromSlash(path)
  21. if !strings.HasPrefix(path, fmt.Sprintf("~%c", PathSeparator)) {
  22. return path, nil
  23. }
  24. home, err := getHomeDir()
  25. if err != nil {
  26. return "", err
  27. }
  28. return filepath.Join(home, path[2:]), nil
  29. }
  30. func getHomeDir() (string, error) {
  31. if build.IsWindows {
  32. // Legacy -- we prioritize this for historical reasons, whereas
  33. // os.UserHomeDir uses %USERPROFILE% always.
  34. home := filepath.Join(os.Getenv("HomeDrive"), os.Getenv("HomePath"))
  35. if home != "" {
  36. return home, nil
  37. }
  38. }
  39. return os.UserHomeDir()
  40. }
  41. const windowsDisallowedCharacters = (`<>:"|?*` +
  42. "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" +
  43. "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f")
  44. func WindowsInvalidFilename(name string) error {
  45. // None of the path components should end in space or period, or be a
  46. // reserved name.
  47. for _, part := range strings.Split(name, `\`) {
  48. if part == "" {
  49. continue
  50. }
  51. switch part[len(part)-1] {
  52. case ' ', '.':
  53. // Names ending in space or period are not valid.
  54. return errInvalidFilenameWindowsSpacePeriod
  55. }
  56. if windowsIsReserved(part) {
  57. return errInvalidFilenameWindowsReservedName
  58. }
  59. }
  60. // The path must not contain any disallowed characters
  61. if strings.ContainsAny(name, windowsDisallowedCharacters) {
  62. return errInvalidFilenameWindowsReservedChar
  63. }
  64. return nil
  65. }
  66. // SanitizePath takes a string that might contain all kinds of special
  67. // characters and makes a valid, similar, path name out of it.
  68. //
  69. // Spans of invalid characters, whitespace and/or non-UTF-8 sequences are
  70. // replaced by a single space. The result is always UTF-8 and contains only
  71. // printable characters, as determined by unicode.IsPrint.
  72. //
  73. // Invalid characters are non-printing runes, things not allowed in file names
  74. // in Windows, and common shell metacharacters. Even if asterisks and pipes
  75. // and stuff are allowed on Unixes in general they might not be allowed by
  76. // the filesystem and may surprise the user and cause shell oddness. This
  77. // function is intended for file names we generate on behalf of the user,
  78. // and surprising them with odd shell characters in file names is unkind.
  79. //
  80. // We include whitespace in the invalid characters so that multiple
  81. // whitespace is collapsed to a single space. Additionally, whitespace at
  82. // either end is removed.
  83. //
  84. // If the result is a name disallowed on windows, a hyphen is prepended.
  85. func SanitizePath(path string) string {
  86. var b strings.Builder
  87. const disallowed = `'/\[]{};:!@$%&^#` + windowsDisallowedCharacters
  88. prev := ' '
  89. for _, c := range path {
  90. if !unicode.IsPrint(c) || c == unicode.ReplacementChar ||
  91. strings.ContainsRune(disallowed, c) {
  92. c = ' '
  93. }
  94. if !(c == ' ' && prev == ' ') {
  95. b.WriteRune(c)
  96. }
  97. prev = c
  98. }
  99. path = strings.TrimSpace(b.String())
  100. if windowsIsReserved(path) {
  101. path = "-" + path
  102. }
  103. return path
  104. }
  105. func windowsIsReserved(part string) bool {
  106. // nul.txt.jpg is also disallowed.
  107. dot := strings.IndexByte(part, '.')
  108. if dot != -1 {
  109. part = part[:dot]
  110. }
  111. // Check length to skip allocating ToUpper.
  112. if len(part) != 3 && len(part) != 4 {
  113. return false
  114. }
  115. // COM0 and LPT0 are missing from the Microsoft docs,
  116. // but Windows Explorer treats them as invalid too.
  117. // (https://docs.microsoft.com/windows/win32/fileio/naming-a-file)
  118. switch strings.ToUpper(part) {
  119. case "CON", "PRN", "AUX", "NUL",
  120. "COM0", "COM1", "COM2", "COM3", "COM4",
  121. "COM5", "COM6", "COM7", "COM8", "COM9",
  122. "LPT0", "LPT1", "LPT2", "LPT3", "LPT4",
  123. "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
  124. return true
  125. }
  126. return false
  127. }
  128. // IsParent compares paths purely lexicographically, meaning it returns false
  129. // if path and parent aren't both absolute or relative.
  130. func IsParent(path, parent string) bool {
  131. if parent == path {
  132. // Twice the same root on windows would not be caught at the end.
  133. return false
  134. }
  135. if filepath.IsAbs(path) != filepath.IsAbs(parent) {
  136. return false
  137. }
  138. if parent == "" || parent == "." {
  139. // The empty string is the parent of everything except the empty
  140. // string and ".". (Avoids panic in the last step.)
  141. return path != "" && path != "."
  142. }
  143. if parent == "/" {
  144. // The root is the parent of everything except itself, which would
  145. // not be caught below.
  146. return path != "/"
  147. }
  148. if parent[len(parent)-1] != PathSeparator {
  149. parent += pathSeparatorString
  150. }
  151. return strings.HasPrefix(path, parent)
  152. }
  153. func CommonPrefix(first, second string) string {
  154. if filepath.IsAbs(first) != filepath.IsAbs(second) {
  155. // Whatever
  156. return ""
  157. }
  158. firstParts := PathComponents(filepath.Clean(first))
  159. secondParts := PathComponents(filepath.Clean(second))
  160. isAbs := filepath.IsAbs(first) && filepath.IsAbs(second)
  161. count := len(firstParts)
  162. if len(secondParts) < len(firstParts) {
  163. count = len(secondParts)
  164. }
  165. common := make([]string, 0, count)
  166. for i := 0; i < count; i++ {
  167. if firstParts[i] != secondParts[i] {
  168. break
  169. }
  170. common = append(common, firstParts[i])
  171. }
  172. if isAbs {
  173. if build.IsWindows && isVolumeNameOnly(common) {
  174. // Because strings.Split strips out path separators, if we're at the volume name, we end up without a separator
  175. // Wedge an empty element to be joined with.
  176. common = append(common, "")
  177. } else if len(common) == 1 {
  178. // If isAbs on non Windows, first element in both first and second is "", hence joining that returns nothing.
  179. return pathSeparatorString
  180. }
  181. }
  182. // This should only be true on Windows when drive letters are different or when paths are relative.
  183. // In case of UNC paths we should end up with more than a single element hence joining is fine
  184. if len(common) == 0 {
  185. return ""
  186. }
  187. // This has to be strings.Join, because filepath.Join([]string{"", "", "?", "C:", "Audrius"}...) returns garbage
  188. result := strings.Join(common, pathSeparatorString)
  189. return filepath.Clean(result)
  190. }
  191. // PathComponents returns a list of names of parent directories and the leaf
  192. // item for the given native (fs.PathSeparator delimited) and clean path.
  193. func PathComponents(path string) []string {
  194. return strings.Split(path, pathSeparatorString)
  195. }
  196. func isVolumeNameOnly(parts []string) bool {
  197. isNormalVolumeName := len(parts) == 1 && strings.HasSuffix(parts[0], ":")
  198. isUNCVolumeName := len(parts) == 4 && strings.HasSuffix(parts[3], ":")
  199. return isNormalVolumeName || isUNCVolumeName
  200. }