Skip to content

Commit f751db3

Browse files
authored
Use lstat() semantics for URL directory detection (#1204)
* (145233243) Use lstat() semantics for URL directory detection * Fix Windows try?, fix symlink path in test * Check for reparse point flag explicitly * Move path.isEmpty check outside
1 parent be44158 commit f751db3

File tree

2 files changed

+70
-9
lines changed

2 files changed

+70
-9
lines changed

Sources/FoundationEssentials/URL/URL.swift

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

16-
#if os(Windows)
16+
#if canImport(Darwin)
17+
import Darwin
18+
#elseif canImport(Android)
19+
@preconcurrency import Android
20+
#elseif canImport(Glibc)
21+
@preconcurrency import Glibc
22+
#elseif canImport(Musl)
23+
@preconcurrency import Musl
24+
#elseif os(Windows)
1725
import WinSDK
26+
#elseif os(WASI)
27+
@preconcurrency import WASILibc
1828
#endif
1929

2030
#if FOUNDATION_FRAMEWORK
@@ -2199,17 +2209,30 @@ extension URL {
21992209

22002210
#if !NO_FILESYSTEM
22012211
private static func isDirectory(_ path: String) -> Bool {
2212+
guard !path.isEmpty else { return false }
22022213
#if os(Windows)
22032214
let path = path.replacing(._slash, with: ._backslash)
2204-
#endif
2205-
#if !FOUNDATION_FRAMEWORK
2206-
var isDirectory: Bool = false
2207-
_ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
2208-
return isDirectory
2215+
return (try? path.withNTPathRepresentation { pwszPath in
2216+
// If path points to a symlink (reparse point), get a handle to
2217+
// the symlink itself using FILE_FLAG_OPEN_REPARSE_POINT.
2218+
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)
2219+
guard handle != INVALID_HANDLE_VALUE else { return false }
2220+
defer { CloseHandle(handle) }
2221+
var info: BY_HANDLE_FILE_INFORMATION = BY_HANDLE_FILE_INFORMATION()
2222+
guard GetFileInformationByHandle(handle, &info) else { return false }
2223+
if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == FILE_ATTRIBUTE_REPARSE_POINT { return false }
2224+
return (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == FILE_ATTRIBUTE_DIRECTORY
2225+
}) ?? false
22092226
#else
2210-
var isDirectory: ObjCBool = false
2211-
_ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
2212-
return isDirectory.boolValue
2227+
// FileManager uses stat() to check if the file exists.
2228+
// URL historically won't follow a symlink at the end
2229+
// of the path, so use lstat() here instead.
2230+
return path.withFileSystemRepresentation { fsRep in
2231+
guard let fsRep else { return false }
2232+
var fileInfo = stat()
2233+
guard lstat(fsRep, &fileInfo) == 0 else { return false }
2234+
return (mode_t(fileInfo.st_mode) & S_IFMT) == S_IFDIR
2235+
}
22132236
#endif
22142237
}
22152238
#endif // !NO_FILESYSTEM

Tests/FoundationEssentialsTests/URLTests.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,44 @@ final class URLTests : XCTestCase {
399399
}
400400
}
401401

402+
func testURLFilePathDoesNotFollowLastSymlink() throws {
403+
try FileManagerPlayground {
404+
Directory("dir") {
405+
"Foo"
406+
SymbolicLink("symlink", destination: "../dir")
407+
}
408+
}.test {
409+
let currentDirectoryPath = $0.currentDirectoryPath
410+
let baseURL = URL(filePath: currentDirectoryPath, directoryHint: .isDirectory)
411+
412+
let dirURL = baseURL.appending(path: "dir", directoryHint: .checkFileSystem)
413+
XCTAssertTrue(dirURL.hasDirectoryPath)
414+
415+
var symlinkURL = dirURL.appending(path: "symlink", directoryHint: .notDirectory)
416+
417+
// FileManager uses stat(), which will follow the symlink to the directory.
418+
419+
#if FOUNDATION_FRAMEWORK
420+
var isDirectory: ObjCBool = false
421+
XCTAssertTrue(FileManager.default.fileExists(atPath: symlinkURL.path, isDirectory: &isDirectory))
422+
XCTAssertTrue(isDirectory.boolValue)
423+
#else
424+
var isDirectory = false
425+
XCTAssertTrue(FileManager.default.fileExists(atPath: symlinkURL.path, isDirectory: &isDirectory))
426+
XCTAssertTrue(isDirectory)
427+
#endif
428+
429+
// URL uses lstat(), which will not follow the symlink at the end of the path.
430+
// Check that URL(filePath:) and .appending(path:) preserve this behavior.
431+
432+
symlinkURL = URL(filePath: symlinkURL.path, directoryHint: .checkFileSystem)
433+
XCTAssertFalse(symlinkURL.hasDirectoryPath)
434+
435+
symlinkURL = dirURL.appending(path: "symlink", directoryHint: .checkFileSystem)
436+
XCTAssertFalse(symlinkURL.hasDirectoryPath)
437+
}
438+
}
439+
402440
func testURLRelativeDotDotResolution() throws {
403441
let baseURL = URL(filePath: "/docs/src/")
404442
var result = URL(filePath: "../images/foo.png", relativeTo: baseURL)

0 commit comments

Comments
 (0)