diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index cd1549e58..8d08beacd 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -13,6 +13,10 @@ public struct URLResourceKey {} #endif +#if os(Windows) +import WinSDK +#endif + #if FOUNDATION_FRAMEWORK internal import _ForSwiftFoundation internal import CoreFoundation_Private.CFURL @@ -1349,31 +1353,43 @@ public struct URL: Equatable, Sendable, Hashable { } } - private static func windowsPath(for posixPath: String) -> String { - let utf8 = posixPath.utf8 - guard utf8.count >= 4 else { - return posixPath + #if os(Windows) + private static func windowsPath(for urlPath: String) -> String { + var iter = urlPath.utf8.makeIterator() + guard iter.next() == ._slash else { + return decodeFilePath(urlPath._droppingTrailingSlashes) + } + // "C:\" is standardized to "/C:/" on initialization. + if let driveLetter = iter.next(), driveLetter.isAlpha, + iter.next() == ._colon, + iter.next() == ._slash { + // Strip trailing slashes from the path, which preserves a root "/". + let path = String(Substring(urlPath.utf8.dropFirst(3)))._droppingTrailingSlashes + // Don't include a leading slash before the drive letter + return "\(Unicode.Scalar(driveLetter)):\(decodeFilePath(path))" } - // "C:\" is standardized to "/C:/" on initialization - let array = Array(utf8) - if array[0] == ._slash, - array[1].isAlpha, - array[2] == ._colon, - array[3] == ._slash { - return String(Substring(utf8.dropFirst())) + // There are many flavors of UNC paths, so use PathIsRootW to ensure + // we don't strip a trailing slash that represents a root. + let path = decodeFilePath(urlPath) + return path.replacing(._slash, with: ._backslash).withCString(encodedAs: UTF16.self) { pwszPath in + guard !PathIsRootW(pwszPath) else { + return path + } + return path._droppingTrailingSlashes } - return posixPath } + #endif - private static func fileSystemPath(for urlPath: String) -> String { + private static func decodeFilePath(_ path: some StringProtocol) -> String { let charsToLeaveEncoded: Set = [._slash, 0] - guard let posixPath = Parser.percentDecode(urlPath._droppingTrailingSlashes, excluding: charsToLeaveEncoded) else { - return "" - } + return Parser.percentDecode(path, excluding: charsToLeaveEncoded) ?? "" + } + + private static func fileSystemPath(for urlPath: String) -> String { #if os(Windows) - return windowsPath(for: posixPath) + return windowsPath(for: urlPath) #else - return posixPath + return decodeFilePath(urlPath._droppingTrailingSlashes) #endif } diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index e6834886f..0b7a3e649 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -340,13 +340,50 @@ final class URLTests : XCTestCase { #if os(Windows) func testURLWindowsDriveLetterPath() throws { - let url = URL(filePath: "C:\\test\\path", directoryHint: .notDirectory) + var url = URL(filePath: #"C:\test\path"#, directoryHint: .notDirectory) // .absoluteString and .path() use the RFC 8089 URL path XCTAssertEqual(url.absoluteString, "file:///C:/test/path") XCTAssertEqual(url.path(), "/C:/test/path") // .path and .fileSystemPath strip the leading slash XCTAssertEqual(url.path, "C:/test/path") XCTAssertEqual(url.fileSystemPath, "C:/test/path") + + url = URL(filePath: #"C:\"#, directoryHint: .isDirectory) + XCTAssertEqual(url.absoluteString, "file:///C:/") + XCTAssertEqual(url.path(), "/C:/") + XCTAssertEqual(url.path, "C:/") + XCTAssertEqual(url.fileSystemPath, "C:/") + + url = URL(filePath: #"C:\\\"#, directoryHint: .isDirectory) + XCTAssertEqual(url.absoluteString, "file:///C:///") + XCTAssertEqual(url.path(), "/C:///") + XCTAssertEqual(url.path, "C:/") + XCTAssertEqual(url.fileSystemPath, "C:/") + + url = URL(filePath: #"\C:\"#, directoryHint: .isDirectory) + XCTAssertEqual(url.absoluteString, "file:///C:/") + XCTAssertEqual(url.path(), "/C:/") + XCTAssertEqual(url.path, "C:/") + XCTAssertEqual(url.fileSystemPath, "C:/") + + let base = URL(filePath: #"\d:\path\"#, directoryHint: .isDirectory) + url = URL(filePath: #"%43:\fake\letter"#, directoryHint: .notDirectory, relativeTo: base) + // ":" is encoded to "%3A" in the first path segment so it's not mistaken as the scheme separator + XCTAssertEqual(url.relativeString, "%2543%3A/fake/letter") + XCTAssertEqual(url.path(), "/d:/path/%2543%3A/fake/letter") + XCTAssertEqual(url.path, "d:/path/%43:/fake/letter") + XCTAssertEqual(url.fileSystemPath, "d:/path/%43:/fake/letter") + + let cwd = URL.currentDirectory() + var iter = cwd.path().utf8.makeIterator() + if iter.next() == ._slash, + let driveLetter = iter.next(), driveLetter.isLetter!, + iter.next() == ._colon { + let path = #"\\?\"# + "\(Unicode.Scalar(driveLetter))" + #":\"# + url = URL(filePath: path, directoryHint: .isDirectory) + XCTAssertEqual(url.path.last, "/") + XCTAssertEqual(url.fileSystemPath.last, "/") + } } #endif