Skip to content
Merged
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
83 changes: 66 additions & 17 deletions Sources/ShellKit/Shell+Path.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,31 @@ extension Shell {
///
/// On Windows, backslashes are normalised to forward slashes
/// up front (Win32 path APIs accept both). Drive-letter paths
/// keep their `C:` prefix as the root segment so the result is
/// still a valid Windows path: `C:\Users\foo\..\bar` → `C:/Users/bar`.
/// keep their `C:` prefix as the root segment, and UNC paths keep
/// their `//server/share` root, so each result remains a valid
/// Windows path: `C:\Users\foo\..\bar` → `C:/Users/bar`.
public static func normalizePath(_ path: String) -> String {
guard !path.isEmpty else { return "" }
#if os(Windows)
let normalized = path.replacingOccurrences(of: "\\", with: "/")
#else
let normalized = path
#endif
// Split on `/`, tracking whether the path is anchored at the
// root (Unix `/foo`) or at a drive (Windows `C:/foo`). For a
// drive-letter path we keep the `C:` segment in the stack so
// the rebuilt string still stems from that drive.
// Split on `/`, tracking whether the path is anchored at a
// Unix root, a Windows drive, or a Windows UNC share.
let isUnixAbsolute = normalized.hasPrefix("/")
var stack: [String] = []
var driveRoot: String?
var uncRoot: String?
var saw: [Substring] = normalized.split(
separator: "/", omittingEmptySubsequences: true)
#if os(Windows)
// Detect a leading `C:` segment (drive root). After we
// record it, the rest of the segments are walked as if the
// path were absolute beneath that drive.
if let first = saw.first,
first.count == 2,
let firstChar = first.first, firstChar.isLetter,
first.last == ":" {
driveRoot = String(first)
saw = Array(saw.dropFirst())
}
let windowsRoot = Self.windowsRoot(for: normalized, segments: saw)
driveRoot = windowsRoot.drive
uncRoot = windowsRoot.unc
saw = windowsRoot.remaining
#endif
let anchored = isUnixAbsolute || driveRoot != nil
let anchored = isUnixAbsolute || driveRoot != nil || uncRoot != nil
for seg in saw {
switch seg {
case ".":
Expand All @@ -70,11 +64,66 @@ extension Shell {
if let driveRoot {
return driveRoot + "/" + stack.joined(separator: "/")
}
if let uncRoot {
return uncRoot + (stack.isEmpty ? "" : "/" + stack.joined(separator: "/"))
}
if isUnixAbsolute {
return "/" + stack.joined(separator: "/")
}
return stack.isEmpty ? "." : stack.joined(separator: "/")
}

#if os(Windows)
private struct WindowsRoot {
let drive: String?
let unc: String?
let remaining: [Substring]
}

private static func windowsRoot(
for normalized: String,
segments: [Substring]
) -> WindowsRoot {
// Extended UNC paths put the server and share after the
// `//?/UNC` namespace marker, making all four segments the
// indivisible root.
if normalized.hasPrefix("//"),
segments.count >= 4,
segments[0] == "?",
segments[1].lowercased() == "unc" {
let unc = "//" + segments.prefix(4).joined(separator: "/")
return WindowsRoot(
drive: nil,
unc: unc,
remaining: Array(segments.dropFirst(4)))
}

// A UNC server and share form an indivisible root. Remove
// both from the segments we walk so `..` cannot escape the
// share, then restore the double-slash prefix on rebuild.
if normalized.hasPrefix("//"), segments.count >= 2 {
let unc = "//" + segments.prefix(2).joined(separator: "/")
return WindowsRoot(
drive: nil,
unc: unc,
remaining: Array(segments.dropFirst(2)))
}

// A leading `C:` segment is a drive root. After we record it,
// walk the rest as absolute beneath it.
if let first = segments.first,
first.count == 2,
let firstChar = first.first, firstChar.isLetter,
first.last == ":" {
return WindowsRoot(
drive: String(first),
unc: nil,
remaining: Array(segments.dropFirst()))
}

return WindowsRoot(drive: nil, unc: nil, remaining: segments)
}
#endif
}

extension Shell {
Expand Down
49 changes: 49 additions & 0 deletions Tests/ShellKitTests/PathNormalizationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Testing
@testable import ShellKit

@Suite struct PathNormalizationTests {

@Test func preservesEmptyPath() {
#expect(Shell.normalizePath("") == "")
}

@Test func normalizesRelativePathSegments() {
#expect(Shell.normalizePath("foo/./bar/../baz") == "foo/baz")
#expect(Shell.normalizePath("foo//bar/") == "foo/bar")
#expect(Shell.normalizePath("../../foo/../bar") == "../../bar")
}

@Test func normalizesAbsolutePathSegments() {
#expect(Shell.normalizePath("/foo/./bar/../baz") == "/foo/baz")
#expect(Shell.normalizePath("/../../foo") == "/foo")
#expect(Shell.normalizePath("/") == "/")
}

#if !os(Windows)
@Test func collapsesMultipleLeadingSlashesOnPOSIX() {
#expect(Shell.normalizePath("//foo///bar") == "/foo/bar")
}
#endif

#if os(Windows)
@Test func preservesDriveRoot() {
#expect(Shell.normalizePath(#"C:\Users\foo\..\bar"#) == "C:/Users/bar")
#expect(Shell.normalizePath(#"C:\.."#) == "C:/")
}

@Test func preservesUNCRoot() {
#expect(Shell.normalizePath(#"\\server\share\x\.."#) == "//server/share")
#expect(Shell.normalizePath("//server/share/file") == "//server/share/file")
#expect(Shell.normalizePath("//server/share/../../file") == "//server/share/file")
}

@Test func preservesExtendedUNCRoot() {
#expect(
Shell.normalizePath(#"\\?\UNC\server\share\dir\.."#)
== "//?/UNC/server/share")
#expect(
Shell.normalizePath(#"\\?\UNC\server\share\..\file"#)
== "//?/UNC/server/share/file")
}
#endif
}
Loading