diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/File/DestinationWriter.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/File/DestinationWriter.swift index 9c6a1965..c580f67b 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/File/DestinationWriter.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/File/DestinationWriter.swift @@ -438,6 +438,9 @@ public final class DestinationWriter { public var destinationDidRead: (@Sendable (Int, String, Int) -> Void)? public var verificationSourceDidOpen: (@Sendable (String) -> Void)? public var verificationSourceDidRead: (@Sendable (String, Int) -> Void)? + /// Test hook for a repeatably wrong source read on later verification + /// passes. Production never supplies this; it does not write the card. + public var transformVerificationSourceRead: (@Sendable (String, Data) throws -> Data)? public init( beforeSourceOpen: (@Sendable (URL) throws -> Void)? = nil, @@ -454,7 +457,8 @@ public final class DestinationWriter { destinationDidOpenForRead: (@Sendable (Int, String) -> Void)? = nil, destinationDidRead: (@Sendable (Int, String, Int) -> Void)? = nil, verificationSourceDidOpen: (@Sendable (String) -> Void)? = nil, - verificationSourceDidRead: (@Sendable (String, Int) -> Void)? = nil + verificationSourceDidRead: (@Sendable (String, Int) -> Void)? = nil, + transformVerificationSourceRead: (@Sendable (String, Data) throws -> Data)? = nil ) { self.beforeSourceOpen = beforeSourceOpen self.sourceDidOpen = sourceDidOpen @@ -471,6 +475,7 @@ public final class DestinationWriter { self.destinationDidRead = destinationDidRead self.verificationSourceDidOpen = verificationSourceDidOpen self.verificationSourceDidRead = verificationSourceDidRead + self.transformVerificationSourceRead = transformVerificationSourceRead } } @@ -1456,7 +1461,9 @@ public final class DestinationWriter { let requested = min(4 * 1024 * 1024, Int(Int64(initial.st_size) - bytesRead)) // Drain each chunk's buffer; see readPinnedDestination. let count = try autoreleasepool { () throws -> Int in - let data = try handle.read(upToCount: requested) ?? Data() + let read = try handle.read(upToCount: requested) ?? Data() + let data = try hooks?.transformVerificationSourceRead?(relativePath, read) ?? read + guard data.count == read.count else { throw sourceChangedError() } if !data.isEmpty { hasher.update(data) } return data.count } @@ -1560,7 +1567,9 @@ public final class DestinationWriter { try await PauseGate.waitIfCurrentIsPaused() // Drain each chunk's buffers; see readPinnedDestination. let (sourceCount, destinationCount, same) = try autoreleasepool { () throws -> (Int, Int, Bool) in - let sourceData = try sourceHandle.read(upToCount: 4 * 1024 * 1024) ?? Data() + let sourceRead = try sourceHandle.read(upToCount: 4 * 1024 * 1024) ?? Data() + let sourceData = try hooks?.transformVerificationSourceRead?(relativePath, sourceRead) ?? sourceRead + guard sourceData.count == sourceRead.count else { throw sourceChangedError() } let destinationData = try destinationHandle.read(upToCount: 4 * 1024 * 1024) ?? Data() return (sourceData.count, destinationData.count, sourceData == destinationData) } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/FanOutTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/FanOutTests.swift index 7c764223..5f8a0aa8 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/FanOutTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/FanOutTests.swift @@ -645,11 +645,68 @@ struct FanOutTests { try await assertCopyStreamCorruption(mode: .thorough, expectsSafeVerdict: false) } + @Test + func paranoidCopyStreamCorruptionIsCaughtByByteComparison() async throws { + try await assertCopyStreamCorruption(mode: .paranoid, expectsSafeVerdict: false) + } + @Test func standardCopyStreamCorruptionIsNotCaughtKnownStandardTradeOff() async throws { try await assertCopyStreamCorruption(mode: .standard, expectsSafeVerdict: true) } + @Test + func repeatablyWrongSourceReadsDuringNewCopyCannotEstablishOriginalCameraBytes() async throws { + try await FileOperationsTestLock.shared.run { + #if os(macOS) + let original = Data(repeating: 0x35, count: 8193) + for mode in VerificationMode.allCases { + let fixture = try FanOutFixture(name: "repeatable-source-read-\(mode.rawValue)") + defer { fixture.cleanup() } + try fixture.write(original, to: "clip.bin") + let corrupt: @Sendable (Data) -> Data = { data in + guard !data.isEmpty else { return data } + var wrong = data + wrong[wrong.startIndex] ^= 0xff + return wrong + } + let operation = try await fixture.pipeline(hooks: .init( + sourceDidRead: { _, data in corrupt(data) }, + transformVerificationSourceRead: { _, data in corrupt(data) } + )).performFileOperation( + sourceURL: fixture.source, + destinationURLs: fixture.destinations, + verificationMode: mode, + settings: CameraLabelSettings(), + progressCallback: { _ in }, + onFileResult: nil + ) + + // The real source stays untouched. Every simulated card read + // returned the same wrong byte, so independent rereads cannot + // recover the original byte without outside evidence. + #expect(try Data(contentsOf: fixture.source.appendingPathComponent("clip.bin")) == original) + for destination in fixture.destinations { + let output = SafetyValidator.resolvedDestinationRoot( + source: fixture.source, destination: destination, + settings: CameraLabelSettings() + ).appendingPathComponent("clip.bin") + #expect(try Data(contentsOf: output) == corrupt(original)) + } + let verdict = fixture.verdict(for: operation, settings: CameraLabelSettings()) + if mode == .quick { + #expect(!verdict.success && verdict.copiedNotVerified) + } else { + #expect(verdict.success) + #expect(operation.results.allSatisfy { $0.verificationResult?.matches == true }) + } + } + #else + #expect(true) + #endif + } + } + private func assertCopyStreamCorruption( mode: VerificationMode, expectsSafeVerdict: Bool diff --git a/README.md b/README.md index 4bf860b0..cd002883 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Free, open-source camera card offloading for Mac.
- Copy to several drives, verify every file with SHA-256, and know exactly when the card is safe to erase. + Copy to several drives, verify the files you chose, and know when it’s safe to erase the card.

@@ -55,14 +55,14 @@ Requires **macOS 15.5 or newer**. For iPad and iPhone, build from source for now πŸ’Ύ A πŸ’Ύ B ``` -Plug in your card and drives, allow BitMatch to use them once, and it picks up the card as the source; you choose the destinations. In the default **Standard** mode, BitMatch hashes the card bytes as it copies to every drive, then reads each drive back from disk with SHA-256. **Thorough** independently rereads the card; **Paranoid** also compares bytes. **Quick** checks sizes only and never earns a safe verdict. The card is only **Safe to erase** when the required verification, coverage and evidence checks pass on every destination. Queue the next card while one copies; each card is a row that shows its own progress and verdict. Use **One-time transfer** for one card or **Project transfer** for a shoot with several cards and cameras. +Plug in your card and drives, allow BitMatch to use them once, and it picks up the card as the source; you choose the destinations. In the default **Standard** mode, BitMatch hashes each new file as it copies to every drive, then reads each drive back from disk with SHA-256. An existing file is checked against a fresh source read before reuse. **Thorough** independently rereads the card for new copies; **Paranoid** also compares bytes. **Quick** checks sizes only and never earns a safe verdict. The card is only **Safe to erase** when the required verification, coverage and evidence checks pass on every destination. Queue the next card while one copies; each card is a row that shows its own progress and verdict. Use **One-time transfer** for one card or **Project transfer** for a shoot with several cards and cameras. -Standard does not independently reread the source: a wrong source read can be copied and hashed consistently. Extra reads can catch differing bytes, but no mode can prove the original camera bytes or detect every repeatably wrong source read. See [How verification works](docs/GUIDE.md#how-verification-works) for scope and limits. +For new copies, Standard does not independently reread the source: a wrong source read can be copied and hashed consistently. Extra reads can catch differing bytes, but no mode can prove the original camera bytes or detect every repeatably wrong source read. See [How verification works](docs/GUIDE.md#how-verification-works) for scope and limits. ## What It Does -- **Multi destination copy**: the copy pass reads the card once and writes to every drive at the same time. Verification reads each drive back; Thorough and Paranoid also reread the card. Each destination gets its own results. -- **Careful about "safe to erase"**: a card or folder that changes during the copy, online-only iCloud/Dropbox files, or two "backups" on the same physical drive never count as safe. Recognized AppleDouble `._` metadata companions are skipped by default; other hidden files and camera sidecars stay in the copy. Turn off **Ignore AppleDouble metadata files** in **Settings β†’ File handling** to preserve them. That copy can verify the selected files, but it won't mark the whole card safe to erase. +- **Multi destination copy**: for new files, the copy pass reads the card once and writes to every drive at the same time. Verification reads each drive back; reused files get a fresh source read, and Thorough and Paranoid reread the card too. Each destination gets its own results. +- **Careful about "safe to erase"**: a card or folder that changes during the copy, or online-only iCloud/Dropbox files, won't earn a safe verdict. Two folders on the same physical drive count as one backup, not two independent ones. Recognized AppleDouble `._` metadata companions are skipped by default; other hidden files and camera sidecars stay in the copy. Turn off **Ignore AppleDouble metadata files** in **Settings β†’ File handling** to preserve them. That copy can verify the selected files, but it won't mark the whole card safe to erase. - **Photographer jobs** for shoots with several photographers, cameras, and cards. Save the setup instead of rebuilding it every time. - **Camera detection** for Sony, Canon, ARRI, RED, Blackmagic, Panasonic, Fujifilm, GoPro, DJI, Insta360, and generic DCIM. - **Check** for stuff you already copied: compare two folders, or check a backup against its saved checksums. @@ -76,8 +76,8 @@ Standard does not independently reread the source: a wrong source read can be co | Mode | What it checks | | --- | --- | -| Quick | Copy only; no checksum verification | -| Standard β€” default | Reads the card once; reads every drive back and checks SHA-256 | +| Quick | Copies and checks file sizes only; no checksum verification | +| Standard β€” default | New copies: reads the card while copying, then reads each backup back with SHA-256. Reused files get a fresh source read | | Thorough | Also re-reads the card; SHA-256 and MD5 | | Paranoid | Also re-reads the card and compares every byte, plus SHA-256 | diff --git a/docs/GUIDE.md b/docs/GUIDE.md index fd878f7b..7a48b28b 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -10,14 +10,14 @@ Notifications say when a card is safe to erase, when something needs you, and wh ## How Verification Works -Copying a file and checking it are separate jobs. BitMatch reads the card, writes each backup, then reads the destination bytes back. A matching checksum is evidence that those bytes matched when they were checked; a copy finishing or a filename appearing isn't enough. +Copying a new file and checking it are separate jobs. In the verified modes, BitMatch reads the card, writes each backup, then reads the destination bytes back. A matching checksum is evidence that those bytes matched when they were checked; a copy finishing or a filename appearing isn't enough. - **Quick** copies and checks sizes. It doesn't verify contents and never marks the card safe to erase. -- **Standard** hashes the source while copying and reads every backup back with SHA-256. +- **Standard** hashes new files while copying and reads every backup back with SHA-256. Existing files are checked against a fresh source read before reuse. - **Thorough** also reads the source again, using SHA-256 and MD5 to check for changes during the transfer. - **Paranoid** adds a byte-by-byte comparison with the source as well as SHA-256. -Standard verifies the backup against the bytes obtained during the copy read. If that read returns wrong bytes consistently to the copy and hash, its destination readback can still match. Thorough's separate source read and Paranoid's byte comparison can reveal differing reads; neither can prove what the camera originally wrote or rule out a fault that returns the same wrong bytes every time. A verified backup and a healthy source card are separate questions. +For new copies, Standard verifies the backup against the bytes obtained during the copy read. If that read returns wrong bytes consistently to the copy and hash, its destination readback can still match. Thorough's separate source read and Paranoid's byte comparison can reveal differing reads; neither can prove what the camera originally wrote or rule out a fault that returns the same wrong bytes every time. A verified backup and a healthy source card are separate questions. An existing file is reused only after the selected verification checks establish that it matches. Conflicting files are never overwritten. A failed, cancelled or interrupted attempt keeps its partial results and stays unsafe. A retry is a new attempt, not permission to trust the old one.