diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index aa0e58d81..5d006d1d7 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 @@ -2199,17 +2209,30 @@ extension URL { #if !NO_FILESYSTEM private static func isDirectory(_ path: String) -> Bool { + guard !path.isEmpty else { return false } #if os(Windows) 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 (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) + 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 } + if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == FILE_ATTRIBUTE_REPARSE_POINT { return false } + return (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == FILE_ATTRIBUTE_DIRECTORY + }) ?? false #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. + return 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..3f7c84507 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 = dirURL.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)