util.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  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. // The path must not contain any disallowed characters.
  46. if idx := strings.IndexAny(name, windowsDisallowedCharacters); idx != -1 {
  47. return fmt.Errorf("%w: %q", errInvalidFilenameWindowsReservedChar, name[idx:idx+1])
  48. }
  49. // None of the path components should end in space or period, or be a
  50. // reserved name.
  51. for len(name) > 0 {
  52. part, rest, _ := strings.Cut(name, `\`)
  53. name = rest
  54. if part == "" {
  55. continue
  56. }
  57. switch part[len(part)-1] {
  58. case ' ', '.':
  59. // Names ending in space or period are not valid.
  60. return errInvalidFilenameWindowsSpacePeriod
  61. }
  62. if reserved := windowsReservedNamePart(part); reserved != "" {
  63. return fmt.Errorf("%w: %q", errInvalidFilenameWindowsReservedName, reserved)
  64. }
  65. }
  66. return nil
  67. }
  68. // SanitizePath takes a string that might contain all kinds of special
  69. // characters and makes a valid, similar, path name out of it.
  70. //
  71. // Spans of invalid characters, whitespace and/or non-UTF-8 sequences are
  72. // replaced by a single space. The result is always UTF-8 and contains only
  73. // printable characters, as determined by unicode.IsPrint.
  74. //
  75. // Invalid characters are non-printing runes, things not allowed in file names
  76. // in Windows, and common shell metacharacters. Even if asterisks and pipes
  77. // and stuff are allowed on Unixes in general they might not be allowed by
  78. // the filesystem and may surprise the user and cause shell oddness. This
  79. // function is intended for file names we generate on behalf of the user,
  80. // and surprising them with odd shell characters in file names is unkind.
  81. //
  82. // We include whitespace in the invalid characters so that multiple
  83. // whitespace is collapsed to a single space. Additionally, whitespace at
  84. // either end is removed.
  85. //
  86. // If the result is a name disallowed on windows, a hyphen is prepended.
  87. func SanitizePath(path string) string {
  88. var b strings.Builder
  89. const disallowed = `'/\[]{};:!@$%&^#` + windowsDisallowedCharacters
  90. prev := ' '
  91. for _, c := range path {
  92. if !unicode.IsPrint(c) || c == unicode.ReplacementChar ||
  93. strings.ContainsRune(disallowed, c) {
  94. c = ' '
  95. }
  96. if !(c == ' ' && prev == ' ') {
  97. b.WriteRune(c)
  98. }
  99. prev = c
  100. }
  101. path = strings.TrimSpace(b.String())
  102. if reserved := windowsReservedNamePart(path); reserved != "" {
  103. path = "-" + path
  104. }
  105. return path
  106. }
  107. func windowsReservedNamePart(part string) string {
  108. // nul.txt.jpg is also disallowed.
  109. dot := strings.IndexByte(part, '.')
  110. if dot != -1 {
  111. part = part[:dot]
  112. }
  113. // Check length to skip allocating ToUpper.
  114. if len(part) != 3 && len(part) != 4 {
  115. return ""
  116. }
  117. // COM0 and LPT0 are missing from the Microsoft docs,
  118. // but Windows Explorer treats them as invalid too.
  119. // (https://docs.microsoft.com/windows/win32/fileio/naming-a-file)
  120. switch strings.ToUpper(part) {
  121. case "CON", "PRN", "AUX", "NUL",
  122. "COM0", "COM1", "COM2", "COM3", "COM4",
  123. "COM5", "COM6", "COM7", "COM8", "COM9",
  124. "LPT0", "LPT1", "LPT2", "LPT3", "LPT4",
  125. "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
  126. return part
  127. }
  128. return ""
  129. }
  130. // IsParent compares paths purely lexicographically, meaning it returns false
  131. // if path and parent aren't both absolute or relative.
  132. func IsParent(path, parent string) bool {
  133. if parent == path {
  134. // Twice the same root on windows would not be caught at the end.
  135. return false
  136. }
  137. if filepath.IsAbs(path) != filepath.IsAbs(parent) {
  138. return false
  139. }
  140. if parent == "" || parent == "." {
  141. // The empty string is the parent of everything except the empty
  142. // string and ".". (Avoids panic in the last step.)
  143. return path != "" && path != "."
  144. }
  145. if parent == "/" {
  146. // The root is the parent of everything except itself, which would
  147. // not be caught below.
  148. return path != "/"
  149. }
  150. if parent[len(parent)-1] == PathSeparator {
  151. return strings.HasPrefix(path, parent)
  152. }
  153. if !strings.HasPrefix(path, parent) {
  154. return false
  155. }
  156. if len(path) <= len(parent) {
  157. return false
  158. }
  159. return path[len(parent)] == PathSeparator
  160. }
  161. func CommonPrefix(first, second string) string {
  162. if filepath.IsAbs(first) != filepath.IsAbs(second) {
  163. // Whatever
  164. return ""
  165. }
  166. firstParts := PathComponents(filepath.Clean(first))
  167. secondParts := PathComponents(filepath.Clean(second))
  168. isAbs := filepath.IsAbs(first) && filepath.IsAbs(second)
  169. count := len(firstParts)
  170. if len(secondParts) < len(firstParts) {
  171. count = len(secondParts)
  172. }
  173. common := make([]string, 0, count)
  174. for i := range count {
  175. if firstParts[i] != secondParts[i] {
  176. break
  177. }
  178. common = append(common, firstParts[i])
  179. }
  180. if isAbs {
  181. if build.IsWindows && isVolumeNameOnly(common) {
  182. // Because strings.Split strips out path separators, if we're at the volume name, we end up without a separator
  183. // Wedge an empty element to be joined with.
  184. common = append(common, "")
  185. } else if len(common) == 1 {
  186. // If isAbs on non Windows, first element in both first and second is "", hence joining that returns nothing.
  187. return pathSeparatorString
  188. }
  189. }
  190. // This should only be true on Windows when drive letters are different or when paths are relative.
  191. // In case of UNC paths we should end up with more than a single element hence joining is fine
  192. if len(common) == 0 {
  193. return ""
  194. }
  195. // This has to be strings.Join, because filepath.Join([]string{"", "", "?", "C:", "Audrius"}...) returns garbage
  196. result := strings.Join(common, pathSeparatorString)
  197. return filepath.Clean(result)
  198. }
  199. // PathComponents returns a list of names of parent directories and the leaf
  200. // item for the given native (fs.PathSeparator delimited) and clean path.
  201. func PathComponents(path string) []string {
  202. return strings.Split(path, pathSeparatorString)
  203. }
  204. func isVolumeNameOnly(parts []string) bool {
  205. isNormalVolumeName := len(parts) == 1 && strings.HasSuffix(parts[0], ":")
  206. isUNCVolumeName := len(parts) == 4 && strings.HasSuffix(parts[3], ":")
  207. return isNormalVolumeName || isUNCVolumeName
  208. }