From f6b1473d4134ea25cd0a74764ba4c9b21c5822a3 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Thu, 1 Oct 2026 19:44:07 +0200 Subject: [PATCH 1/3] Preserve UNC roots when normalizing Windows paths --- Sources/ShellKit/Shell+Path.swift | 31 +++++++++----- .../PathNormalizationTests.swift | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 Tests/ShellKitTests/PathNormalizationTests.swift diff --git a/Sources/ShellKit/Shell+Path.swift b/Sources/ShellKit/Shell+Path.swift index ccf277c..ff1586e 100644 --- a/Sources/ShellKit/Shell+Path.swift +++ b/Sources/ShellKit/Shell+Path.swift @@ -18,8 +18,9 @@ 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) @@ -27,28 +28,32 @@ extension Shell { #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, + // 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("//"), saw.count >= 2 { + uncRoot = "//" + saw.prefix(2).joined(separator: "/") + saw = Array(saw.dropFirst(2)) + } else if let first = saw.first, first.count == 2, let firstChar = first.first, firstChar.isLetter, first.last == ":" { + // A leading `C:` segment is a drive root. After we + // record it, walk the rest as absolute beneath it. driveRoot = String(first) saw = Array(saw.dropFirst()) } #endif - let anchored = isUnixAbsolute || driveRoot != nil + let anchored = isUnixAbsolute || driveRoot != nil || uncRoot != nil for seg in saw { switch seg { case ".": @@ -70,6 +75,10 @@ extension Shell { if let driveRoot { return driveRoot + "/" + stack.joined(separator: "/") } + if let uncRoot { + guard !stack.isEmpty else { return uncRoot } + return uncRoot + "/" + stack.joined(separator: "/") + } if isUnixAbsolute { return "/" + stack.joined(separator: "/") } diff --git a/Tests/ShellKitTests/PathNormalizationTests.swift b/Tests/ShellKitTests/PathNormalizationTests.swift new file mode 100644 index 0000000..c001240 --- /dev/null +++ b/Tests/ShellKitTests/PathNormalizationTests.swift @@ -0,0 +1,40 @@ +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") + } + #endif +} From c32c62fbe2dda7feee6afcd9df6ee57bfd6c730a Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Thu, 1 Oct 2026 19:48:10 +0200 Subject: [PATCH 2/3] Refactor Windows path root parsing --- Sources/ShellKit/Shell+Path.swift | 60 ++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/Sources/ShellKit/Shell+Path.swift b/Sources/ShellKit/Shell+Path.swift index ff1586e..7fd9167 100644 --- a/Sources/ShellKit/Shell+Path.swift +++ b/Sources/ShellKit/Shell+Path.swift @@ -37,21 +37,10 @@ extension Shell { var saw: [Substring] = normalized.split( separator: "/", omittingEmptySubsequences: true) #if os(Windows) - // 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("//"), saw.count >= 2 { - uncRoot = "//" + saw.prefix(2).joined(separator: "/") - saw = Array(saw.dropFirst(2)) - } else if let first = saw.first, - first.count == 2, - let firstChar = first.first, firstChar.isLetter, - first.last == ":" { - // A leading `C:` segment is a drive root. After we - // record it, walk the rest as absolute beneath it. - 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 || uncRoot != nil for seg in saw { @@ -76,14 +65,51 @@ extension Shell { return driveRoot + "/" + stack.joined(separator: "/") } if let uncRoot { - guard !stack.isEmpty else { return uncRoot } - return uncRoot + "/" + stack.joined(separator: "/") + 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 { + // 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 { From d139aff01de8838827698878a874e554c2ddc601 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Thu, 1 Oct 2026 19:49:42 +0200 Subject: [PATCH 3/3] Preserve extended UNC share roots --- Sources/ShellKit/Shell+Path.swift | 14 ++++++++++++++ Tests/ShellKitTests/PathNormalizationTests.swift | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/Sources/ShellKit/Shell+Path.swift b/Sources/ShellKit/Shell+Path.swift index 7fd9167..d9c1468 100644 --- a/Sources/ShellKit/Shell+Path.swift +++ b/Sources/ShellKit/Shell+Path.swift @@ -84,6 +84,20 @@ extension Shell { 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. diff --git a/Tests/ShellKitTests/PathNormalizationTests.swift b/Tests/ShellKitTests/PathNormalizationTests.swift index c001240..0489d68 100644 --- a/Tests/ShellKitTests/PathNormalizationTests.swift +++ b/Tests/ShellKitTests/PathNormalizationTests.swift @@ -36,5 +36,14 @@ import Testing #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 }