From 468bf514162818a4f70ab275b79812d717d4b9c8 Mon Sep 17 00:00:00 2001 From: Jonathan Flat Date: Wed, 5 Mar 2025 16:51:15 -0800 Subject: [PATCH 1/4] (145233243) Use lstat() semantics for URL directory detection --- Sources/FoundationEssentials/URL/URL.swift | 40 ++++++++++++++----- .../FoundationEssentialsTests/URLTests.swift | 38 ++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index aa0e58d81..b679eb98a 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -13,8 +13,18 @@ public struct URLResourceKey {} #endif -#if os(Windows) +#if canImport(Darwin) +import Darwin +#elseif canImport(Android) +@preconcurrency import Android +#elseif canImport(Glibc) +@preconcurrency import Glibc +#elseif canImport(Musl) +@preconcurrency import Musl +#elseif os(Windows) import WinSDK +#elseif os(WASI) +@preconcurrency import WASILibc #endif #if FOUNDATION_FRAMEWORK @@ -2200,16 +2210,28 @@ extension URL { #if !NO_FILESYSTEM private static func isDirectory(_ path: String) -> Bool { #if os(Windows) + guard !path.isEmpty else { return false } let path = path.replacing(._slash, with: ._backslash) - #endif - #if !FOUNDATION_FRAMEWORK - var isDirectory: Bool = false - _ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) - return isDirectory + return path.withNTPathRepresentation { pwszPath in + // If path points to a symlink (reparse point), get a handle to + // the symlink itself using FILE_FLAG_OPEN_REPARSE_POINT. + let handle = CreateFileW(pwszPath, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nil) + guard handle != INVALID_HANDLE_VALUE else { return false } + defer { CloseHandle(handle) } + var info: BY_HANDLE_FILE_INFORMATION = BY_HANDLE_FILE_INFORMATION() + guard GetFileInformationByHandle(handle, &info) else { return false } + return (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == FILE_ATTRIBUTE_DIRECTORY + } #else - var isDirectory: ObjCBool = false - _ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) - return isDirectory.boolValue + // FileManager uses stat() to check if the file exists. + // URL historically won't follow a symlink at the end + // of the path, so use lstat() here instead. + path.withFileSystemRepresentation { fsRep in + guard let fsRep else { return false } + var fileInfo = stat() + guard lstat(fsRep, &fileInfo) == 0 else { return false } + return (mode_t(fileInfo.st_mode) & S_IFMT) == S_IFDIR + } #endif } #endif // !NO_FILESYSTEM diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 049285462..440e59655 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -399,6 +399,44 @@ final class URLTests : XCTestCase { } } + func testURLFilePathDoesNotFollowLastSymlink() throws { + try FileManagerPlayground { + Directory("dir") { + "Foo" + SymbolicLink("symlink", destination: "../dir") + } + }.test { + let currentDirectoryPath = $0.currentDirectoryPath + let baseURL = URL(filePath: currentDirectoryPath, directoryHint: .isDirectory) + + let dirURL = baseURL.appending(path: "dir", directoryHint: .checkFileSystem) + XCTAssertTrue(dirURL.hasDirectoryPath) + + var symlinkURL = dirURL.appending(path: "symlink", directoryHint: .notDirectory) + + // FileManager uses stat(), which will follow the symlink to the directory. + + #if FOUNDATION_FRAMEWORK + var isDirectory: ObjCBool = false + XCTAssertTrue(FileManager.default.fileExists(atPath: symlinkURL.path, isDirectory: &isDirectory)) + XCTAssertTrue(isDirectory.boolValue) + #else + var isDirectory = false + XCTAssertTrue(FileManager.default.fileExists(atPath: symlinkURL.path, isDirectory: &isDirectory)) + XCTAssertTrue(isDirectory) + #endif + + // URL uses lstat(), which will not follow the symlink at the end of the path. + // Check that URL(filePath:) and .appending(path:) preserve this behavior. + + symlinkURL = URL(filePath: symlinkURL.path, directoryHint: .checkFileSystem) + XCTAssertFalse(symlinkURL.hasDirectoryPath) + + symlinkURL = baseURL.appending(path: "symlink", directoryHint: .checkFileSystem) + XCTAssertFalse(symlinkURL.hasDirectoryPath) + } + } + func testURLRelativeDotDotResolution() throws { let baseURL = URL(filePath: "/docs/src/") var result = URL(filePath: "../images/foo.png", relativeTo: baseURL) From df086e410de9da867acf68eae5129be7ec5a7c5b Mon Sep 17 00:00:00 2001 From: Jonathan Flat Date: Thu, 6 Mar 2025 11:30:08 -0800 Subject: [PATCH 2/4] Fix Windows try?, fix symlink path in test --- Sources/FoundationEssentials/URL/URL.swift | 6 +++--- Tests/FoundationEssentialsTests/URLTests.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index b679eb98a..b186775b5 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -2212,7 +2212,7 @@ extension URL { #if os(Windows) guard !path.isEmpty else { return false } let path = path.replacing(._slash, with: ._backslash) - return path.withNTPathRepresentation { pwszPath in + return (try? path.withNTPathRepresentation { pwszPath in // If path points to a symlink (reparse point), get a handle to // the symlink itself using FILE_FLAG_OPEN_REPARSE_POINT. let handle = CreateFileW(pwszPath, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nil) @@ -2221,12 +2221,12 @@ extension URL { var info: BY_HANDLE_FILE_INFORMATION = BY_HANDLE_FILE_INFORMATION() guard GetFileInformationByHandle(handle, &info) else { return false } return (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == FILE_ATTRIBUTE_DIRECTORY - } + }) ?? false #else // FileManager uses stat() to check if the file exists. // URL historically won't follow a symlink at the end // of the path, so use lstat() here instead. - path.withFileSystemRepresentation { fsRep in + return path.withFileSystemRepresentation { fsRep in guard let fsRep else { return false } var fileInfo = stat() guard lstat(fsRep, &fileInfo) == 0 else { return false } diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 440e59655..3f7c84507 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -432,7 +432,7 @@ final class URLTests : XCTestCase { symlinkURL = URL(filePath: symlinkURL.path, directoryHint: .checkFileSystem) XCTAssertFalse(symlinkURL.hasDirectoryPath) - symlinkURL = baseURL.appending(path: "symlink", directoryHint: .checkFileSystem) + symlinkURL = dirURL.appending(path: "symlink", directoryHint: .checkFileSystem) XCTAssertFalse(symlinkURL.hasDirectoryPath) } } From 4410d427e94c1b657800f5413d3d0e203385273f Mon Sep 17 00:00:00 2001 From: Jonathan Flat Date: Thu, 6 Mar 2025 12:30:43 -0800 Subject: [PATCH 3/4] Check for reparse point flag explicitly --- Sources/FoundationEssentials/URL/URL.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index b186775b5..71a028afd 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -2220,6 +2220,7 @@ extension URL { defer { CloseHandle(handle) } var info: BY_HANDLE_FILE_INFORMATION = BY_HANDLE_FILE_INFORMATION() guard GetFileInformationByHandle(handle, &info) else { return false } + if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == FILE_ATTRIBUTE_REPARSE_POINT { return false } return (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == FILE_ATTRIBUTE_DIRECTORY }) ?? false #else From efa49323e34aecba96afc261b4fb130b6780d3ad Mon Sep 17 00:00:00 2001 From: Jonathan Flat Date: Fri, 7 Mar 2025 13:21:07 -0700 Subject: [PATCH 4/4] Move path.isEmpty check outside --- Sources/FoundationEssentials/URL/URL.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 71a028afd..5d006d1d7 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -2209,8 +2209,8 @@ extension URL { #if !NO_FILESYSTEM private static func isDirectory(_ path: String) -> Bool { - #if os(Windows) guard !path.isEmpty else { return false } + #if os(Windows) let path = path.replacing(._slash, with: ._backslash) return (try? path.withNTPathRepresentation { pwszPath in // If path points to a symlink (reparse point), get a handle to