Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

URL(filePath:) should resolve Windows drive-relative paths #1044

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
17 changes: 17 additions & 0 deletions Sources/FoundationEssentials/String/String+Internals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ extension String {
}
}
}

/// Returns a string created by calling `GetFullPathNameW` on `self`.
/// If `self` is a relative path, this will resolve against the current directory to return an absolute path.
internal var fullPathName: String? {
return self.withCString(encodedAs: UTF16.self) { pwszPath in
let dwLength: DWORD = GetFullPathNameW(pwszPath, 0, nil, nil)
guard dwLength > 0 else {
return nil
}
return withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(dwLength)) {
guard GetFullPathNameW(pwszPath, DWORD($0.count), $0.baseAddress, nil) > 0 else {
return nil
}
return String(decodingCString: $0.baseAddress!, as: UTF16.self)
}
}
}
}
#endif

Expand Down
103 changes: 43 additions & 60 deletions Sources/FoundationEssentials/URL/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2136,8 +2136,11 @@ extension URL {
}
#endif // FOUNDATION_FRAMEWORK

#if !NO_FILESYSTEM
/// Checks the file system to determine if the path is a directory
private static func isDirectory(_ path: String) -> Bool {
#if NO_FILESYSTEM
return path.utf8.last == ._slash
#else
#if os(Windows)
let path = path.replacing(._slash, with: ._backslash)
#endif
Expand All @@ -2149,56 +2152,64 @@ extension URL {
var isDirectory: ObjCBool = false
_ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
return isDirectory.boolValue
#endif
#endif // !FOUNDATION_FRAMEWORK
#endif // NO_FILESYSTEM
}
#endif // !NO_FILESYSTEM

/// Checks if a file path is absolute and standardizes the inputted file path on Windows
/// Assumes the path only contains `/` as the path separator
/// Checks if a file path is absolute and standardizes the inputted file path
internal static func isAbsolute(standardizing filePath: inout String) -> Bool {
if filePath.utf8.first == ._slash {
#if os(Windows)
filePath = filePath.replacing(._backslash, with: ._slash)
#endif
return true
}
#if os(Windows)
let utf8 = filePath.utf8
guard utf8.count >= 3 else {
#if NO_FILESYSTEM
return false
#elseif os(Windows)
// PathIsRelativeW:
// - true for "path" and "\path"
// - false otherwise (including "C:path")
let isRelative: Bool = filePath.withCString(encodedAs: UTF16.self) { pwszPath in
PathIsRelativeW(pwszPath)
}
if isRelative && filePath.utf8.first != ._backslash {
// e.g. "path" - only case where we won't resolve to an absolute path
filePath = filePath.replacing(._backslash, with: ._slash)
return false
}
// Check if this is a drive letter
let first = utf8.first!
let secondIndex = utf8.index(after: utf8.startIndex)
let second = utf8[secondIndex]
let thirdIndex = utf8.index(after: secondIndex)
let third = utf8[thirdIndex]
let isAbsolute = (
first.isAlpha
&& (second == ._colon || second == ._pipe)
&& third == ._slash
)
if isAbsolute {
// Standardize to "/[drive-letter]:/..."
if second == ._pipe {
var filePathArray = Array(utf8)
filePathArray[1] = ._colon
filePathArray.insert(._slash, at: 0)
filePath = String(decoding: filePathArray, as: UTF8.self)
} else {
filePath = "/" + filePath
}
filePath = filePath.fullPathName ?? filePath
filePath = filePath.replacing(._backslash, with: ._slash)
if filePath.utf8.first != ._slash {
// Prepend a "/" to form an RFC 8089 path
filePath = "/" + filePath
}
return isAbsolute
return true
#else // os(Windows)
#if !NO_FILESYSTEM
// Expand the tilde if present
if filePath.utf8.first == UInt8(ascii: "~") {
filePath = filePath.expandingTildeInPath
}
#endif
// Make sure the expanded path is absolute
return filePath.utf8.first == ._slash
#endif // os(Windows)
}

private static func currentDirectoryOrNil() -> URL? {
#if NO_FILESYSTEM
return nil
#else
let path: String? = FileManager.default.currentDirectoryPath
guard var filePath = path else {
return nil
}
guard URL.isAbsolute(standardizing: &filePath) else {
return nil
}
return URL(filePath: filePath, directoryHint: .isDirectory)
#endif // NO_FILESYSTEM
}

/// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL.
///
/// If an empty string is used for the path, then the path is assumed to be ".".
Expand All @@ -2225,19 +2236,12 @@ extension URL {
#endif // FOUNDATION_FRAMEWORK
var baseURL = base
guard !path.isEmpty else {
#if !NO_FILESYSTEM
baseURL = baseURL ?? .currentDirectoryOrNil()
#endif
self.init(string: "", relativeTo: baseURL)!
return
}

#if os(Windows)
// Convert any "\" to "/" before storing the URL parse info
var filePath = path.replacing(._backslash, with: ._slash)
#else
var filePath = path
#endif

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

let isAbsolute = URL.isAbsolute(standardizing: &filePath)

#if !NO_FILESYSTEM
if !isAbsolute {
baseURL = baseURL ?? .currentDirectoryOrNil()
}
#endif

let isDirectory: Bool
switch directoryHint {
Expand All @@ -2266,7 +2267,6 @@ extension URL {
filePath = filePath._droppingTrailingSlashes
isDirectory = false
case .checkFileSystem:
#if !NO_FILESYSTEM
func absoluteFilePath() -> String {
guard !isAbsolute, let baseURL else {
return filePath
Expand All @@ -2275,9 +2275,6 @@ extension URL {
return URL.fileSystemPath(for: absolutePath)
}
isDirectory = URL.isDirectory(absoluteFilePath())
#else
isDirectory = filePath.utf8.last == ._slash
#endif
case .inferFromPath:
isDirectory = filePath.utf8.last == ._slash
}
Expand Down Expand Up @@ -2571,20 +2568,6 @@ extension URL {

#if !NO_FILESYSTEM
extension URL {
private static func currentDirectoryOrNil() -> URL? {
let path: String? = FileManager.default.currentDirectoryPath
guard var filePath = path else {
return nil
}
#if os(Windows)
filePath = filePath.replacing(._backslash, with: ._slash)
#endif
guard URL.isAbsolute(standardizing: &filePath) else {
return nil
}
return URL(filePath: filePath, directoryHint: .isDirectory)
}

/// The working directory of the current process.
/// Calling this property will issue a `getcwd` syscall.
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
Expand Down
16 changes: 11 additions & 5 deletions Tests/FoundationEssentialsTests/URLTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,18 +345,18 @@ final class URLTests : XCTestCase {
XCTAssertEqual(url.fileSystemPath, "C:/")

url = URL(filePath: #"C:\\\"#, directoryHint: .isDirectory)
XCTAssertEqual(url.absoluteString, "file:///C:///")
XCTAssertEqual(url.path(), "/C:///")
XCTAssertEqual(url.absoluteString, "file:///C:/")
XCTAssertEqual(url.path(), "/C:/")
XCTAssertEqual(url.path, "C:/")
XCTAssertEqual(url.fileSystemPath, "C:/")

url = URL(filePath: #"\C:\"#, directoryHint: .isDirectory)
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)
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")
Expand All @@ -369,10 +369,16 @@ final class URLTests : XCTestCase {
if iter.next() == ._slash,
let driveLetter = iter.next(), driveLetter.isLetter!,
iter.next() == ._colon {
let path = #"\\?\"# + "\(Unicode.Scalar(driveLetter))" + #":\"#
let drive = "\(Unicode.Scalar(driveLetter))"
let path = #"\\?\"# + drive + #":\"#
url = URL(filePath: path, directoryHint: .isDirectory)
XCTAssertEqual(url.path.last, "/")
XCTAssertEqual(url.fileSystemPath.last, "/")

// Test drive-relative path
let driveRelativePath = "\(Unicode.Scalar(driveLetter)):hello"
url = URL(filePath: driveRelativePath)
XCTAssertTrue(url.path.starts(with: "\(drive):/"))
}
}
#endif
Expand Down