Skip to content

Commit 5b61e03

Browse files
authored
Fix Windows symlink handling in FileManager APIs (#858) (#865)
* Fix Windows symlink handling in FileManager APIs * Address feedback
1 parent d8d4511 commit 5b61e03

File tree

4 files changed

+97
-10
lines changed

4 files changed

+97
-10
lines changed

Sources/FoundationEssentials/FileManager/FileManager+Basics.swift

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,14 @@ internal struct _FileManagerImpl {
8585
) -> Bool {
8686
#if os(Windows)
8787
return (try? path.withNTPathRepresentation {
88-
let hLHS = CreateFileW($0, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nil)
88+
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)
8989
if hLHS == INVALID_HANDLE_VALUE {
9090
return false
9191
}
9292
defer { CloseHandle(hLHS) }
9393

9494
return (try? other.withNTPathRepresentation {
95-
let hRHS = CreateFileW($0, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nil)
95+
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)
9696
if hRHS == INVALID_HANDLE_VALUE {
9797
return false
9898
}
@@ -129,11 +129,21 @@ internal struct _FileManagerImpl {
129129
return false
130130
}
131131

132-
if fbiLHS.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT,
133-
fbiRHS.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT {
132+
let lhsIsReparsePoint = fbiLHS.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT
133+
let rhsIsReparsePoint = fbiRHS.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT
134+
let lhsIsDirectory = fbiLHS.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY
135+
let rhsIsDirectory = fbiRHS.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY
136+
137+
guard lhsIsReparsePoint == rhsIsReparsePoint, lhsIsDirectory == rhsIsDirectory else {
138+
// If they aren't the same "type", then they cannot be equivalent
139+
return false
140+
}
141+
142+
if lhsIsReparsePoint {
143+
// Both are symbolic links, so they are equivalent if their destinations are equivalent
134144
return (try? fileManager.destinationOfSymbolicLink(atPath: path) == fileManager.destinationOfSymbolicLink(atPath: other)) ?? false
135-
} else if fbiLHS.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY,
136-
fbiRHS.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY {
145+
} else if lhsIsDirectory {
146+
// Both are directories, so recursively compare the directories
137147
guard let aLHSItems = try? fileManager.contentsOfDirectory(atPath: path),
138148
let aRHSItems = try? fileManager.contentsOfDirectory(atPath: other),
139149
aLHSItems == aRHSItems else {
@@ -160,6 +170,7 @@ internal struct _FileManagerImpl {
160170

161171
return true
162172
} else {
173+
// Both must be standard files, so binary compare the contents of the files
163174
var liLHSSize: LARGE_INTEGER = .init()
164175
var liRHSSize: LARGE_INTEGER = .init()
165176
guard GetFileSizeEx(hLHS, &liLHSSize), GetFileSizeEx(hRHS, &liRHSSize), LARGE_INTEGER._equals(liLHSSize, liRHSSize) else {

Sources/FoundationEssentials/FileManager/FileOperations.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,16 @@ enum _FileOperations {
807807
guard delegate.shouldPerformOnItemAtPath(src, to: dst) else { return }
808808

809809
try dst.withNTPathRepresentation { pwszDestination in
810-
if faAttributes.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY {
810+
// 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
811+
if faAttributes.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT {
812+
do {
813+
let linkDest = try fileManager.destinationOfSymbolicLink(atPath: src)
814+
try fileManager.createSymbolicLink(atPath: dst, withDestinationPath: linkDest)
815+
} catch {
816+
try delegate.throwIfNecessary(error, src, dst)
817+
return
818+
}
819+
} else if faAttributes.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY {
811820
do {
812821
try fileManager.createDirectory(atPath: dst, withIntermediateDirectories: true)
813822
} catch {
@@ -816,10 +825,10 @@ enum _FileOperations {
816825
for item in _Win32DirectoryContentsSequence(path: src, appendSlashForDirectory: true) {
817826
try linkOrCopyFile(src.appendingPathComponent(item.fileName), dst: dst.appendingPathComponent(item.fileName), with: fileManager, delegate: delegate)
818827
}
819-
} else if bCopyFile || faAttributes.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT {
828+
} else if bCopyFile {
820829
var ExtendedParameters: COPYFILE2_EXTENDED_PARAMETERS = .init()
821830
ExtendedParameters.dwSize = DWORD(MemoryLayout<COPYFILE2_EXTENDED_PARAMETERS>.size)
822-
ExtendedParameters.dwCopyFlags = COPY_FILE_FAIL_IF_EXISTS | COPY_FILE_COPY_SYMLINK | COPY_FILE_NO_BUFFERING | COPY_FILE_OPEN_AND_COPY_REPARSE_POINT
831+
ExtendedParameters.dwCopyFlags = COPY_FILE_FAIL_IF_EXISTS | COPY_FILE_NO_BUFFERING
823832

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

Tests/FoundationEssentialsTests/FileManager/FileManagerPlayground.swift

Lines changed: 22 additions & 0 deletions
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

Lines changed: 46 additions & 1 deletion
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)