Skip to content

Commit 13c8aeb

Browse files
authored
URL.path should not strip trailing slash for root paths on Windows (#1038)
* (139379934) URL should not strip trailing slash for root paths on Windows * Use PathIsRootW to ensure we don't strip a root slash
1 parent 8455ab0 commit 13c8aeb

File tree

2 files changed

+72
-19
lines changed

2 files changed

+72
-19
lines changed

Sources/FoundationEssentials/URL/URL.swift

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
public struct URLResourceKey {}
1414
#endif
1515

16+
#if os(Windows)
17+
import WinSDK
18+
#endif
19+
1620
#if FOUNDATION_FRAMEWORK
1721
internal import _ForSwiftFoundation
1822
internal import CoreFoundation_Private.CFURL
@@ -1357,31 +1361,43 @@ public struct URL: Equatable, Sendable, Hashable {
13571361
}
13581362
}
13591363

1360-
private static func windowsPath(for posixPath: String) -> String {
1361-
let utf8 = posixPath.utf8
1362-
guard utf8.count >= 4 else {
1363-
return posixPath
1364+
#if os(Windows)
1365+
private static func windowsPath(for urlPath: String) -> String {
1366+
var iter = urlPath.utf8.makeIterator()
1367+
guard iter.next() == ._slash else {
1368+
return decodeFilePath(urlPath._droppingTrailingSlashes)
1369+
}
1370+
// "C:\" is standardized to "/C:/" on initialization.
1371+
if let driveLetter = iter.next(), driveLetter.isAlpha,
1372+
iter.next() == ._colon,
1373+
iter.next() == ._slash {
1374+
// Strip trailing slashes from the path, which preserves a root "/".
1375+
let path = String(Substring(urlPath.utf8.dropFirst(3)))._droppingTrailingSlashes
1376+
// Don't include a leading slash before the drive letter
1377+
return "\(Unicode.Scalar(driveLetter)):\(decodeFilePath(path))"
13641378
}
1365-
// "C:\" is standardized to "/C:/" on initialization
1366-
let array = Array(utf8)
1367-
if array[0] == ._slash,
1368-
array[1].isAlpha,
1369-
array[2] == ._colon,
1370-
array[3] == ._slash {
1371-
return String(Substring(utf8.dropFirst()))
1379+
// There are many flavors of UNC paths, so use PathIsRootW to ensure
1380+
// we don't strip a trailing slash that represents a root.
1381+
let path = decodeFilePath(urlPath)
1382+
return path.replacing(._slash, with: ._backslash).withCString(encodedAs: UTF16.self) { pwszPath in
1383+
guard !PathIsRootW(pwszPath) else {
1384+
return path
1385+
}
1386+
return path._droppingTrailingSlashes
13721387
}
1373-
return posixPath
13741388
}
1389+
#endif
13751390

1376-
private static func fileSystemPath(for urlPath: String) -> String {
1391+
private static func decodeFilePath(_ path: some StringProtocol) -> String {
13771392
let charsToLeaveEncoded: Set<UInt8> = [._slash, 0]
1378-
guard let posixPath = Parser.percentDecode(urlPath._droppingTrailingSlashes, excluding: charsToLeaveEncoded) else {
1379-
return ""
1380-
}
1393+
return Parser.percentDecode(path, excluding: charsToLeaveEncoded) ?? ""
1394+
}
1395+
1396+
private static func fileSystemPath(for urlPath: String) -> String {
13811397
#if os(Windows)
1382-
return windowsPath(for: posixPath)
1398+
return windowsPath(for: urlPath)
13831399
#else
1384-
return posixPath
1400+
return decodeFilePath(urlPath._droppingTrailingSlashes)
13851401
#endif
13861402
}
13871403

Tests/FoundationEssentialsTests/URLTests.swift

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,13 +340,50 @@ final class URLTests : XCTestCase {
340340

341341
#if os(Windows)
342342
func testURLWindowsDriveLetterPath() throws {
343-
let url = URL(filePath: "C:\\test\\path", directoryHint: .notDirectory)
343+
var url = URL(filePath: #"C:\test\path"#, directoryHint: .notDirectory)
344344
// .absoluteString and .path() use the RFC 8089 URL path
345345
XCTAssertEqual(url.absoluteString, "file:///C:/test/path")
346346
XCTAssertEqual(url.path(), "/C:/test/path")
347347
// .path and .fileSystemPath strip the leading slash
348348
XCTAssertEqual(url.path, "C:/test/path")
349349
XCTAssertEqual(url.fileSystemPath, "C:/test/path")
350+
351+
url = URL(filePath: #"C:\"#, directoryHint: .isDirectory)
352+
XCTAssertEqual(url.absoluteString, "file:///C:/")
353+
XCTAssertEqual(url.path(), "/C:/")
354+
XCTAssertEqual(url.path, "C:/")
355+
XCTAssertEqual(url.fileSystemPath, "C:/")
356+
357+
url = URL(filePath: #"C:\\\"#, directoryHint: .isDirectory)
358+
XCTAssertEqual(url.absoluteString, "file:///C:///")
359+
XCTAssertEqual(url.path(), "/C:///")
360+
XCTAssertEqual(url.path, "C:/")
361+
XCTAssertEqual(url.fileSystemPath, "C:/")
362+
363+
url = URL(filePath: #"\C:\"#, directoryHint: .isDirectory)
364+
XCTAssertEqual(url.absoluteString, "file:///C:/")
365+
XCTAssertEqual(url.path(), "/C:/")
366+
XCTAssertEqual(url.path, "C:/")
367+
XCTAssertEqual(url.fileSystemPath, "C:/")
368+
369+
let base = URL(filePath: #"\d:\path\"#, directoryHint: .isDirectory)
370+
url = URL(filePath: #"%43:\fake\letter"#, directoryHint: .notDirectory, relativeTo: base)
371+
// ":" is encoded to "%3A" in the first path segment so it's not mistaken as the scheme separator
372+
XCTAssertEqual(url.relativeString, "%2543%3A/fake/letter")
373+
XCTAssertEqual(url.path(), "/d:/path/%2543%3A/fake/letter")
374+
XCTAssertEqual(url.path, "d:/path/%43:/fake/letter")
375+
XCTAssertEqual(url.fileSystemPath, "d:/path/%43:/fake/letter")
376+
377+
let cwd = URL.currentDirectory()
378+
var iter = cwd.path().utf8.makeIterator()
379+
if iter.next() == ._slash,
380+
let driveLetter = iter.next(), driveLetter.isLetter!,
381+
iter.next() == ._colon {
382+
let path = #"\\?\"# + "\(Unicode.Scalar(driveLetter))" + #":\"#
383+
url = URL(filePath: path, directoryHint: .isDirectory)
384+
XCTAssertEqual(url.path.last, "/")
385+
XCTAssertEqual(url.fileSystemPath.last, "/")
386+
}
350387
}
351388
#endif
352389

0 commit comments

Comments
 (0)