From 7216480036dc5b28876787c30aeef4ec5695fd0b Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 00:57:30 -0400 Subject: [PATCH 1/4] Audit verification claims and source-read faults --- .../File/DestinationWriter.swift | 15 ++++- .../BitMatchEngineTests/FanOutTests.swift | 57 +++++++++++++++++++ README.md | 6 +- docs/GUIDE.md | 2 +- 4 files changed, 73 insertions(+), 7 deletions(-) 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..80d22211 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.

@@ -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 once, then reads each backup back and checks SHA-256 | | 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..a09eac7c 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -10,7 +10,7 @@ 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 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. From 9ac03b6ac47fb4f9063852dad2b8221e64486a6c Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 01:00:28 -0400 Subject: [PATCH 2/4] Clarify same-disk backup wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 80d22211..036a4f71 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Standard does not independently reread the source: a wrong source read can be co ## 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. +- **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. From 85550ffbf73f81f082c5295d4ebd9ed48a649fc5 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 02:05:57 -0400 Subject: [PATCH 3/4] Clarify Standard checks for reused files --- BitMatch/Views/PreferencesWindow.swift | 7 +------ .../Sources/BitMatchEngine/Models/Verification.swift | 2 +- README.md | 4 ++-- docs/GUIDE.md | 6 +++--- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/BitMatch/Views/PreferencesWindow.swift b/BitMatch/Views/PreferencesWindow.swift index 12bf0d09..67704a03 100644 --- a/BitMatch/Views/PreferencesWindow.swift +++ b/BitMatch/Views/PreferencesWindow.swift @@ -360,12 +360,7 @@ struct PreferencesWindow: View { } private var verificationExplanation: String { - switch coordinator.verificationMode { - case .standard: "Standard reads the card once and checks every drive." - case .quick: "Quick compares file sizes without checking file contents." - case .thorough: "Thorough also re-reads the card for verification." - case .paranoid: "Paranoid also compares every byte." - } + coordinator.verificationMode.description } } diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Models/Verification.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Models/Verification.swift index 55232810..5f6e4969 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/Models/Verification.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Models/Verification.swift @@ -222,7 +222,7 @@ public enum VerificationMode: String, CaseIterable, Identifiable, Codable, Senda public var description: String { switch self { case .quick: return "Quick checks file sizes only; file contents are not checksum-verified." - case .standard: return "Standard reads the card once and checks every drive." + case .standard: return "Standard reads each backup back with SHA-256. New copies use the source hash from copying; existing files are checked against a fresh source read." case .thorough: return "Thorough also re-reads the card for verification." case .paranoid: return "Paranoid also compares every byte." } diff --git a/README.md b/README.md index 036a4f71..8f5234d4 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,9 @@ 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 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index a09eac7c..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. 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. +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. From 50dfc06567b1f61f66750920800171fc00fb889d Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 02:17:23 -0400 Subject: [PATCH 4/4] Qualify source rereads in README mode summary --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f5234d4..cd002883 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ For new copies, Standard does not independently reread the source: a wrong sourc ## 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. +- **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. @@ -77,7 +77,7 @@ For new copies, Standard does not independently reread the source: a wrong sourc | Mode | What it checks | | --- | --- | | Quick | Copies and checks file sizes only; no checksum verification | -| Standard — default | New copies: reads the card once, then reads each backup back and checks SHA-256 | +| 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 |