Skip to content

Commit e555c62

Browse files
authored
Fix Windows symlink handling in FileManager APIs (#858)
* Fix Windows symlink handling in FileManager APIs * Address feedback
1 parent 83073e2 commit e555c62

File tree

4 files changed

+97
-10
lines changed

4 files changed

+97
-10
lines changed

Sources/FoundationEssentials/FileManager/FileManager+Basics.swift

+17-6
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,14 @@ internal struct _FileManagerImpl {
7878
) -> Bool {
7979
#if os(Windows)
8080
return (try? path.withNTPathRepresentation {
81-
let hLHS = CreateFileW($0, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nil)
81+
let hLHS = CreateFileW($0, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nil)
8282
if hLHS == INVALID_HANDLE_VALUE {
8383
return false
8484
}
8585
defer { CloseHandle(hLHS) }
8686

8787
return (try? other.withNTPathRepresentation {
88-
let hRHS = CreateFileW($0, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nil)
88+
let hRHS = CreateFileW($0, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nil)
8989
if hRHS == INVALID_HANDLE_VALUE {
9090
return false
9191
}
@@ -122,11 +122,21 @@ internal struct _FileManagerImpl {
122122
return false
123123
}
124124

125-
if fbiLHS.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT,
126-
fbiRHS.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT {
125+
let lhsIsReparsePoint = fbiLHS.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT
126+
let rhsIsReparsePoint = fbiRHS.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT
127+
let lhsIsDirectory = fbiLHS.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY
128+
let rhsIsDirectory = fbiRHS.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY
129+
130+
guard lhsIsReparsePoint == rhsIsReparsePoint, lhsIsDirectory == rhsIsDirectory else {
131+
// If they aren't the same "type", then they cannot be equivalent
132+
return false
133+
}
134+
135+
if lhsIsReparsePoint {
136+
// Both are symbolic links, so they are equivalent if their destinations are equivalent
127137
return (try? fileManager.destinationOfSymbolicLink(atPath: path) == fileManager.destinationOfSymbolicLink(atPath: other)) ?? false
128-
} else if fbiLHS.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY,
129-
fbiRHS.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY {
138+
} else if lhsIsDirectory {
139+
// Both are directories, so recursively compare the directories
130140
guard let aLHSItems = try? fileManager.contentsOfDirectory(atPath: path),
131141
let aRHSItems = try? fileManager.contentsOfDirectory(atPath: other),
132142
aLHSItems == aRHSItems else {
@@ -153,6 +163,7 @@ internal struct _FileManagerImpl {
153163

154164
return true
155165
} else {
166+
// Both must be standard files, so binary compare the contents of the files
156167
var liLHSSize: LARGE_INTEGER = .init()
157168
var liRHSSize: LARGE_INTEGER = .init()
158169
guard GetFileSizeEx(hLHS, &liLHSSize), GetFileSizeEx(hRHS, &liRHSSize), LARGE_INTEGER._equals(liLHSSize, liRHSSize) else {

Sources/FoundationEssentials/FileManager/FileOperations.swift

+12-3
Original file line numberDiff line numberDiff line change
@@ -817,7 +817,16 @@ enum _FileOperations {
817817
guard delegate.shouldPerformOnItemAtPath(src, to: dst) else { return }
818818

819819
try dst.withNTPathRepresentation { pwszDestination in
820-
if faAttributes.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY {
820+
// Check for reparse points first because symlinks to directories are reported as both reparse points and directories, and we should copy the symlink not the contents of the linked directory
821+
if faAttributes.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT {
822+
do {
823+
let linkDest = try fileManager.destinationOfSymbolicLink(atPath: src)
824+
try fileManager.createSymbolicLink(atPath: dst, withDestinationPath: linkDest)
825+
} catch {
826+
try delegate.throwIfNecessary(error, src, dst)
827+
return
828+
}
829+
} else if faAttributes.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY {
821830
do {
822831
try fileManager.createDirectory(atPath: dst, withIntermediateDirectories: true)
823832
} catch {
@@ -826,10 +835,10 @@ enum _FileOperations {
826835
for item in _Win32DirectoryContentsSequence(path: src, appendSlashForDirectory: true) {
827836
try linkOrCopyFile(src.appendingPathComponent(item.fileName), dst: dst.appendingPathComponent(item.fileName), with: fileManager, delegate: delegate)
828837
}
829-
} else if bCopyFile || faAttributes.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT {
838+
} else if bCopyFile {
830839
var ExtendedParameters: COPYFILE2_EXTENDED_PARAMETERS = .init()
831840
ExtendedParameters.dwSize = DWORD(MemoryLayout<COPYFILE2_EXTENDED_PARAMETERS>.size)
832-
ExtendedParameters.dwCopyFlags = COPY_FILE_FAIL_IF_EXISTS | COPY_FILE_COPY_SYMLINK | COPY_FILE_NO_BUFFERING | COPY_FILE_OPEN_AND_COPY_REPARSE_POINT
841+
ExtendedParameters.dwCopyFlags = COPY_FILE_FAIL_IF_EXISTS | COPY_FILE_NO_BUFFERING
833842

834843
if FAILED(CopyFile2(pwszSource, pwszDestination, &ExtendedParameters)) {
835844
try delegate.throwIfNecessary(GetLastError(), src, dst)

Tests/FoundationEssentialsTests/FileManager/FileManagerPlayground.swift

+22
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@ struct File : ExpressibleByStringLiteral, Buildable {
4646
}
4747
}
4848

49+
struct SymbolicLink : Buildable {
50+
fileprivate let name: String
51+
private let destination: String
52+
53+
init(_ name: String, destination: String) {
54+
self.name = name
55+
self.destination = destination
56+
}
57+
58+
fileprivate func build(in path: String, using fileManager: FileManager) throws {
59+
let linkPath = path.appendingPathComponent(name)
60+
let destPath = path.appendingPathComponent(destination)
61+
try fileManager.createSymbolicLink(atPath: linkPath, withDestinationPath: destPath)
62+
}
63+
}
64+
4965
struct Directory : Buildable {
5066
fileprivate let name: String
5167
private let attributes: [FileAttributeKey : Any]?
@@ -70,11 +86,13 @@ struct FileManagerPlayground {
7086
enum Item : Buildable {
7187
case file(File)
7288
case directory(Directory)
89+
case symbolicLink(SymbolicLink)
7390

7491
fileprivate func build(in path: String, using fileManager: FileManager) throws {
7592
switch self {
7693
case let .file(file): try file.build(in: path, using: fileManager)
7794
case let .directory(dir): try dir.build(in: path, using: fileManager)
95+
case let .symbolicLink(symlink): try symlink.build(in: path, using: fileManager)
7896
}
7997
}
8098
}
@@ -92,6 +110,10 @@ struct FileManagerPlayground {
92110
static func buildExpression(_ expression: Directory) -> Item {
93111
.directory(expression)
94112
}
113+
114+
static func buildExpression(_ expression: SymbolicLink) -> Item {
115+
.symbolicLink(expression)
116+
}
95117
}
96118

97119
private let directory: Directory

Tests/FoundationEssentialsTests/FileManager/FileManagerTests.swift

+46-1
Original file line numberDiff line numberDiff line change
@@ -214,10 +214,20 @@ final class FileManagerTests : XCTestCase {
214214
File("Baz", contents: randomData())
215215
}
216216
}
217+
Directory("symlinks") {
218+
File("Foo", contents: randomData())
219+
SymbolicLink("LinkToFoo", destination: "Foo")
220+
}
221+
Directory("EmptyDirectory") {}
222+
"EmptyFile"
217223
}.test {
218224
XCTAssertTrue($0.contentsEqual(atPath: "dir1", andPath: "dir1_copy"))
219225
XCTAssertFalse($0.contentsEqual(atPath: "dir1/dir2", andPath: "dir1/dir3"))
220226
XCTAssertFalse($0.contentsEqual(atPath: "dir1", andPath: "dir1_diffdata"))
227+
XCTAssertFalse($0.contentsEqual(atPath: "symlinks/LinkToFoo", andPath: "symlinks/Foo"), "Symbolic link should not be equal to its destination")
228+
XCTAssertFalse($0.contentsEqual(atPath: "symlinks/LinkToFoo", andPath: "EmptyFile"), "Symbolic link should not be equal to an empty file")
229+
XCTAssertFalse($0.contentsEqual(atPath: "symlinks/LinkToFoo", andPath: "EmptyDirectory"), "Symbolic link should not be equal to an empty directory")
230+
XCTAssertFalse($0.contentsEqual(atPath: "symlinks/EmptyDirectory", andPath: "EmptyFile"), "Empty directory should not be equal to empty file")
221231
}
222232
}
223233

@@ -253,21 +263,30 @@ final class FileManagerTests : XCTestCase {
253263
"Baz"
254264
}
255265
}
266+
Directory("symlinks") {
267+
"Foo"
268+
SymbolicLink("Bar", destination: "Foo")
269+
SymbolicLink("Parent", destination: "..")
270+
}
256271
}.test {
257272
XCTAssertEqual(try $0.subpathsOfDirectory(atPath: "dir1").sorted(), ["dir2", "dir2/Bar", "dir2/Foo", "dir3", "dir3/Baz"])
258273
XCTAssertEqual(try $0.subpathsOfDirectory(atPath: "dir1/dir2").sorted(), ["Bar", "Foo"])
259274
XCTAssertEqual(try $0.subpathsOfDirectory(atPath: "dir1/dir3").sorted(), ["Baz"])
275+
276+
XCTAssertEqual(try $0.subpathsOfDirectory(atPath: "symlinks").sorted(), ["Bar", "Foo", "Parent"])
277+
260278
XCTAssertThrowsError(try $0.subpathsOfDirectory(atPath: "does_not_exist")) {
261279
XCTAssertEqual(($0 as? CocoaError)?.code, .fileReadNoSuchFile)
262280
}
263281

264-
let fullContents = ["dir1", "dir1/dir2", "dir1/dir2/Bar", "dir1/dir2/Foo", "dir1/dir3", "dir1/dir3/Baz"]
282+
let fullContents = ["dir1", "dir1/dir2", "dir1/dir2/Bar", "dir1/dir2/Foo", "dir1/dir3", "dir1/dir3/Baz", "symlinks", "symlinks/Bar", "symlinks/Foo", "symlinks/Parent"]
265283
let cwd = $0.currentDirectoryPath
266284
XCTAssertNotEqual(cwd.last, "/")
267285
let paths = [cwd, "\(cwd)/", "\(cwd)//", ".", "./", ".//"]
268286
for path in paths {
269287
XCTAssertEqual(try $0.subpathsOfDirectory(atPath: path).sorted(), fullContents)
270288
}
289+
271290
}
272291
}
273292

@@ -345,6 +364,32 @@ final class FileManagerTests : XCTestCase {
345364
XCTAssertEqual($0.delegateCaptures.shouldCopy, [.init("foo", "bar")])
346365
XCTAssertEqual($0.delegateCaptures.shouldProceedAfterCopyError, [.init("foo", "bar", code: .fileWriteFileExists)])
347366
}
367+
368+
try FileManagerPlayground {
369+
"foo"
370+
SymbolicLink("bar", destination: "foo")
371+
}.test(captureDelegateCalls: true) {
372+
XCTAssertTrue($0.delegateCaptures.isEmpty)
373+
try $0.copyItem(atPath: "bar", toPath: "copy")
374+
XCTAssertEqual($0.delegateCaptures.shouldCopy, [.init("bar", "copy")])
375+
XCTAssertEqual($0.delegateCaptures.shouldProceedAfterCopyError, [])
376+
let copyDestination = try $0.destinationOfSymbolicLink(atPath: "copy")
377+
XCTAssertEqual(copyDestination.lastPathComponent, "foo", "Copied symbolic link points at \(copyDestination) instead of foo")
378+
}
379+
380+
try FileManagerPlayground {
381+
Directory("dir") {
382+
"foo"
383+
}
384+
SymbolicLink("link", destination: "dir")
385+
}.test(captureDelegateCalls: true) {
386+
XCTAssertTrue($0.delegateCaptures.isEmpty)
387+
try $0.copyItem(atPath: "link", toPath: "copy")
388+
XCTAssertEqual($0.delegateCaptures.shouldCopy, [.init("link", "copy")])
389+
XCTAssertEqual($0.delegateCaptures.shouldProceedAfterCopyError, [])
390+
let copyDestination = try $0.destinationOfSymbolicLink(atPath: "copy")
391+
XCTAssertEqual(copyDestination.lastPathComponent, "dir", "Copied symbolic link points at \(copyDestination) instead of foo")
392+
}
348393
}
349394

350395
func testCreateSymbolicLinkAtPath() throws {

0 commit comments

Comments
 (0)