mirror of https://github.com/cirruslabs/tart.git
fix(drop): propagate directory-read failures during drop copy
`copyTree`/`copyInto` treated `contentsOfDirectory` errors as an empty listing (`try? … ?? []`), so a permission/I/O failure inside a dropped folder was silently ignored and the drop reported success — producing an incomplete copy in the guest with no warning (data-lossy for users who expect the whole folder to transfer). Let the read error propagate instead. copyTree's do/catch already removes the partial `dst` and rethrows, so an unreadable subtree now aborts the drop and cleans up rather than half-completing. Adds a regression test covering an unreadable nested directory.
This commit is contained in:
parent
e78cb3c247
commit
2fb91b61cb
|
|
@ -81,9 +81,13 @@ enum DropProgressCopier {
|
|||
try fm.createDirectory(at: dst, withIntermediateDirectories: true)
|
||||
// Deterministic depth-first walk so the destination tree mirrors the
|
||||
// source and directories are created before their contents.
|
||||
let children = (try? fm.contentsOfDirectory(
|
||||
// Surface (don't swallow) read failures: a permission/I/O error here
|
||||
// means an incomplete copy, so let it propagate to the do/catch below,
|
||||
// which removes the partial `dst` and rethrows rather than reporting a
|
||||
// silent success.
|
||||
let children = try fm.contentsOfDirectory(
|
||||
at: src, includingPropertiesForKeys: walkKeys, options: []
|
||||
)) ?? []
|
||||
)
|
||||
for child in children.sorted(by: { $0.path < $1.path }) {
|
||||
if token.isCancelled { throw DropCopyCancelled() }
|
||||
let childDst = dst.appendingPathComponent(child.lastPathComponent)
|
||||
|
|
@ -113,9 +117,12 @@ enum DropProgressCopier {
|
|||
let values = try? src.resourceValues(forKeys: walkKeySet)
|
||||
if values?.isDirectory == true {
|
||||
try fm.createDirectory(at: dst, withIntermediateDirectories: true)
|
||||
let children = (try? fm.contentsOfDirectory(
|
||||
// Propagate read failures so an unreadable nested directory aborts the
|
||||
// drop (cleaned up by copyTree's catch) instead of silently producing a
|
||||
// partial tree in the guest.
|
||||
let children = try fm.contentsOfDirectory(
|
||||
at: src, includingPropertiesForKeys: walkKeys, options: []
|
||||
)) ?? []
|
||||
)
|
||||
for child in children.sorted(by: { $0.path < $1.path }) {
|
||||
if token.isCancelled { throw DropCopyCancelled() }
|
||||
try copyInto(child, dst.appendingPathComponent(child.lastPathComponent), &copied, token, report)
|
||||
|
|
|
|||
|
|
@ -119,4 +119,38 @@ final class DropProgressCopierTests: XCTestCase {
|
|||
let src = try write("sized.bin", 4242)
|
||||
XCTAssertEqual(DropProgressCopier.totalSize(of: src), 4242)
|
||||
}
|
||||
|
||||
func testUnreadableNestedDirectoryThrowsAndRemovesPartial() throws {
|
||||
// A nested directory whose contents can't be listed (permission denied)
|
||||
// must abort the copy and clean up — not silently produce a partial tree
|
||||
// in the guest and report success.
|
||||
try XCTSkipIf(getuid() == 0, "root bypasses POSIX permissions; can't exercise the denied-read path")
|
||||
|
||||
let srcDir = tmp.appendingPathComponent("locked-bundle", isDirectory: true)
|
||||
let sealed = srcDir.appendingPathComponent("sealed", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: sealed, withIntermediateDirectories: true)
|
||||
try Data(repeating: 0x41, count: 10).write(to: srcDir.appendingPathComponent("a.txt"))
|
||||
try Data(repeating: 0x41, count: 20).write(to: sealed.appendingPathComponent("secret.txt"))
|
||||
|
||||
// Drop read/exec on the nested dir so contentsOfDirectory(at:) fails.
|
||||
// Restore perms before the suite's tmp teardown so cleanup can recurse in
|
||||
// (teardown blocks run LIFO, so this runs before the setUp cleanup).
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: sealed.path)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: sealed.path)
|
||||
}
|
||||
|
||||
let dst = tmp.appendingPathComponent("locked-copy", isDirectory: true)
|
||||
XCTAssertThrowsError(
|
||||
try DropProgressCopier.copyTree(
|
||||
from: srcDir, to: dst, totalBytes: 30, token: DropCancellationToken()
|
||||
) { _ in }
|
||||
) { error in
|
||||
XCTAssertFalse(error is DropCopyCancelled, "should surface the read error, not a cancellation")
|
||||
}
|
||||
XCTAssertFalse(
|
||||
FileManager.default.fileExists(atPath: dst.path),
|
||||
"an unreadable nested directory must not leave a partial tree in the drop zone"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue