Skip to content

Commit 45b3193

Browse files
committed
(139676985) URL(filePath:) should resolve Windows drive-relative paths
1 parent b01f9c6 commit 45b3193

File tree

3 files changed

+71
-65
lines changed

3 files changed

+71
-65
lines changed

Sources/FoundationEssentials/String/String+Internals.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ extension String {
4747
}
4848
}
4949
}
50+
51+
/// Returns a string created by calling `GetFullPathNameW` on `self`.
52+
/// If `self` is a relative path, this will resolve against the current directory to return an absolute path.
53+
internal var fullPathName: String? {
54+
return self.withCString(encodedAs: UTF16.self) { pwszPath in
55+
let dwLength: DWORD = GetFullPathNameW(pwszPath, 0, nil, nil)
56+
guard dwLength > 0 else {
57+
return nil
58+
}
59+
return withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(dwLength)) {
60+
guard GetFullPathNameW(pwszPath, DWORD($0.count), $0.baseAddress, nil) > 0 else {
61+
return nil
62+
}
63+
return String(decodingCString: $0.baseAddress!, as: UTF16.self)
64+
}
65+
}
66+
}
5067
}
5168
#endif
5269

Sources/FoundationEssentials/URL/URL.swift

Lines changed: 43 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,8 +2136,11 @@ extension URL {
21362136
}
21372137
#endif // FOUNDATION_FRAMEWORK
21382138

2139-
#if !NO_FILESYSTEM
2139+
/// Checks the file system to determine if the path is a directory
21402140
private static func isDirectory(_ path: String) -> Bool {
2141+
#if NO_FILESYSTEM
2142+
return path.utf8.last == ._slash
2143+
#else
21412144
#if os(Windows)
21422145
let path = path.replacing(._slash, with: ._backslash)
21432146
#endif
@@ -2149,56 +2152,64 @@ extension URL {
21492152
var isDirectory: ObjCBool = false
21502153
_ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
21512154
return isDirectory.boolValue
2152-
#endif
2155+
#endif // !FOUNDATION_FRAMEWORK
2156+
#endif // NO_FILESYSTEM
21532157
}
2154-
#endif // !NO_FILESYSTEM
21552158

2156-
/// Checks if a file path is absolute and standardizes the inputted file path on Windows
2157-
/// Assumes the path only contains `/` as the path separator
2159+
/// Checks if a file path is absolute and standardizes the inputted file path
21582160
internal static func isAbsolute(standardizing filePath: inout String) -> Bool {
21592161
if filePath.utf8.first == ._slash {
2162+
#if os(Windows)
2163+
filePath = filePath.replacing(._backslash, with: ._slash)
2164+
#endif
21602165
return true
21612166
}
2162-
#if os(Windows)
2163-
let utf8 = filePath.utf8
2164-
guard utf8.count >= 3 else {
2167+
#if NO_FILESYSTEM
2168+
return false
2169+
#elseif os(Windows)
2170+
// PathIsRelativeW:
2171+
// - true for "path" and "\path"
2172+
// - false otherwise (including "C:path")
2173+
let isRelative: Bool = filePath.withCString(encodedAs: UTF16.self) { pwszPath in
2174+
PathIsRelativeW(pwszPath)
2175+
}
2176+
if isRelative && filePath.utf8.first != ._backslash {
2177+
// e.g. "path" - only case where we won't resolve to an absolute path
2178+
filePath = filePath.replacing(._backslash, with: ._slash)
21652179
return false
21662180
}
2167-
// Check if this is a drive letter
2168-
let first = utf8.first!
2169-
let secondIndex = utf8.index(after: utf8.startIndex)
2170-
let second = utf8[secondIndex]
2171-
let thirdIndex = utf8.index(after: secondIndex)
2172-
let third = utf8[thirdIndex]
2173-
let isAbsolute = (
2174-
first.isAlpha
2175-
&& (second == ._colon || second == ._pipe)
2176-
&& third == ._slash
2177-
)
2178-
if isAbsolute {
2179-
// Standardize to "/[drive-letter]:/..."
2180-
if second == ._pipe {
2181-
var filePathArray = Array(utf8)
2182-
filePathArray[1] = ._colon
2183-
filePathArray.insert(._slash, at: 0)
2184-
filePath = String(decoding: filePathArray, as: UTF8.self)
2185-
} else {
2186-
filePath = "/" + filePath
2187-
}
2181+
filePath = filePath.fullPathName ?? filePath
2182+
filePath = filePath.replacing(._backslash, with: ._slash)
2183+
if filePath.utf8.first != ._slash {
2184+
// Prepend a "/" to form an RFC 8089 path
2185+
filePath = "/" + filePath
21882186
}
2189-
return isAbsolute
2187+
return true
21902188
#else // os(Windows)
2191-
#if !NO_FILESYSTEM
21922189
// Expand the tilde if present
21932190
if filePath.utf8.first == UInt8(ascii: "~") {
21942191
filePath = filePath.expandingTildeInPath
21952192
}
2196-
#endif
21972193
// Make sure the expanded path is absolute
21982194
return filePath.utf8.first == ._slash
21992195
#endif // os(Windows)
22002196
}
22012197

2198+
private static func currentDirectoryOrNil() -> URL? {
2199+
#if NO_FILESYSTEM
2200+
return nil
2201+
#else
2202+
let path: String? = FileManager.default.currentDirectoryPath
2203+
guard var filePath = path else {
2204+
return nil
2205+
}
2206+
guard URL.isAbsolute(standardizing: &filePath) else {
2207+
return nil
2208+
}
2209+
return URL(filePath: filePath, directoryHint: .isDirectory)
2210+
#endif // NO_FILESYSTEM
2211+
}
2212+
22022213
/// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL.
22032214
///
22042215
/// If an empty string is used for the path, then the path is assumed to be ".".
@@ -2225,19 +2236,12 @@ extension URL {
22252236
#endif // FOUNDATION_FRAMEWORK
22262237
var baseURL = base
22272238
guard !path.isEmpty else {
2228-
#if !NO_FILESYSTEM
22292239
baseURL = baseURL ?? .currentDirectoryOrNil()
2230-
#endif
22312240
self.init(string: "", relativeTo: baseURL)!
22322241
return
22332242
}
22342243

2235-
#if os(Windows)
2236-
// Convert any "\" to "/" before storing the URL parse info
2237-
var filePath = path.replacing(._backslash, with: ._slash)
2238-
#else
22392244
var filePath = path
2240-
#endif
22412245

22422246
#if FOUNDATION_FRAMEWORK
22432247
// Linked-on-or-after check for apps which incorrectly pass a full
@@ -2251,12 +2255,9 @@ extension URL {
22512255
#endif
22522256

22532257
let isAbsolute = URL.isAbsolute(standardizing: &filePath)
2254-
2255-
#if !NO_FILESYSTEM
22562258
if !isAbsolute {
22572259
baseURL = baseURL ?? .currentDirectoryOrNil()
22582260
}
2259-
#endif
22602261

22612262
let isDirectory: Bool
22622263
switch directoryHint {
@@ -2266,7 +2267,6 @@ extension URL {
22662267
filePath = filePath._droppingTrailingSlashes
22672268
isDirectory = false
22682269
case .checkFileSystem:
2269-
#if !NO_FILESYSTEM
22702270
func absoluteFilePath() -> String {
22712271
guard !isAbsolute, let baseURL else {
22722272
return filePath
@@ -2275,9 +2275,6 @@ extension URL {
22752275
return URL.fileSystemPath(for: absolutePath)
22762276
}
22772277
isDirectory = URL.isDirectory(absoluteFilePath())
2278-
#else
2279-
isDirectory = filePath.utf8.last == ._slash
2280-
#endif
22812278
case .inferFromPath:
22822279
isDirectory = filePath.utf8.last == ._slash
22832280
}
@@ -2571,20 +2568,6 @@ extension URL {
25712568

25722569
#if !NO_FILESYSTEM
25732570
extension URL {
2574-
private static func currentDirectoryOrNil() -> URL? {
2575-
let path: String? = FileManager.default.currentDirectoryPath
2576-
guard var filePath = path else {
2577-
return nil
2578-
}
2579-
#if os(Windows)
2580-
filePath = filePath.replacing(._backslash, with: ._slash)
2581-
#endif
2582-
guard URL.isAbsolute(standardizing: &filePath) else {
2583-
return nil
2584-
}
2585-
return URL(filePath: filePath, directoryHint: .isDirectory)
2586-
}
2587-
25882571
/// The working directory of the current process.
25892572
/// Calling this property will issue a `getcwd` syscall.
25902573
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)

Tests/FoundationEssentialsTests/URLTests.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,18 +345,18 @@ final class URLTests : XCTestCase {
345345
XCTAssertEqual(url.fileSystemPath, "C:/")
346346

347347
url = URL(filePath: #"C:\\\"#, directoryHint: .isDirectory)
348-
XCTAssertEqual(url.absoluteString, "file:///C:///")
349-
XCTAssertEqual(url.path(), "/C:///")
348+
XCTAssertEqual(url.absoluteString, "file:///C:/")
349+
XCTAssertEqual(url.path(), "/C:/")
350350
XCTAssertEqual(url.path, "C:/")
351351
XCTAssertEqual(url.fileSystemPath, "C:/")
352352

353-
url = URL(filePath: #"\C:\"#, directoryHint: .isDirectory)
353+
url = URL(filePath: "/C:/", directoryHint: .isDirectory)
354354
XCTAssertEqual(url.absoluteString, "file:///C:/")
355355
XCTAssertEqual(url.path(), "/C:/")
356356
XCTAssertEqual(url.path, "C:/")
357357
XCTAssertEqual(url.fileSystemPath, "C:/")
358358

359-
let base = URL(filePath: #"\d:\path\"#, directoryHint: .isDirectory)
359+
let base = URL(filePath: #"d:\path\"#, directoryHint: .isDirectory)
360360
url = URL(filePath: #"%43:\fake\letter"#, directoryHint: .notDirectory, relativeTo: base)
361361
// ":" is encoded to "%3A" in the first path segment so it's not mistaken as the scheme separator
362362
XCTAssertEqual(url.relativeString, "%2543%3A/fake/letter")
@@ -369,10 +369,16 @@ final class URLTests : XCTestCase {
369369
if iter.next() == ._slash,
370370
let driveLetter = iter.next(), driveLetter.isLetter!,
371371
iter.next() == ._colon {
372-
let path = #"\\?\"# + "\(Unicode.Scalar(driveLetter))" + #":\"#
372+
let drive = "\(Unicode.Scalar(driveLetter))"
373+
let path = #"\\?\"# + drive + #":\"#
373374
url = URL(filePath: path, directoryHint: .isDirectory)
374375
XCTAssertEqual(url.path.last, "/")
375376
XCTAssertEqual(url.fileSystemPath.last, "/")
377+
378+
// Test drive-relative path
379+
let driveRelativePath = "\(Unicode.Scalar(driveLetter)):hello"
380+
url = URL(filePath: driveRelativePath)
381+
XCTAssertTrue(url.path.starts(with: "\(drive):/"))
376382
}
377383
}
378384
#endif

0 commit comments

Comments
 (0)