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
86 changes: 85 additions & 1 deletion BitMatchTests/ReportScannerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,91 @@ struct ReportScannerTests {
let cards = await ReportScanner.scan(at: root)
#expect(cards.count == 1)
#expect(cards.first?.verified == true)
#expect(cards.first?.metadata?.verificationMode == .standard)
#expect(cards.first?.metadata?.verificationMode == nil)
#expect(cards.first.map { MasterReportPresentation.statusText(for: $0).contains("mode not recorded") } == true)
}

@Test func explicitThoroughScopeSurvivesMasterReportScan() throws {
let root = try makeTemporaryFolder()
defer { try? FileManager.default.removeItem(at: root) }
var object = try #require(JSONSerialization.jsonObject(with: reportData(mode: .thorough, root: root, finished: Date())) as? [String: Any])
var verification = try #require(object["verification"] as? [String: Any])
verification["scope"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(
VerificationScopeEvidence(selectedMode: .thorough, results: [
ResultRow(path: "a", status: "✅ Verified", size: 10, checksum: "a", destination: "Backup"),
ResultRow(path: "b", status: "✅ Verified", size: 10, checksum: "b", destination: "Backup")
])))
object["verification"] = verification
let card = try #require(ReportScanner.transferCard(reportData: JSONSerialization.data(withJSONObject: object),
reportURL: root.appendingPathComponent("r.json")))
#expect(card.verified)
#expect(card.metadata?.verificationMode == .thorough)
}

@Test func legacyMD5ReportDoesNotClaimThorough() throws {
let root = try makeTemporaryFolder()
defer { try? FileManager.default.removeItem(at: root) }
let data = try reportData(mode: .thorough, root: root, finished: Date())
let card = try #require(ReportScanner.transferCard(reportData: data, reportURL: root.appendingPathComponent("r.json")))
#expect(card.verified) // Preserve the older report's content-check result.
#expect(card.metadata?.verificationMode == nil)
#expect(MasterReportPresentation.statusText(for: card).contains("mode not recorded"))
}

@Test func contradictoryQuickScopeCannotBecomeVerified() throws {
let root = try makeTemporaryFolder()
defer { try? FileManager.default.removeItem(at: root) }
var object = try #require(JSONSerialization.jsonObject(with: reportData(mode: .standard, root: root, finished: Date())) as? [String: Any])
var verification = try #require(object["verification"] as? [String: Any])
verification["scope"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(
VerificationScopeEvidence(selectedMode: .quick, results: [])))
object["verification"] = verification
let card = try #require(ReportScanner.transferCard(reportData: JSONSerialization.data(withJSONObject: object),
reportURL: root.appendingPathComponent("r.json")))
#expect(card.verified == false)
#expect(card.metadata?.verificationMode == nil)
}

@Test func savedScopeMustAgreeWithStatisticsBeforeMasterReportShowsVerified() throws {
let root = try makeTemporaryFolder()
defer { try? FileManager.default.removeItem(at: root) }
var object = try #require(JSONSerialization.jsonObject(with: reportData(mode: .standard, root: root, finished: Date())) as? [String: Any])
var verification = try #require(object["verification"] as? [String: Any])
verification["scope"] = [
"selectedMode": "Standard", "countsBasis": "recorded-results-only",
"recordedSelectedSourceFiles": 2, "recordedSelectedResults": 2,
"recordedVerifiedResults": 1, "recordedExcludedSourceFiles": 0
] as [String: Any]
object["verification"] = verification
let card = try #require(ReportScanner.transferCard(reportData: JSONSerialization.data(withJSONObject: object),
reportURL: root.appendingPathComponent("r.json")))
#expect(card.verified == false)
}

@Test func twoBackupScopeKeepsDistinctSourceCountAndRejectsInconsistentSourceTotals() throws {
let root = try makeTemporaryFolder()
defer { try? FileManager.default.removeItem(at: root) }
let destinations = [root.appendingPathComponent("A"), root.appendingPathComponent("B")]
let rows = destinations.map { ResultRow(path: root.appendingPathComponent("card/clip.mov").path,
status: "✅ Verified", size: 10, checksum: "abc", destination: $0.path) }
let report: EnhancedJSONReport<String> = try EvidenceWriter.makeEnhancedJSONReport(
results: rows, jobID: UUID(), started: Date(), finished: Date(), kind: .copyAndVerify,
sourceURL: root.appendingPathComponent("card"), destinationURLs: destinations,
fileCount: 2, matchCount: 2, totalBytesProcessed: 20, duration: 1, workers: 1,
prefs: ReportPrefs(verificationMode: .standard), project: nil, safeToErase: true)
let data = try EvidenceWriter.encodeEnhancedJSONReport(report)
let url = root.appendingPathComponent("r.json")
let control = try #require(ReportScanner.transferCard(reportData: data, reportURL: url))
#expect(control.verified)
#expect(control.fileCount == 1)
#expect(control.metadata?.verificationMode == .standard)

var object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
var source = try #require(object["source"] as? [String: Any])
source["fileCount"] = 2
object["source"] = source
let changed = try #require(ReportScanner.transferCard(reportData: JSONSerialization.data(withJSONObject: object), reportURL: url))
#expect(changed.verified == false)
}

/// A Quick copy checks sizes only; its report must not read as verified.
Expand Down
9 changes: 7 additions & 2 deletions BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,11 @@ final class SharedProjectFixture {
)
fixture.coordinator.destinationURLs = [folders.primary, folders.secondary]
fixture.coordinator.sourceURL = folders.source
await fixture.waitUntilSourceScanned()
guard await fixture.waitUntilSourceScanned() else {
folders.cleanup()
throw NSError(domain: "SharedProjectFixture", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Fixture source analysis did not settle before setup."])
}
if prepareCard { try fixture.prepareCard() }
return fixture
}
Expand Down Expand Up @@ -236,10 +240,11 @@ final class SharedProjectFixture {

@discardableResult
func waitUntilSourceScanned() async -> Bool {
await waitUntil(timeout: .seconds(5)) {
await waitUntil(timeout: .seconds(30)) {
coordinator.sourceFolderInfo?.url == folders.source
&& !coordinator.isFolderInfoLoading(for: folders.source)
&& !coordinator.appleDoubleSelection.isReviewing
&& !coordinator.isAnalysingSource
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,34 +173,74 @@ public enum EvidenceReader: Sendable {
name.lowercased().hasPrefix("bitmatch")
}

/// Reads the verification mode the report recorded. `nil` means the report
/// does not say, and such a report is never treated as verified.
public static func verificationMode(method: String?, algorithm: String?) -> VerificationMode? {
/// Reads an explicit selected mode when present and compatible with the
/// recorded method/digests. Legacy generic checksum reports use Standard
/// as the weakest content-check classification; MD5 does not prove a
/// separate source reread. None of these fields proves completion.
public static func verificationMode(method: String?, algorithm: String?, scope: VerificationScopeEvidence? = nil) -> VerificationMode? {
guard let method else { return nil }
let mode: VerificationMode
switch method.lowercased() {
case "size-only", "quick": return .quick
case "size-only", "quick": mode = .quick
case "checksum":
guard let algorithm else { return nil }
let names = algorithm.lowercased().split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespaces) }
guard !names.isEmpty, names.allSatisfy({ ["sha-256", "sha256", "md5", "xxh64", "xxhash64"].contains($0) }) else { return nil }
return names.contains("md5") ? .thorough : .standard
case "standard": return .standard
case "thorough": return .thorough
mode = .standard
case "standard": mode = .standard
case "thorough": mode = .thorough
case "checksum-and-byte-compare", "paranoid",
"byte-compare", "byte-to-byte", "byte_compare":
return .paranoid
mode = .paranoid
default: return nil
}
guard let scope else { return mode }
guard scope.hasConsistentRecordedCounts else { return nil }
// A selected plan cannot override incompatible recorded checks. In
// particular, a malformed Quick scope must never become verified.
let algorithms = Set((algorithm ?? "").lowercased().split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) })
guard algorithms.isSubset(of: ["sha-256", "sha256", "md5", "xxh64", "xxhash64"]) else { return nil }
let hasSHA256 = !algorithms.isDisjoint(with: ["sha-256", "sha256"])
switch scope.selectedMode {
case .quick:
guard mode == .quick else { return nil }
case .standard:
guard mode == .standard, hasSHA256 else { return nil }
case .thorough:
guard [.standard, .thorough].contains(mode), hasSHA256, algorithms.contains("md5") else { return nil }
case .paranoid:
guard mode == .paranoid, hasSHA256 else { return nil }
}
return scope.selectedMode
}

/// The actual selected mode, without inventing a copy plan for a legacy
/// report that recorded only generic checksum evidence.
public static func recordedVerificationMode(method: String?, algorithm: String?, scope: VerificationScopeEvidence? = nil) -> VerificationMode? {
if scope == nil {
// Generic byte-comparison evidence also does not prove that the
// Paranoid copy plan (including SHA-256) was selected.
guard ["size-only", "quick", "standard", "thorough", "paranoid"].contains(method?.lowercased() ?? "") else { return nil }
}
return verificationMode(method: method, algorithm: algorithm, scope: scope)
}

/// Verified means every file matched, at least one file was checked, and
/// the report says contents were compared. A Quick (size-only) copy or a
/// report with no recorded method is never verified (promise 2).
public static func isVerified(matches: Int, issues: Int, mode: VerificationMode?, totalResults: Int? = nil, safeToErase: Bool? = nil) -> Bool {
public static func isVerified(matches: Int, issues: Int, mode: VerificationMode?, totalResults: Int? = nil, safeToErase: Bool? = nil, scope: VerificationScopeEvidence? = nil, sourceFileCount: Int? = nil) -> Bool {
guard let mode, mode != .quick else { return false }
guard safeToErase != false else { return false }
if let totalResults { guard totalResults > 0, matches == totalResults else { return false } }
if let scope {
guard scope.hasConsistentRecordedCounts, scope.selectedMode == mode,
scope.recordedExcludedSourceFiles == 0,
scope.recordedVerifiedResults == matches,
scope.recordedSelectedResults == totalResults,
scope.recordedSelectedSourceFiles == sourceFileCount else { return false }
}
return issues == 0 && matches > 0
}

Expand Down Expand Up @@ -293,10 +333,12 @@ public enum EvidenceReader: Sendable {
public struct Verification: Decodable, Sendable {
public let method: String
public let algorithm: String?
public let scope: VerificationScopeEvidence?

public init(method: String, algorithm: String?) {
public init(method: String, algorithm: String?, scope: VerificationScopeEvidence? = nil) {
self.method = method
self.algorithm = algorithm
self.scope = scope
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,18 @@ public struct VerificationScopeEvidence: Codable, Sendable {
guard recordedExcludedSourceFiles > 0 else { return nil }
return "\(recordedExcludedSourceFiles) AppleDouble source files intentionally excluded; the whole card was not backed up."
}

/// Counts decoded from another report are claims, not trusted counters.
/// Compare them with that report's totals before accepting its verdict.
public var hasConsistentRecordedCounts: Bool {
countsBasis == "recorded-results-only"
&& recordedSelectedSourceFiles >= 0
&& recordedSelectedResults >= 0
&& recordedVerifiedResults >= 0
&& recordedExcludedSourceFiles >= 0
&& recordedSelectedSourceFiles <= recordedSelectedResults
&& (recordedSelectedSourceFiles == 0) == (recordedSelectedResults == 0)
&& recordedVerifiedResults <= recordedSelectedResults
&& (selectedMode != .quick || recordedVerifiedResults == 0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,70 @@ struct VerificationScopeEvidenceTests {
#expect(VerificationScopeEvidence.checkPlan(for: .thorough).contains("MD5"))
#expect(VerificationScopeEvidence.checkPlan(for: .paranoid).contains("byte comparison"))
}

@Test(arguments: VerificationMode.allCases)
func savedModeRoundTripsWithoutInferringItsSourceReadsFromDigests(mode: VerificationMode) throws {
let description = EvidenceWriter.verificationDescription(for: ReportPrefs(verificationMode: mode))
let written = EnhancedJSONReport<String>.Verification(method: description.method, algorithm: description.algorithm,
issuesByType: [:], checksumCache: nil, scope: .init(selectedMode: mode, results: []))
let read = try JSONDecoder().decode(EvidenceReader.Snapshot.Verification.self, from: JSONEncoder().encode(written))
#expect(read.scope?.selectedMode == mode)
#expect(EvidenceReader.verificationMode(method: read.method, algorithm: read.algorithm, scope: read.scope) == mode)
}

@Test(arguments: ["MD5", "SHA-256, MD5"])
func legacyCompatibilityDigestsDoNotInventAThoroughSourceReread(algorithm: String) throws {
let written = EnhancedJSONReport<String>.Verification(method: "checksum", algorithm: algorithm,
issuesByType: [:], checksumCache: nil)
let read = try JSONDecoder().decode(EvidenceReader.Snapshot.Verification.self, from: JSONEncoder().encode(written))
#expect(read.scope == nil)
#expect(EvidenceReader.verificationMode(method: read.method, algorithm: read.algorithm, scope: read.scope) == .standard)
#expect(EvidenceReader.recordedVerificationMode(method: read.method, algorithm: read.algorithm, scope: read.scope) == nil)
}

@Test func selectedScopeCannotOverrideContradictoryOrUnsupportedRecordedChecks() {
func resolve(_ mode: VerificationMode, _ method: String, _ algorithm: String?) -> VerificationMode? {
EvidenceReader.verificationMode(method: method, algorithm: algorithm,
scope: .init(selectedMode: mode, results: []))
}
#expect(resolve(.quick, "checksum", "SHA-256") == nil)
#expect(resolve(.standard, "size-only", nil) == nil)
#expect(resolve(.standard, "checksum", "MD5") == nil)
#expect(resolve(.thorough, "checksum", "SHA-256") == nil)
#expect(resolve(.paranoid, "checksum", "SHA-256") == nil)
#expect(resolve(.standard, "checksum", "future-hash") == nil)
#expect(resolve(.paranoid, "checksum-and-byte-compare", "SHA-256, future-hash") == nil)
#expect(resolve(.standard, "future-method", "SHA-256") == nil)
}

@Test(arguments: ["checksum-and-byte-compare", "byte-compare", "byte-to-byte", "byte_compare"])
func legacyByteChecksDoNotInventAParanoidCopyPlan(method: String) {
#expect(EvidenceReader.verificationMode(method: method, algorithm: nil) == .paranoid)
#expect(EvidenceReader.recordedVerificationMode(method: method, algorithm: nil) == nil)
}

@Test(arguments: ["recordedSelectedSourceFiles", "recordedSelectedResults", "recordedVerifiedResults", "recordedExcludedSourceFiles", "countsBasis"])
func inconsistentScopeCannotOverrideSavedVerdict(field: String) throws {
let rows = [ResultRow(path: "clip", status: "✅ Verified", size: 1, checksum: "a", destination: "Backup")]
let scope = VerificationScopeEvidence(selectedMode: .standard, results: rows)
#expect(EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: scope, sourceFileCount: 1))
var object = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(scope)) as? [String: Any])
object[field] = field == "countsBasis" ? "invented-counts" : -1
let changed = try JSONDecoder().decode(VerificationScopeEvidence.self, from: JSONSerialization.data(withJSONObject: object))
#expect(EvidenceReader.verificationMode(method: "checksum", algorithm: "SHA-256", scope: changed) == nil)
#expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: changed, sourceFileCount: 1))
}

@Test func selectedResultCountsMustAgreeWithSavedStatisticsAndExclusionsRemainUnsafe() {
let one = ResultRow(path: "clip", status: "✅ Verified", size: 1, checksum: "a", destination: "Backup")
let scope = VerificationScopeEvidence(selectedMode: .standard, results: [one])
#expect(!EvidenceReader.isVerified(matches: 2, issues: 0, mode: .standard, totalResults: 2, safeToErase: true, scope: scope, sourceFileCount: 1))
#expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, safeToErase: true, scope: scope, sourceFileCount: 1))
#expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: false, scope: scope, sourceFileCount: 1))
#expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: scope, sourceFileCount: 2))
#expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: scope))
let excluded = ResultRow(path: "._clip", status: ResultOutcome.excludedAppleDouble.statusText, size: 1, checksum: nil, destination: "Backup")
let filtered = VerificationScopeEvidence(selectedMode: .standard, results: [one, excluded])
#expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: filtered, sourceFileCount: 2))
}
}
3 changes: 2 additions & 1 deletion Shared/Core/Models/TransferModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ struct TransferMetadata: Codable {
let endTime: Date?
let totalFiles: Int
let totalSize: Int64
let verificationMode: VerificationMode
/// Nil for older generic checksum reports that did not record a mode.
let verificationMode: VerificationMode?
let cameraSettings: CameraLabelSettings?

var duration: TimeInterval? {
Expand Down
Loading
Loading