diff --git a/Sources/System/FilePath/FilePathParsing.swift b/Sources/System/FilePath/FilePathParsing.swift index f372dd67..a7064c63 100644 --- a/Sources/System/FilePath/FilePathParsing.swift +++ b/Sources/System/FilePath/FilePathParsing.swift @@ -132,7 +132,11 @@ extension FilePath { defer { assert(isLexicallyNormal) } let relStart = _relativeStart - let hasRoot = relStart != _storage.startIndex + + // Whether a leading `..` collapses against a fixed root. A traditional + // drive-relative root such as `C:` does not anchor leading `..`, so those + // are preserved exactly as for a rootless relative path. + let rootAnchorsLeadingParents = _rootAnchorsLeadingParents // TODO: all this logic might be nicer if _parseComponent considered // the null character index to be the next start... @@ -153,8 +157,8 @@ extension FilePath { // otherwise parse-back a component to remove the parent (but stop at // root). if _isParentDirectory(component) { - // Skip over it if we're at the root - if hasRoot && writeIdx == relStart { + // Skip over it if it collapses against a fixed root + if rootAnchorsLeadingParents && writeIdx == relStart { readIdx = nextStart continue } @@ -168,7 +172,7 @@ extension FilePath { readIdx = nextStart continue } - assert(self.root == nil && self.components.first!.kind == .parentDirectory) + assert(!rootAnchorsLeadingParents && self.components.first!.kind == .parentDirectory) } } @@ -205,6 +209,16 @@ extension FilePath { internal var _hasRoot: Bool { _relativeStart != _storage.startIndex } + + // Whether a leading `..` component refers to the parent of a fixed anchor + // (and therefore collapses during lexical normalization), as opposed to + // being preserved. This is true when the path has a root, unless that root + // is a traditional drive-relative root such as `C:`, which anchors to the + // drive's current directory rather than to a fixed location. + internal var _rootAnchorsLeadingParents: Bool { + guard let root = self.root else { return false } + return !root._isTraditionalDriveRelative + } } // Parse separators @@ -321,6 +335,21 @@ extension FilePath.Root { (slice.count == 2 && slice.last == .colon)) return false } + + // Whether this is a traditional drive-relative root, i.e. `X:` with no + // trailing separator. + // + // Such a root anchors the path to the current working directory *on* the + // named drive rather than to a fixed directory, so a leading `..` is + // meaningful and must not be collapsed during lexical normalization + // (`C:..` is the parent of the drive's current directory). This is unlike + // `\` and `C:\`, where a leading `..` refers to the parent of a fixed root + // and collapses to the root itself. + internal var _isTraditionalDriveRelative: Bool { + guard _windowsPaths else { return false } + let slice = self._slice + return slice.count == 2 && slice.last == .colon + } } @available(System 0.0.1, *) diff --git a/Sources/System/FilePath/FilePathSyntax.swift b/Sources/System/FilePath/FilePathSyntax.swift index eb1f25df..8ed01da5 100644 --- a/Sources/System/FilePath/FilePathSyntax.swift +++ b/Sources/System/FilePath/FilePathSyntax.swift @@ -360,13 +360,14 @@ extension FilePath { /// * `"../local/bin".isLexicallyNormal == true` /// * `"local/bin/..".isLexicallyNormal == false` public var isLexicallyNormal: Bool { - // `..` components are permitted at the front of a - // relative path, otherwise there should be no special directories - // - // FIXME: Windows `C:..\foo\bar` should probably be lexically normal, but - // `\..\foo\bar` should not. - components.drop( - while: { root == nil && $0.kind == .parentDirectory } + // `..` components are permitted at the front of a path that is not anchored + // to a fixed root, otherwise there should be no special directories. On + // Windows, a traditional drive-relative root such as `C:` does not anchor + // leading `..` (e.g. `C:..\foo\bar` is lexically normal), whereas a rooted + // path such as `\..\foo\bar` is not. + let preservesLeadingParents = !_rootAnchorsLeadingParents + return components.drop( + while: { preservesLeadingParents && $0.kind == .parentDirectory } ).allSatisfy { $0.kind == .regular } } diff --git a/Tests/SystemTests/FilePathTests/FilePathSyntaxTest.swift b/Tests/SystemTests/FilePathTests/FilePathSyntaxTest.swift index 0fdfbc27..3f3e88f3 100644 --- a/Tests/SystemTests/FilePathTests/FilePathSyntaxTest.swift +++ b/Tests/SystemTests/FilePathTests/FilePathSyntaxTest.swift @@ -651,7 +651,86 @@ final class FilePathSyntaxTest: XCTestCase { root: #"C:"#, relative: #"foo\bar\..\..\.."#, dirname: #"C:foo\bar\..\.."#, basename: "..", components: ["foo", "bar", "..", "..", ".."], - lexicallyNormalized: "C:" + // `C:` is drive-relative, so the `..` that escapes the relative + // portion is preserved rather than collapsed against the root. + lexicallyNormalized: #"C:.."# + ), + + // A `..` at the front of a drive-relative path refers to the parent of + // the drive's current directory, so it is preserved during lexical + // normalization (unlike a `..` at the front of a rooted `\` path). + .windows( + #"C:.."#, + absolute: false, + root: #"C:"#, relative: #".."#, + dirname: #"C:"#, basename: "..", + components: [".."], + lexicallyNormalized: #"C:.."# + ), + + .windows( + #"C:..\foo\bar"#, + absolute: false, + root: #"C:"#, relative: #"..\foo\bar"#, + dirname: #"C:..\foo"#, basename: "bar", + components: ["..", "foo", "bar"], + lexicallyNormalized: #"C:..\foo\bar"# + ), + + .windows( + #"C:..\..\foo"#, + absolute: false, + root: #"C:"#, relative: #"..\..\foo"#, + dirname: #"C:..\.."#, basename: "foo", + components: ["..", "..", "foo"], + lexicallyNormalized: #"C:..\..\foo"# + ), + + .windows( + #"C:foo\..\..\bar"#, + absolute: false, + root: #"C:"#, relative: #"foo\..\..\bar"#, + dirname: #"C:foo\..\.."#, basename: "bar", + components: ["foo", "..", "..", "bar"], + lexicallyNormalized: #"C:..\bar"# + ), + + .windows( + #"C:.\..\foo"#, + absolute: false, + root: #"C:"#, relative: #".\..\foo"#, + dirname: #"C:.\.."#, basename: "foo", + components: [".", "..", "foo"], + lexicallyNormalized: #"C:..\foo"# + ), + + .windows( + #"C:..\.\..\bar"#, + absolute: false, + root: #"C:"#, relative: #"..\.\..\bar"#, + dirname: #"C:..\.\.."#, basename: "bar", + components: ["..", ".", "..", "bar"], + lexicallyNormalized: #"C:..\..\bar"# + ), + + // In contrast, `\` anchors to the root of the current drive, so a + // leading `..` collapses against it rather than being preserved. + .windows( + #"\..\foo\bar"#, + absolute: false, + root: #"\"#, relative: #"..\foo\bar"#, + dirname: #"\..\foo"#, basename: "bar", + components: ["..", "foo", "bar"], + lexicallyNormalized: #"\foo\bar"# + ), + + .windows( + #"\..\..\foo"#, + absolute: false, + root: #"\"#, relative: #"..\..\foo"#, + dirname: #"\..\.."#, basename: "foo", + components: ["..", "..", "foo"], + lexicallyNormalized: #"\foo"# ), .windows(