util.go 6.5 KB

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