Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions Sources/System/FilePath/FilePathParsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand All @@ -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
}
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, *)
Expand Down
15 changes: 8 additions & 7 deletions Sources/System/FilePath/FilePathSyntax.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down
81 changes: 80 additions & 1 deletion Tests/SystemTests/FilePathTests/FilePathSyntaxTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading