Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -471,6 +475,7 @@ public final class DestinationWriter {
self.destinationDidRead = destinationDidRead
self.verificationSourceDidOpen = verificationSourceDidOpen
self.verificationSourceDidRead = verificationSourceDidRead
self.transformVerificationSourceRead = transformVerificationSourceRead
}
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<p align="center">
<b>Free, open-source camera card offloading for Mac.</b><br>
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.
</p>

<p align="center">
Expand Down Expand Up @@ -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.
Expand All @@ -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 |

Expand Down
6 changes: 3 additions & 3 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading