From 0db7ae586c06cd3fec42d04314723e6eecf62b1b Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 10 Oct 2026 21:01:36 -0400 Subject: [PATCH 1/2] Preserve complete saved-checksum evidence and add ASC compatibility fixtures --- .../WorkflowSnapshotTests.swift | 16 ++ BitMatchTests/ComparePresentationTests.swift | 36 +++ BitMatchTests/WorkflowSnapshotTests.swift | 16 ++ Packages/BitMatchEngine/Package.swift | 8 +- .../Evidence/EvidenceReader.swift | 6 +- .../BitMatchEngine/SavedChecksumCheck.swift | 130 +++++++--- .../EvidenceCompatibilityTests.swift | 224 ++++++++++++++++++ .../EvidenceCompatibility/ASC-LICENSE.md | 19 ++ .../Fixtures/EvidenceCompatibility/README.md | 17 ++ .../EvidenceCompatibility/classic-v1.mhl | 2 + .../reference/c4/Camera A/ padded name.txt | 1 + .../c4/Camera A/clip & caf\303\251.txt" | 1 + .../c4/ascmhl/0001_c4_2026-10-11_004036Z.mhl | 47 ++++ .../reference/c4/ascmhl/ascmhl_chain.xml | 7 + .../reference/c4/empty.bin | 0 .../reference/md5/Camera A/ padded name.txt | 1 + .../md5/Camera A/clip & caf\303\251.txt" | 1 + .../reference/md5/added.txt | 1 + .../ascmhl/0001_md5_2026-10-11_004036Z.mhl | 47 ++++ .../ascmhl/0002_md5_2026-10-11_004036Z.mhl | 51 ++++ .../reference/md5/ascmhl/ascmhl_chain.xml | 11 + .../reference/md5/empty.bin | 0 .../xxh64/Camera A/ padded name.txt | 1 + .../xxh64/Camera A/clip & caf\303\251.txt" | 1 + .../ascmhl/0001_xxh64_2026-10-11_004036Z.mhl | 47 ++++ .../reference/xxh64/ascmhl/ascmhl_chain.xml | 7 + .../reference/xxh64/empty.bin | 0 .../reports/BitMatch_Report_current.json | 27 +++ .../BitMatch_Report_future-fields.json | 32 +++ .../BitMatch_Report_legacy-no-method.json | 21 ++ .../reports/BitMatch_Report_quick.json | 26 ++ .../reports/BitMatch_Report_truncated.json | 1 + .../BitMatch_Report_unknown-algorithm.json | 27 +++ .../BitMatch_Report_unknown-method.json | 27 +++ .../reports/BitMatch_Report_unsafe.json | 27 +++ .../Tools/ASCMHLFixture/main.swift | 28 +++ Scripts/ascmhl/GenerateFixture.swift | 13 - .../ascmhl/create_compatibility_fixtures.py | 33 +++ Scripts/ascmhl/validate_reference.sh | 16 +- Shared/Core/Models/ComparePresentation.swift | 10 +- .../Core/Services/SharedAppCoordinator.swift | 8 +- Shared/Views/Compare/CompareScreen.swift | 8 + docs/EVIDENCE_COMPATIBILITY_VALIDATION.md | 41 ++++ docs/GUIDE.md | 2 + docs/TODO.md | 2 +- docs/validation/ascmhl/README.md | 12 +- test.sh | 6 +- 47 files changed, 1006 insertions(+), 59 deletions(-) create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/ASC-LICENSE.md create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/README.md create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/classic-v1.mhl create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/Camera A/ padded name.txt create mode 100644 "Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/Camera A/clip & caf\303\251.txt" create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/ascmhl/0001_c4_2026-10-11_004036Z.mhl create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/ascmhl/ascmhl_chain.xml create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/empty.bin create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/Camera A/ padded name.txt create mode 100644 "Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/Camera A/clip & caf\303\251.txt" create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/added.txt create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/0001_md5_2026-10-11_004036Z.mhl create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/0002_md5_2026-10-11_004036Z.mhl create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/ascmhl_chain.xml create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/empty.bin create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/Camera A/ padded name.txt create mode 100644 "Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/Camera A/clip & caf\303\251.txt" create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/ascmhl/0001_xxh64_2026-10-11_004036Z.mhl create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/ascmhl/ascmhl_chain.xml create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/empty.bin create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_current.json create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_future-fields.json create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_legacy-no-method.json create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_quick.json create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_truncated.json create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unknown-algorithm.json create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unknown-method.json create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unsafe.json create mode 100644 Packages/BitMatchEngine/Tools/ASCMHLFixture/main.swift delete mode 100644 Scripts/ascmhl/GenerateFixture.swift create mode 100644 Scripts/ascmhl/create_compatibility_fixtures.py create mode 100644 docs/EVIDENCE_COMPATIBILITY_VALIDATION.md diff --git a/BitMatch-iPadTests/WorkflowSnapshotTests.swift b/BitMatch-iPadTests/WorkflowSnapshotTests.swift index ff93484a..b86df22e 100644 --- a/BitMatch-iPadTests/WorkflowSnapshotTests.swift +++ b/BitMatch-iPadTests/WorkflowSnapshotTests.swift @@ -155,6 +155,22 @@ final class WorkflowSnapshotTests: XCTestCase { } } + func testCaptureUnreadableSavedEvidence() throws { + try requireCaptureConfiguration() + let url = URL(fileURLWithPath: "/fixture/Shuttle") + let left = CompareFolderSlot.make(url: url, infoURL: url, fileCount: 3, totalSize: 1024, isFetching: false) + let right = CompareFolderSlot.make(url: nil, infoURL: nil, fileCount: nil, totalSize: nil, isFetching: false) + let presentation = ComparePresentation.make(left: left, right: right, + savedAvailability: .unreadable(message: SavedChecksumCheck.CheckError.unsupportedEvidence.localizedDescription), + mode: .standard, isRunning: false, progress: nil, stats: nil, end: nil) + for width in [320.0, 500.0, 820.0] { + try capture(CompareScreen(presentation: presentation, checkAgainst: .constant(.anotherFolder), + verificationMode: .constant(.standard), advancedExpanded: .constant(false), + actions: CompareActions(pickLeft: {}, pickRight: {}, clearLeft: {}, clearRight: {}, compare: {}, cancel: {})).padding(), + size: CGSize(width: width, height: 1000), name: "mobile-unreadable-evidence-\(Int(width))") + } + } + func testCaptureTransferDetails() throws { try requireCaptureConfiguration() let prefs = ReportPrefs(notes: "Card 01 · backups to Alex.\nKeep the source until both drives are checked.", operatorName: "Alex Morgan") diff --git a/BitMatchTests/ComparePresentationTests.swift b/BitMatchTests/ComparePresentationTests.swift index bec17817..b386446a 100644 --- a/BitMatchTests/ComparePresentationTests.swift +++ b/BitMatchTests/ComparePresentationTests.swift @@ -64,6 +64,42 @@ struct ComparePresentationTests { #expect(verdict.tone == .verified) } + /// Plant: report failed discovery as .notFound in SharedAppCoordinator. + @Test func unreadableSavedEvidenceIsExplainedInCheck() async throws { + let folders = try CoordinatorFolders() + defer { folders.cleanup() } + let reports = folders.primary.appendingPathComponent("Reports") + try FileManager.default.createDirectory(at: reports, withIntermediateDirectories: true) + try Data("{truncated".utf8).write(to: reports.appendingPathComponent("BitMatch_Report_bad.json")) + let coordinator = SharedAppCoordinator( + platformManager: RecordingPlatformManager(fileOperations: RecordingFileOperations()), + transferJournal: LocalTransferJournal(fileURL: folders.journalURL), + projectStore: InMemoryPhotographerJobStore(), defaults: folders.defaults) + coordinator.leftURL = folders.primary + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while coordinator.savedChecksumAvailability == .checking, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + guard case .unreadable(let message) = coordinator.savedChecksumAvailability else { + Issue.record("Unreadable evidence must not be reported as absent or ready") + return + } + #expect(message == SavedChecksumCheck.CheckError.unreadableEvidence.localizedDescription) + #expect(coordinator.checkAgainst == .anotherFolder) + #expect(!CompareReadiness.resolve(left: Self.loaded(Self.card), right: Self.emptySlot, + choice: .savedChecksums, savedAvailability: coordinator.savedChecksumAvailability, isRunning: false).canStart) + } + + /// Plant: remove the file-content scope from makeSaved's ASC verdict. + @Test func ascResultDoesNotClaimChainOrDirectoryVerification() { + let result = SavedChecksumCheck.Result(root: Self.card, matchingPaths: ["a.mov"], changedPaths: [], + missingPaths: [], newPaths: [], records: [.init(url: Self.card.appendingPathComponent("ascmhl/one.mhl"), + date: Date(), kind: .ascMHL)]) + let verdict = CompareVerdictPresentation.makeSaved(result) + #expect(verdict.tone == .verified) + #expect(verdict.detail.contains("File contents checked; MHL history and directory structure were not verified.")) + } + @Test func zeroByteFolderUsesEmptyWording() { let slot = CompareFolderSlot.make( url: Self.card, infoURL: Self.card, fileCount: 1, totalSize: 0, isFetching: false diff --git a/BitMatchTests/WorkflowSnapshotTests.swift b/BitMatchTests/WorkflowSnapshotTests.swift index eef8091d..10a2a1f5 100644 --- a/BitMatchTests/WorkflowSnapshotTests.swift +++ b/BitMatchTests/WorkflowSnapshotTests.swift @@ -65,6 +65,22 @@ final class WorkflowSnapshotTests: XCTestCase { try capture(Form { Section("File handling") { AppleDoubleSelectionView(review: review) } }.formStyle(.grouped), size: CGSize(width: 580, height: 260), name: "mac-appledouble-settings") } + func testCaptureUnreadableSavedEvidence() throws { + try requireCaptureConfiguration() + let url = URL(fileURLWithPath: "/fixture/Shuttle") + let left = CompareFolderSlot.make(url: url, infoURL: url, fileCount: 3, totalSize: 1024, isFetching: false) + let right = CompareFolderSlot.make(url: nil, infoURL: nil, fileCount: nil, totalSize: nil, isFetching: false) + let presentation = ComparePresentation.make(left: left, right: right, + savedAvailability: .unreadable(message: SavedChecksumCheck.CheckError.unsupportedEvidence.localizedDescription), + mode: .standard, isRunning: false, progress: nil, stats: nil, end: nil) + for width in [580.0, 1000.0] { + try capture(CompareScreen(presentation: presentation, checkAgainst: .constant(.anotherFolder), + verificationMode: .constant(.standard), advancedExpanded: .constant(false), + actions: CompareActions(pickLeft: {}, pickRight: {}, clearLeft: {}, clearRight: {}, compare: {}, cancel: {})).padding(), + size: CGSize(width: width, height: 1000), name: "mac-unreadable-evidence-\(Int(width))") + } + } + func testCaptureTransferDetails() throws { try requireCaptureConfiguration() let prefs = ReportPrefs(notes: "Card 01 · backups to Alex.\nKeep the source until both drives are checked.", operatorName: "Alex Morgan") diff --git a/Packages/BitMatchEngine/Package.swift b/Packages/BitMatchEngine/Package.swift index 6a247970..5f9e71aa 100644 --- a/Packages/BitMatchEngine/Package.swift +++ b/Packages/BitMatchEngine/Package.swift @@ -20,7 +20,13 @@ let package = Package( ), .testTarget( name: "BitMatchEngineTests", - dependencies: ["BitMatchEngine"] + dependencies: ["BitMatchEngine"], + resources: [.copy("Fixtures")] + ), + .executableTarget( + name: "ASCMHLFixture", + dependencies: ["BitMatchEngine"], + path: "Tools/ASCMHLFixture" ), ], swiftLanguageModes: [.v6] diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift index e6d43793..a86ac9f1 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift @@ -151,7 +151,11 @@ public enum EvidenceReader: Sendable { switch method.lowercased() { case "size-only", "quick": return .quick case "checksum": - return algorithm?.lowercased().contains("md5") == true ? .thorough : .standard + 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 case "checksum-and-byte-compare", "paranoid", diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift index ab23631f..cc243e60 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift @@ -93,10 +93,14 @@ public struct SavedChecksumCheck: Sendable { public enum CheckError: LocalizedError, Equatable, Sendable { case notFound + case unreadableEvidence + case unsupportedEvidence public var errorDescription: String? { switch self { case .notFound: "No saved checksums found here." + case .unreadableEvidence: "Some saved checksums are damaged or inconsistent. Compare with another folder instead." + case .unsupportedEvidence: "These saved checksums include a format or algorithm BitMatch cannot fully read yet. Compare with another folder instead." } } } @@ -121,9 +125,7 @@ public struct SavedChecksumCheck: Sendable { var reportEntries: [ExpectedFile] = [] for reportURL in reports { try Task.checkCancellation() - if let entries = try? Self.readReport(reportURL, root: canonicalRoot), !entries.isEmpty { - reportEntries.append(contentsOf: entries) - } + reportEntries.append(contentsOf: try Self.readReport(reportURL, root: canonicalRoot)) } if !reportEntries.isEmpty { return Self.makeDiscovery(root: canonicalRoot, entries: reportEntries) @@ -133,9 +135,7 @@ public struct SavedChecksumCheck: Sendable { var mhlEntries: [ExpectedFile] = [] for historyURL in histories { try Task.checkCancellation() - if let entries = try? Self.readMHL(historyURL, root: canonicalRoot), !entries.isEmpty { - mhlEntries.append(contentsOf: entries) - } + mhlEntries.append(contentsOf: try Self.readMHL(historyURL, root: canonicalRoot)) } guard !mhlEntries.isEmpty else { throw CheckError.notFound } return Self.makeDiscovery(root: canonicalRoot, entries: mhlEntries) @@ -239,28 +239,50 @@ public struct SavedChecksumCheck: Sendable { private static func readReport(_ url: URL, root: URL) throws -> [ExpectedFile] { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let payload = try decoder.decode(ReportPayload.self, from: Data(contentsOf: url)) + guard let payload = try? decoder.decode(ReportPayload.self, from: boundedEvidenceData(url)) else { + throw CheckError.unreadableEvidence + } guard payload.mode == nil || payload.mode == "copy-and-verify" else { return [] } let algorithm = reportAlgorithm(payload.verification?.algorithm) let record = Record(url: url, date: payload.timestamp, kind: .bitMatchReport) let manifest = readAdjacentManifest(for: url, root: root) - return payload.results.compactMap { item in + var paths = Set() + return try payload.results.compactMap { item in guard ResultRow.isVerifiedStatus(item.status), let target = item.target, let relative = relativePath(target, root: root) else { return nil } let manifestValue = manifest[relative] let value = manifestValue?.checksum ?? item.checksum let selectedAlgorithm = manifestValue?.algorithm ?? algorithm - guard let value, let selectedAlgorithm, isValid(value, for: selectedAlgorithm) else { return nil } + guard let selectedAlgorithm else { throw CheckError.unsupportedEvidence } + guard let value, isValid(value, for: selectedAlgorithm), paths.insert(relative).inserted else { + throw CheckError.unreadableEvidence + } return ExpectedFile(relativePath: relative, checksum: value.lowercased(), algorithm: selectedAlgorithm, record: record) } } private static func reportAlgorithm(_ text: String?) -> Algorithm? { - guard let value = text?.lowercased() else { return nil } - if value.contains("sha-256") || value.contains("sha256") { return .sha256 } - if value.contains("md5") { return .md5 } - if value.contains("xxh64") || value.contains("xxhash64") { return .xxh64 } - return nil + guard let text else { return nil } + let names = text.lowercased().split(separator: ",", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + let algorithms = names.compactMap { name -> Algorithm? in + switch name { + case "sha-256", "sha256": .sha256 + case "md5": .md5 + case "xxh64", "xxhash64": .xxh64 + default: nil + } + } + guard !algorithms.isEmpty, algorithms.count == names.count else { return nil } + return algorithms.contains(.sha256) ? .sha256 : algorithms.first + } + + private static func boundedEvidenceData(_ url: URL) throws -> Data { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize + guard let size, size <= EvidenceReader.maxReportBytes else { throw CheckError.unreadableEvidence } + let data = try Data(contentsOf: url) + guard data.count <= EvidenceReader.maxReportBytes else { throw CheckError.unreadableEvidence } + return data } private static func readAdjacentManifest(for report: URL, root: URL) -> [String: (checksum: String, algorithm: Algorithm)] { @@ -326,20 +348,31 @@ public struct SavedChecksumCheck: Sendable { } private static func readMHL(_ url: URL, root: URL) throws -> [ExpectedFile] { - let parser = XMLParser(data: try Data(contentsOf: url)) + let parser = XMLParser(data: try boundedEvidenceData(url)) let delegate = MHLParser() + parser.shouldProcessNamespaces = true + parser.shouldResolveExternalEntities = false parser.delegate = delegate - guard parser.parse() else { throw parser.parserError ?? CheckError.notFound } + let parsed = parser.parse() + if let failure = delegate.failure { throw failure } + guard parsed else { throw CheckError.unreadableEvidence } let fallbackDate = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast let record = Record(url: url, date: delegate.creationDate ?? fallbackDate, kind: .ascMHL) - return delegate.items.compactMap { item in - guard let relative = safeRecordedPath(item.path, root: root) else { return nil } + // ASC paths belong to the folder containing ascmhl/, not whichever + // ancestor the user selected in Check. + let historyRoot = url.deletingLastPathComponent().deletingLastPathComponent() + var paths = Set() + return try delegate.items.map { item in + guard let path = safeRecordedPath(item.path), + let relative = relativePath(historyRoot.appendingPathComponent(path).path, root: root), + paths.insert(relative).inserted else { throw CheckError.unreadableEvidence } let selected: (Algorithm, String)? if let hash = item.sha256 { selected = (.sha256, hash) } else if let hash = item.md5 { selected = (.md5, hash) } else if let hash = item.xxh64 { selected = (.xxh64, hash) } else { selected = nil } - guard let (algorithm, value) = selected, isValid(value, for: algorithm) else { return nil } + guard let (algorithm, value) = selected else { throw CheckError.unsupportedEvidence } + guard isValid(value, for: algorithm) else { throw CheckError.unreadableEvidence } return ExpectedFile(relativePath: relative, checksum: value.lowercased(), algorithm: algorithm, record: record) } } @@ -353,35 +386,72 @@ public struct SavedChecksumCheck: Sendable { } var items: [Item] = [] var creationDate: Date? + var failure: CheckError? private var item: Item? - private var element = "" + private var stack: [String] = [] private var text = "" + private var fields = Set() + private let namespace = "urn:ASC:MHL:v2.0" + + private func reject(_ parser: XMLParser, _ error: CheckError) { + failure = error + parser.abortParsing() + } func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { - element = elementName.lowercased() + let name = elementName.lowercased() + if stack.isEmpty && (name != "hashlist" || namespaceURI != namespace || attributeDict["version"] != "2.0") { + reject(parser, .unsupportedEvidence) + return + } + stack.append(name) text = "" - if element == "hash" { item = Item() } + if stack == ["hashlist", "hashes", "hash"] { + guard namespaceURI == namespace else { reject(parser, .unsupportedEvidence); return } + item = Item() + fields.removeAll() + } else if item != nil, stack.count == 4 { + guard namespaceURI == namespace else { reject(parser, .unsupportedEvidence); return } + guard fields.insert(name).inserted else { reject(parser, .unreadableEvidence); return } + if name != "path", name != "metadata", name != "previouspath", + let action = attributeDict["action"], !["original", "verified"].contains(action) { + reject(parser, .unsupportedEvidence) + } + } else if item != nil, stack.count > 4, stack[3] != "metadata" { + reject(parser, .unreadableEvidence) + } } func parser(_ parser: XMLParser, foundCharacters string: String) { text += string } + func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { + guard let value = String(data: CDATABlock, encoding: .utf8) else { reject(parser, .unreadableEvidence); return } + text += value + } + func parser(_ parser: XMLParser, foundInternalEntityDeclarationWithName name: String, value: String?) { + reject(parser, .unsupportedEvidence) + } + func parser(_ parser: XMLParser, foundExternalEntityDeclarationWithName name: String, publicID: String?, systemID: String?) { + reject(parser, .unsupportedEvidence) + } func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + defer { if !stack.isEmpty { stack.removeLast() }; text = "" } let name = elementName.lowercased() let value = text.trimmingCharacters(in: .whitespacesAndNewlines) - if item != nil { + if item != nil, stack.count == 4 { switch name { - case "path": item?.path = value + case "path": item?.path = text // Filename whitespace is data. case "sha256", "sha-256": item?.sha256 = value case "md5": item?.md5 = value case "xxh64", "xxhash64": item?.xxh64 = value - case "hash": if let item { items.append(item) }; item = nil default: break } - } else if name == "creationdate" { + } else if stack == ["hashlist", "hashes", "hash"] { + if let item { items.append(item) } + item = nil + } else if stack == ["hashlist", "creatorinfo", "creationdate"] { creationDate = ISO8601DateFormatter().date(from: value) } - element = "" - text = "" } } @@ -433,8 +503,8 @@ public struct SavedChecksumCheck: Sendable { return try? RelativePathResolver(base: root).resolve(url) } - private static func safeRecordedPath(_ path: String, root: URL) -> String? { - if path.hasPrefix("/") { return relativePath(path, root: root) } + private static func safeRecordedPath(_ path: String) -> String? { + guard !path.hasPrefix("/"), !path.contains("\0") else { return nil } let parts = path.split(separator: "/", omittingEmptySubsequences: false) guard !path.isEmpty, parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { return nil } return path diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift new file mode 100644 index 00000000..77c268c5 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift @@ -0,0 +1,224 @@ +import CryptoKit +import Foundation +import Testing +@testable import BitMatchEngine + +/// Permanent external-tool fixtures plus adversarial variants. No camera footage. +struct EvidenceCompatibilityTests { + private var checker: SavedChecksumCheck { + SavedChecksumCheck(fileAccess: LocalFileAccess(), checksum: ChecksumEngine.shared) + } + private func temporaryRoot() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("bitmatch-evidence-corpus-\(UUID())") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + private func fixture(_ name: String) throws -> URL { + try #require(Bundle.module.resourceURL).appendingPathComponent("Fixtures/EvidenceCompatibility/\(name)") + } + private func copyReference(_ algorithm: String) throws -> URL { + let root = try temporaryRoot() + try FileManager.default.copyItem(at: fixture("reference/\(algorithm)"), to: root.appendingPathComponent("media")) + return root + } + private func contents(_ root: URL) throws -> [String: Data] { + let files = try CardSource.enumerateRegularFiles(base: root) + return try Dictionary(uniqueKeysWithValues: files.map { ($0.url.path, try Data(contentsOf: $0.url)) }) + } + private func run(_ root: URL) async throws -> SavedChecksumCheck.Result { + try await checker.check(try await checker.discover(under: root)) { _ in } + } + private func md5(_ value: String) -> String { + Insecure.MD5.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + private func xml(_ second: String = "") -> String { + """ + + 2026-10-01T00:00:00Z + a.txt\(md5("A"))\(second) + """ + } + private func write(_ text: String, path: String, root: URL) throws { + let url = root.appendingPathComponent(path) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(text.utf8).write(to: url) + } + + /// Plant: trim text, or resolve nested manifests relative to the selected root. + @Test(arguments: ["md5", "xxh64"]) + func officialReferenceFilesIncludingWhitespaceAndNestedRoots(algorithm: String) async throws { + let container = try copyReference(algorithm) + defer { try? FileManager.default.removeItem(at: container) } + let root = container.appendingPathComponent("media") + let before = try contents(container) + let result = try await run(root) + #expect(result.isIntact) + #expect(result.recordedFileCount == (algorithm == "md5" ? 4 : 3)) + #expect(result.matchingPaths.contains("Camera A/ padded name.txt ")) + #expect(result.matchingPaths.contains("empty.bin")) + #expect(result.newPaths.isEmpty) + let parent = try await run(container) + #expect(parent.isIntact) + #expect(parent.matchingPaths == result.matchingPaths.map { "media/" + $0 }.sorted()) + #expect(try contents(container) == before) // Checks never rewrite media or histories. + } + + /// Plant: key nested inventories by their bare recorded path instead of their card-relative path. + @Test func twoCardsWithTheSameClipNamesKeepSeparateEvidence() async throws { + let root = try copyReference("md5") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.copyItem(at: fixture("reference/xxh64"), to: root.appendingPathComponent("Other card")) + let result = try await run(root) + #expect(result.isIntact) + #expect(result.recordedFileCount == 7) + #expect(result.matchingPaths.contains("media/Camera A/clip & café.txt")) + #expect(result.matchingPaths.contains("Other card/Camera A/clip & café.txt")) + #expect(result.newPaths.isEmpty) + } + + /// Plant: stop collecting hashes after the first generation. + @Test func secondGenerationMissingChangedAndNewFiles() async throws { + let container = try copyReference("md5") + defer { try? FileManager.default.removeItem(at: container) } + let root = container.appendingPathComponent("media") + try FileManager.default.removeItem(at: root.appendingPathComponent("added.txt")) + try write("modified", path: "Camera A/clip & café.txt", root: root) + try write("new", path: "unrecorded.txt", root: root) + let result = try await run(root) + #expect(!result.isIntact) + #expect(result.missingPaths == ["added.txt"]) + #expect(result.changedPaths == ["Camera A/clip & café.txt"]) + #expect(result.newPaths == ["unrecorded.txt"]) + } + + /// Plant: silently compactMap an unsupported/invalid entry or swallow a bad record. + @Test(arguments: ["unsupported", "invalid-digest", "duplicate", "traversal", "failed-action", "namespace", "version", "truncated"]) + func incompleteRecordsCannotBecomeAPartialGreen(kind: String) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try write("A", path: "a.txt", root: root) + try write("B", path: "b.txt", root: root) + let second: String + switch kind { + case "unsupported": second = "b.txtunsupported" + case "invalid-digest": second = "b.txtnot-a-digest" + case "duplicate": second = "a.txt\(md5("B"))" + case "traversal": second = "../outside.txt\(md5("B"))" + case "failed-action": second = "b.txt\(md5("B"))" + default: second = "" + } + var record = xml(second) + if kind == "namespace" { record = record.replacingOccurrences(of: "urn:ASC:MHL:v2.0", with: "urn:unknown") } + if kind == "version" { record = record.replacingOccurrences(of: "version=\"2.0\"", with: "version=\"9.0\"") } + if kind == "truncated" { record = String(record.dropLast(12)) } + try write(record, path: "ascmhl/0002_bad.mhl", root: root) + // A valid neighbour must not hide a damaged/unsupported record. + try write(xml(), path: "ascmhl/0001_valid.mhl", root: root) + await #expect(throws: (any Error).self) { _ = try await checker.discover(under: root) } + } + + /// Plant: leave XMLParser.shouldProcessNamespaces false. + @Test func namespacePrefixIsNotPartOfTheElementName() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try write("A", path: "a.txt", root: root) + var record = xml().replacingOccurrences(of: "xmlns=", with: "xmlns:m=") + for name in ["hashlist", "creatorinfo", "creationdate", "hashes", "hash", "path", "md5"] { + record = record.replacingOccurrences(of: "<\(name)>", with: "") + .replacingOccurrences(of: "<\(name) ", with: "", with: "") + } + try write(record, path: "ascmhl/prefixed.mhl", root: root) + #expect(try await run(root).isIntact) + } + + /// Plant: silently ignore C4-only records beside supported histories. + @Test func unsupportedReferenceAlgorithmCannotHideBesideMD5() async throws { + let root = try copyReference("md5") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.copyItem(at: fixture("reference/c4"), to: root.appendingPathComponent("unsupported")) + await #expect(throws: (any Error).self) { _ = try await checker.discover(under: root) } + } + + /// Plant: silently drop an invalid/unsupported verified report row or corrupt report. + @Test(arguments: ["unsupported-row", "corrupt-neighbour"]) + func reportsCannotSilentlyLoseRecordedFiles(kind: String) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try write("A", path: "a.txt", root: root) + var rows: [[String: Any]] = [["target": root.appendingPathComponent("a.txt").path, + "status": ResultOutcome.verified.statusText, "checksum": md5("A")]] + if kind == "unsupported-row" { + rows.append(["target": root.appendingPathComponent("b.txt").path, + "status": ResultOutcome.verified.statusText, "checksum": "broken"]) + } + let json: [String: Any] = ["timestamp": "2026-10-01T00:00:00Z", "mode": "copy-and-verify", + "verification": ["algorithm": "MD5"], "results": rows] + let data = try JSONSerialization.data(withJSONObject: json) + try write(String(decoding: data, as: UTF8.self), path: "Reports/BitMatch_Report_good.json", root: root) + if kind == "corrupt-neighbour" { try write("{truncated", path: "Reports/BitMatch_Report_bad.json", root: root) } + await #expect(throws: (any Error).self) { _ = try await checker.discover(under: root) } + } + + /// Plant: interpret classic MHL as an ASC file inventory. + @Test func classicMHLIsExplicitlyUnsupportedInsideASCHistory() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try write("A", path: "a.txt", root: root) + try write(String(contentsOf: fixture("classic-v1.mhl"), encoding: .utf8), path: "ascmhl/classic.mhl", root: root) + await #expect(throws: SavedChecksumCheck.CheckError.unsupportedEvidence) { + _ = try await checker.discover(under: root) + } + } + + /// Plant: resolve entity declarations or trust a nested metadata hash as a file digest. + @Test(arguments: ["entity", "metadata"]) + func xmlExtensionsCannotInventFileEvidence(kind: String) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try write("A", path: "a.txt", root: root) + var record = xml() + if kind == "entity" { + record = "]>" + record.replacingOccurrences(of: "a.txt", with: "&file;") + } else { + record = record.replacingOccurrences(of: "" + md5("A") + "", with: "" + md5("A") + "") + } + try write(record, path: "ascmhl/extension.mhl", root: root) + await #expect(throws: (any Error).self) { _ = try await checker.discover(under: root) } + } + + /// Plant: infer verified from result counts when the format does not establish it. + @Test(arguments: ["current", "legacy-no-method", "future-fields", "quick", "unsafe", "unknown-method", "unknown-algorithm", "truncated"]) + func reportFormatCorpus(name: String) throws { + let url = try fixture("reports/BitMatch_Report_\(name).json") + let snapshot = EvidenceReader.snapshot(reportData: try Data(contentsOf: url), reportURL: url) + if name == "truncated" { #expect(snapshot == nil); return } + let report = try #require(snapshot) + let mode = EvidenceReader.verificationMode(method: report.verification?.method, algorithm: report.verification?.algorithm) + let verified = EvidenceReader.isVerified(matches: report.statistics.matches, issues: report.statistics.issues, + mode: mode, totalResults: report.statistics.totalFiles, safeToErase: report.safeToErase) + #expect(verified == ["current", "future-fields"].contains(name)) + } + + /// Plant: drop unreadable BitMatch reports from the scan without listing them as skipped. + @Test func reportCorpusScanRetainsUnreadableNotice() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let reports = root.appendingPathComponent("Reports") + try FileManager.default.copyItem(at: fixture("reports"), to: reports) + let day = Date(timeIntervalSince1970: 1_800_000_000) + for file in try FileManager.default.contentsOfDirectory(at: reports, includingPropertiesForKeys: nil) { + try FileManager.default.setAttributes([.modificationDate: day], ofItemAtPath: file.path) + } + let result = await EvidenceReader.scanReports(at: root, day: day) + #expect(result.reports.count == 7) + #expect(result.skipped.map(\.displayName) == ["Reports/BitMatch_Report_truncated.json"]) + #expect(result.skipped.first?.reason == .unreadable) + } + + /// Plant: treat any algorithm paired with "checksum" as Standard. + @Test(arguments: ["BLAKE3", "future-SHA-256-ish", "", "SHA-256, UNKNOWN"]) + func unknownReportAlgorithmsAreNotVerified(algorithm: String) { + #expect(EvidenceReader.verificationMode(method: "checksum", algorithm: algorithm) == nil) + } +} diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/ASC-LICENSE.md b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/ASC-LICENSE.md new file mode 100644 index 00000000..c4db142f --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/ASC-LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) 2020 ASC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/README.md b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/README.md new file mode 100644 index 00000000..71c75dc7 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/README.md @@ -0,0 +1,17 @@ +# Evidence compatibility corpus + +All media bytes are tiny, original synthetic payloads, generated by `Scripts/ascmhl/create_compatibility_fixtures.py`. No camera footage, personal hostname or production metadata is included. The fixtures and handcrafted reports/classic-MHL example are offered under BitMatch's MIT license. + +`reference/` was generated with the official [ASC MHL reference implementation](https://github.com/ascmitc/mhl) at commit `0fb61f1e4c7c1c3ff422449aa6f091ce0d3b7687` on October 10, 2026. Its MIT notice is retained in `ASC-LICENSE.md`. The manifests are output from that tool, not BitMatch's writer. MD5 contains two generations; XXH64 and C4 contain one. Each inventory and chain passed the official XSDs, `ascmhl-debug verify` and `ascmhl diff`. The CLI's own timestamp/version fields are retained; the hostname is replaced before generation. + +Regenerate into a new, empty directory using a Python 3.11+ environment with that pinned checkout installed: + +``` +python Scripts/ascmhl/create_compatibility_fixtures.py /tmp/new-bitmatch-evidence-fixtures +``` + +The tests copy fixtures into disposable roots. MD5/XXH64, zero-byte files, Unicode/XML escaping, filename whitespace, nested roots and two generations must retain every expected path. Changed/missing media must fail; additional files are informational. C4-only entries are explicitly unsupported, including beside supported records. Tests derive corrupt, duplicate, unsafe-path, failed-action, foreign-namespace, future-version, prefixed-namespace, entity and nested-metadata variants without changing these originals. + +`reports/` captures current, older, extended, Quick, unsafe, unknown-method, unknown-algorithm and truncated JSON. Only current and compatible extra-field snapshots may supply a verified saved-report status. These handcrafted snapshots test reader compatibility; they are not fresh verification evidence or output attributed to an external offload application. + +`classic-v1.mhl` documents the current unsupported format. BitMatch still discovers ASC inventories under `ascmhl/`; this batch does not add standalone classic-MHL discovery or claim full chain, rename or directory-hash verification. ASC checks explicitly describe their file-content scope. Histories are never rewritten by these checks. diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/classic-v1.mhl b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/classic-v1.mhl new file mode 100644 index 00000000..9ef48700 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/classic-v1.mhl @@ -0,0 +1,2 @@ + +a.txt17fc56270e7a70fa81a5935b72eacbe29 diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/Camera A/ padded name.txt b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/Camera A/ padded name.txt new file mode 100644 index 00000000..615eac09 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/Camera A/ padded name.txt @@ -0,0 +1 @@ +Whitespace is part of this filename diff --git "a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/Camera A/clip & caf\303\251.txt" "b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/Camera A/clip & caf\303\251.txt" new file mode 100644 index 00000000..9608b914 --- /dev/null +++ "b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/Camera A/clip & caf\303\251.txt" @@ -0,0 +1 @@ +Original synthetic clip payload diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/ascmhl/0001_c4_2026-10-11_004036Z.mhl b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/ascmhl/0001_c4_2026-10-11_004036Z.mhl new file mode 100644 index 00000000..2f94200d --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/ascmhl/0001_c4_2026-10-11_004036Z.mhl @@ -0,0 +1,47 @@ + + + + 2026-10-10T20:40:36-04:00 + bitmatch-fixture-host + ascmhl + + + in-place + + + c43voxiUZHEENhE6i7ZbgaBA8cAoUBB9zx4gGMi649ahw7zTajsxootM1WtJEaWWZi6DnaUurQo8YDBE6te8W8sQpJ + + + c44cnWeuK9tS6uaSF1moyvai757bg3D9wpWWxiFwbYPm21putd6FBHaQEMV6DyHCxLNd9w4E8mqgJDfoamXSUZnHz5 + + + + .DS_Store + ascmhl + ascmhl/ + + + + + Camera A/ padded name.txt + c42PMRHDgQMeXBdKhmYyYLDXrqVYhjuurzLA5LouyG1CUiCqSodxcg3tutn9XsxsUn8zXJsgKipJJt16ZrSyj4kS8v + + + Camera A/clip & café.txt + c421nsyRYqXU1YR8Z8tEx3S1wNJWabrsXoUW2E7kACoR5kL1udSynUkiR3b4LyDaUy7W64WmacfSzH5DtY9vMXrXVw + + + Camera A + + c42x5psHLcmAC7K2LVhhpR9kxFYdEGV3ogXjqQrtk5wws1mHPFCy4L8TAyUbDvkoekYE5WAc9UHgoY6dQTdmcdTWwj + + + c44zy1rrVMZeusyf8qFJDGySA1Y6Mp1KgDe8E6Eoowu7WAyhBCt9PgD5yRE1aAzcL4qg8qvSfdm4RTaxsPFjM2V8G8 + + + + empty.bin + c459dsjfscH38cYeXXYogktxf4Cd9ibshE3BHUo6a58hBXmRQdZrAkZzsWcbWtDg5oQstpDuni4Hirj75GEmTc1sFT + + + diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/ascmhl/ascmhl_chain.xml b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/ascmhl/ascmhl_chain.xml new file mode 100644 index 00000000..3d8cb02b --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/ascmhl/ascmhl_chain.xml @@ -0,0 +1,7 @@ + + + + 0001_c4_2026-10-11_004036Z.mhl + c45VpN7ZGYoZwr71kQwjv8SCaN6MfxYc5fWVeWwVXkMpXQoNFdT4NaM4pbiQr7dg5MLhxuv4BgWzjAVfGRtpLCYEB4 + + diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/empty.bin b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/c4/empty.bin new file mode 100644 index 00000000..e69de29b diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/Camera A/ padded name.txt b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/Camera A/ padded name.txt new file mode 100644 index 00000000..615eac09 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/Camera A/ padded name.txt @@ -0,0 +1 @@ +Whitespace is part of this filename diff --git "a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/Camera A/clip & caf\303\251.txt" "b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/Camera A/clip & caf\303\251.txt" new file mode 100644 index 00000000..9608b914 --- /dev/null +++ "b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/Camera A/clip & caf\303\251.txt" @@ -0,0 +1 @@ +Original synthetic clip payload diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/added.txt b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/added.txt new file mode 100644 index 00000000..38be043a --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/added.txt @@ -0,0 +1 @@ +Added in the second generation diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/0001_md5_2026-10-11_004036Z.mhl b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/0001_md5_2026-10-11_004036Z.mhl new file mode 100644 index 00000000..8090bf32 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/0001_md5_2026-10-11_004036Z.mhl @@ -0,0 +1,47 @@ + + + + 2026-10-10T20:40:36-04:00 + bitmatch-fixture-host + ascmhl + + + in-place + + + 82bf5b21ac44d60efb89f52bce084a13 + + + 3f76dada2d39b3d7535bd29c179646c8 + + + + .DS_Store + ascmhl + ascmhl/ + + + + + Camera A/ padded name.txt + da15b5ad8a08c7f570728598f5657909 + + + Camera A/clip & café.txt + 0aabfb59f1df1f0ba91437d0439130bc + + + Camera A + + c582ec76500eca08bf3839841ce6d696 + + + 101ff38ec025e83329b55a10d91f67b9 + + + + empty.bin + d41d8cd98f00b204e9800998ecf8427e + + + diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/0002_md5_2026-10-11_004036Z.mhl b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/0002_md5_2026-10-11_004036Z.mhl new file mode 100644 index 00000000..a2c65d8c --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/0002_md5_2026-10-11_004036Z.mhl @@ -0,0 +1,51 @@ + + + + 2026-10-10T20:40:36-04:00 + bitmatch-fixture-host + ascmhl + + + in-place + + + 4a1dc05692ea0b166ec6b7d55872e3d0 + + + 97dc1fa6e65963466dd86c2df3bfb089 + + + + .DS_Store + ascmhl + ascmhl/ + + + + + Camera A/ padded name.txt + da15b5ad8a08c7f570728598f5657909 + + + Camera A/clip & café.txt + 0aabfb59f1df1f0ba91437d0439130bc + + + Camera A + + c582ec76500eca08bf3839841ce6d696 + + + 101ff38ec025e83329b55a10d91f67b9 + + + + added.txt + 57fd601d9b5697eeb95d204baeed32c4 + + + empty.bin + d41d8cd98f00b204e9800998ecf8427e + + + diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/ascmhl_chain.xml b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/ascmhl_chain.xml new file mode 100644 index 00000000..c6e88fc6 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/ascmhl/ascmhl_chain.xml @@ -0,0 +1,11 @@ + + + + 0001_md5_2026-10-11_004036Z.mhl + c423BY6Xq1hyNHyH4xQE8ywuHQ3yzwuGS5rrfCWFw6kF9j2dmyeXTF2Q21CYMvHyeoXyGEHpRCfB9U8u3kECn4Pf3k + + + 0002_md5_2026-10-11_004036Z.mhl + c4664yScCJQsMfRj9XtE6UAwFdquTtrkSKFBKkW85TzEqWouHsbzfGQ5bJBend2k9ZTaQDAEKLncSTBP1uMJxcD6ty + + diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/empty.bin b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/md5/empty.bin new file mode 100644 index 00000000..e69de29b diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/Camera A/ padded name.txt b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/Camera A/ padded name.txt new file mode 100644 index 00000000..615eac09 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/Camera A/ padded name.txt @@ -0,0 +1 @@ +Whitespace is part of this filename diff --git "a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/Camera A/clip & caf\303\251.txt" "b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/Camera A/clip & caf\303\251.txt" new file mode 100644 index 00000000..9608b914 --- /dev/null +++ "b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/Camera A/clip & caf\303\251.txt" @@ -0,0 +1 @@ +Original synthetic clip payload diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/ascmhl/0001_xxh64_2026-10-11_004036Z.mhl b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/ascmhl/0001_xxh64_2026-10-11_004036Z.mhl new file mode 100644 index 00000000..2733b030 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/ascmhl/0001_xxh64_2026-10-11_004036Z.mhl @@ -0,0 +1,47 @@ + + + + 2026-10-10T20:40:36-04:00 + bitmatch-fixture-host + ascmhl + + + in-place + + + 074a8b1a7cb8f8e1 + + + 5cbf3435625d3122 + + + + .DS_Store + ascmhl + ascmhl/ + + + + + Camera A/ padded name.txt + 86ce606b80b78a3a + + + Camera A/clip & café.txt + e5fb6c10739e0c93 + + + Camera A + + 45dba1371b4bd5dd + + + 06670c636f944d65 + + + + empty.bin + ef46db3751d8e999 + + + diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/ascmhl/ascmhl_chain.xml b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/ascmhl/ascmhl_chain.xml new file mode 100644 index 00000000..c410ea22 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/ascmhl/ascmhl_chain.xml @@ -0,0 +1,7 @@ + + + + 0001_xxh64_2026-10-11_004036Z.mhl + c441wwyHxmJdZb2CLWnho2nLX7EQtAZ1Z85dmhDhAVhnBa71fGQYW4bfZUCE6BXUWcw4nJKHev5PuL9q7aLzV18qx8 + + diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/empty.bin b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/xxh64/empty.bin new file mode 100644 index 00000000..e69de29b diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_current.json b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_current.json new file mode 100644 index 00000000..2f2cee3b --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_current.json @@ -0,0 +1,27 @@ +{ + "timestamp": "2026-10-01T12:00:00Z", + "source": { + "path": "/fixture/Card", + "name": "Card", + "totalSize": 12, + "fileCount": 2 + }, + "destinations": [ + { + "path": "/fixture/Backup" + } + ], + "statistics": { + "matches": 2, + "issues": 0, + "totalFiles": 2 + }, + "performance": { + "totalDuration": 3 + }, + "verification": { + "method": "checksum", + "algorithm": "SHA-256" + }, + "safeToErase": true +} diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_future-fields.json b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_future-fields.json new file mode 100644 index 00000000..053367b9 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_future-fields.json @@ -0,0 +1,32 @@ +{ + "timestamp": "2026-10-01T12:00:00Z", + "source": { + "path": "/fixture/Card", + "name": "Card", + "totalSize": 12, + "fileCount": 2 + }, + "destinations": [ + { + "path": "/fixture/Backup" + } + ], + "statistics": { + "matches": 2, + "issues": 0, + "totalFiles": 2 + }, + "performance": { + "totalDuration": 3 + }, + "verification": { + "method": "checksum", + "algorithm": "SHA-256" + }, + "safeToErase": true, + "futureSchemaExtension": { + "unrecognized": [ + "retained compatibility" + ] + } +} diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_legacy-no-method.json b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_legacy-no-method.json new file mode 100644 index 00000000..ed0ce6fe --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_legacy-no-method.json @@ -0,0 +1,21 @@ +{ + "timestamp": "2026-10-01T12:00:00Z", + "source": { + "path": "/fixture/Card", + "name": "Card", + "totalSize": 12, + "fileCount": 2 + }, + "destinations": [ + { + "path": "/fixture/Backup" + } + ], + "statistics": { + "matches": 2, + "issues": 0 + }, + "performance": { + "totalDuration": 3 + } +} diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_quick.json b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_quick.json new file mode 100644 index 00000000..05ca8f20 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_quick.json @@ -0,0 +1,26 @@ +{ + "timestamp": "2026-10-01T12:00:00Z", + "source": { + "path": "/fixture/Card", + "name": "Card", + "totalSize": 12, + "fileCount": 2 + }, + "destinations": [ + { + "path": "/fixture/Backup" + } + ], + "statistics": { + "matches": 2, + "issues": 0, + "totalFiles": 2 + }, + "performance": { + "totalDuration": 3 + }, + "verification": { + "method": "size-only" + }, + "safeToErase": true +} diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_truncated.json b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_truncated.json new file mode 100644 index 00000000..692af20d --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_truncated.json @@ -0,0 +1 @@ +{"timestamp":"2026-10-01T12:00:00Z","source": \ No newline at end of file diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unknown-algorithm.json b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unknown-algorithm.json new file mode 100644 index 00000000..a1456740 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unknown-algorithm.json @@ -0,0 +1,27 @@ +{ + "timestamp": "2026-10-01T12:00:00Z", + "source": { + "path": "/fixture/Card", + "name": "Card", + "totalSize": 12, + "fileCount": 2 + }, + "destinations": [ + { + "path": "/fixture/Backup" + } + ], + "statistics": { + "matches": 2, + "issues": 0, + "totalFiles": 2 + }, + "performance": { + "totalDuration": 3 + }, + "verification": { + "method": "checksum", + "algorithm": "BLAKE3" + }, + "safeToErase": true +} diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unknown-method.json b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unknown-method.json new file mode 100644 index 00000000..0e0075a1 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unknown-method.json @@ -0,0 +1,27 @@ +{ + "timestamp": "2026-10-01T12:00:00Z", + "source": { + "path": "/fixture/Card", + "name": "Card", + "totalSize": 12, + "fileCount": 2 + }, + "destinations": [ + { + "path": "/fixture/Backup" + } + ], + "statistics": { + "matches": 2, + "issues": 0, + "totalFiles": 2 + }, + "performance": { + "totalDuration": 3 + }, + "verification": { + "method": "future-method", + "algorithm": "SHA-256" + }, + "safeToErase": true +} diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unsafe.json b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unsafe.json new file mode 100644 index 00000000..5d0133fd --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reports/BitMatch_Report_unsafe.json @@ -0,0 +1,27 @@ +{ + "timestamp": "2026-10-01T12:00:00Z", + "source": { + "path": "/fixture/Card", + "name": "Card", + "totalSize": 12, + "fileCount": 2 + }, + "destinations": [ + { + "path": "/fixture/Backup" + } + ], + "statistics": { + "matches": 2, + "issues": 0, + "totalFiles": 2 + }, + "performance": { + "totalDuration": 3 + }, + "verification": { + "method": "checksum", + "algorithm": "SHA-256" + }, + "safeToErase": false +} diff --git a/Packages/BitMatchEngine/Tools/ASCMHLFixture/main.swift b/Packages/BitMatchEngine/Tools/ASCMHLFixture/main.swift new file mode 100644 index 00000000..e4fbf027 --- /dev/null +++ b/Packages/BitMatchEngine/Tools/ASCMHLFixture/main.swift @@ -0,0 +1,28 @@ +import Foundation +import CryptoKit +import BitMatchEngine + +/// A disposable interoperability sample for Scripts/ascmhl/validate_reference.sh. +@main +struct Main { + static func main() throws { + guard CommandLine.arguments.count == 2 else { + throw NSError(domain: "ASCMHLFixture", code: 64, + userInfo: [NSLocalizedDescriptionKey: "Pass a new fixture directory."]) + } + let root = URL(fileURLWithPath: CommandLine.arguments[1]) + guard !FileManager.default.fileExists(atPath: root.path) else { + throw CocoaError(.fileWriteFileExists) + } + try FileManager.default.createDirectory(at: root.appendingPathComponent("Camera A"), withIntermediateDirectories: true) + let data = Data("BitMatch interoperability test\n".utf8) + let path = "Camera A/clip & café.txt" + try data.write(to: root.appendingPathComponent(path), options: .withoutOverwriting) + let sha = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + let url = try ASCMHLGenerator.generateInitialHistory( + destinationURL: root, + files: [.init(relativePath: path, size: Int64(data.count), expectedSHA256: sha)], + startTime: Date(), toolVersion: "compatibility-fixture") + print(url.path) + } +} diff --git a/Scripts/ascmhl/GenerateFixture.swift b/Scripts/ascmhl/GenerateFixture.swift deleted file mode 100644 index 6fb1d744..00000000 --- a/Scripts/ascmhl/GenerateFixture.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation -import CryptoKit -@main struct Main { - static func main() throws { - let root = URL(fileURLWithPath: CommandLine.arguments[1]) - try FileManager.default.createDirectory(at: root.appendingPathComponent("Camera A"), withIntermediateDirectories: true) - let data = Data("BitMatch interoperability test\n".utf8) - try data.write(to: root.appendingPathComponent("Camera A/clip & café.txt")) - let sha = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() - let url = try ASCMHLGenerator.generateInitialHistory(destinationURL: root, files: [.init(relativePath: "Camera A/clip & café.txt", size: Int64(data.count), expectedSHA256: sha)], startTime: Date()) - print(url.path) - } -} diff --git a/Scripts/ascmhl/create_compatibility_fixtures.py b/Scripts/ascmhl/create_compatibility_fixtures.py new file mode 100644 index 00000000..313f0df2 --- /dev/null +++ b/Scripts/ascmhl/create_compatibility_fixtures.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Generate tiny, original media + ASC CLI fixtures. Run in the reference venv. + +The output must not exist. No camera footage or third-party media is used. +The hostname is replaced before generation; no private production data is saved. +""" +import pathlib +import sys +from unittest.mock import patch +from click.testing import CliRunner +from ascmhl.cli.ascmhl import mhltool_cli + +output = pathlib.Path(sys.argv[1]).resolve() +output.mkdir(parents=True, exist_ok=False) +runner = CliRunner() + +def create(root, algorithm): + with patch('platform.node', return_value='bitmatch-fixture-host'): + result = runner.invoke(mhltool_cli, ['create', '-h', algorithm, str(root)]) + if result.exit_code: + raise RuntimeError(result.output) from result.exception + +for algorithm in ['md5', 'xxh64', 'c4']: + root = output / algorithm + (root / 'Camera A').mkdir(parents=True) + (root / 'Camera A' / 'clip & café.txt').write_bytes(b'Original synthetic clip payload\n') + (root / 'Camera A' / ' padded name.txt ').write_bytes(b'Whitespace is part of this filename\n') + (root / 'empty.bin').write_bytes(b'') + create(root, algorithm) + if algorithm == 'md5': + (root / 'added.txt').write_bytes(b'Added in the second generation\n') + create(root, algorithm) +print('Created original ASC CLI fixtures:', output) diff --git a/Scripts/ascmhl/validate_reference.sh b/Scripts/ascmhl/validate_reference.sh index 440f2553..8da76c89 100755 --- a/Scripts/ascmhl/validate_reference.sh +++ b/Scripts/ascmhl/validate_reference.sh @@ -7,8 +7,7 @@ cli="${2:?Pass the directory containing ascmhl and ascmhl-debug}" project="$(cd "$(dirname "$0")/../.." && pwd)" workspace="$(mktemp -d "${TMPDIR:-/tmp}/bitmatch-ascmhl-check.XXXXXX")" trap 'rm -rf "$workspace"' EXIT -swiftc "$project/Shared/Core/Services/ASCMHLGenerator.swift" "$project/Scripts/ascmhl/GenerateFixture.swift" -o "$workspace/generate" -"$workspace/generate" "$workspace/media" +swift run --package-path "$project/Packages/BitMatchEngine" --build-system native --jobs 4 ASCMHLFixture "$workspace/media" "$cli/ascmhl-debug" xsd-schema-check -xsd "$reference/xsd/ASCMHL.xsd" "$workspace/media/ascmhl/"*.mhl "$cli/ascmhl-debug" xsd-schema-check -df -xsd "$reference/xsd/ASCMHLDirectory__combined.xsd" "$workspace/media/ascmhl/ascmhl_chain.xml" "$cli/ascmhl-debug" verify -v "$workspace/media" @@ -32,6 +31,19 @@ for entry in entries: assert hasher.string_digest() == entry.findtext('m:c4', namespaces=ns) print('PASS: official schemas, byte verification, chain C4, and next-generation interoperability') PY +# Permanent external-tool fixtures: validate without rewriting their histories. +corpus="$project/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference" +for algorithm in md5 xxh64 c4; do + fixture="$corpus/$algorithm" + for manifest in "$fixture/ascmhl/"*.mhl; do + "$cli/ascmhl-debug" xsd-schema-check -xsd "$reference/xsd/ASCMHL.xsd" "$manifest" + done + "$cli/ascmhl-debug" xsd-schema-check -df -xsd "$reference/xsd/ASCMHLDirectory__combined.xsd" "$fixture/ascmhl/ascmhl_chain.xml" + "$cli/ascmhl-debug" verify -v "$fixture" + "$cli/ascmhl" diff "$fixture" +done +echo 'PASS: permanent MD5, XXH64 and C4 corpus validated independently' + # Corruption must be detected by the independent reference verifier. printf 'corrupted' > "$workspace/media/Camera A/clip & café.txt" if "$cli/ascmhl-debug" verify "$workspace/media"; then diff --git a/Shared/Core/Models/ComparePresentation.swift b/Shared/Core/Models/ComparePresentation.swift index 6ce4f675..a102333a 100644 --- a/Shared/Core/Models/ComparePresentation.swift +++ b/Shared/Core/Models/ComparePresentation.swift @@ -21,6 +21,7 @@ enum SavedChecksumAvailability: Equatable, Sendable { case checking case found(fileCount: Int) case notFound + case unreadable(message: String) var hasRecords: Bool { if case .found = self { return true } @@ -143,7 +144,7 @@ enum CompareReadiness: Equatable, Sendable { switch savedAvailability { case .checking: return .checkingRecords case .found: return .ready - case .notFound: return .needsRight + case .notFound, .unreadable: return .needsRight } } guard let rightURL = right.url else { return .needsRight } @@ -286,6 +287,9 @@ struct CompareVerdictPresentation: Equatable, Sendable { ) let fileWord = result.recordedFileCount == 1 ? "file" : "files" let reports = result.records.map { $0.url.lastPathComponent }.joined(separator: ", ") + let scope = result.records.contains { $0.kind == .ascMHL } + ? " File contents checked; MHL history and directory structure were not verified." + : "" if result.isIntact { let age = SavedChecksumCheck.agePhrase(since: result.newestRecordDate, now: now, calendar: calendar) let newFiles = result.newPaths.isEmpty @@ -293,7 +297,7 @@ struct CompareVerdictPresentation: Equatable, Sendable { : " \(result.newPaths.count) new \(result.newPaths.count == 1 ? "file is" : "files are") not in the record." return Self( title: "\(result.root.lastPathComponent) still matches: \(fileCount) \(fileWord) intact, \(age)", - detail: "Used \(reports).\(newFiles)", + detail: "Used \(reports).\(newFiles)\(scope)", symbol: "checkmark.seal.fill", tone: .verified ) @@ -305,7 +309,7 @@ struct CompareVerdictPresentation: Equatable, Sendable { if !result.newPaths.isEmpty { parts.append("\(result.newPaths.count) new files not in the record") } return Self( title: "Saved checksum check found differences", - detail: parts.joined(separator: ", ") + ". Used \(reports).", + detail: parts.joined(separator: ", ") + ". Used \(reports)." + scope, symbol: "xmark.octagon.fill", tone: .differ ) diff --git a/Shared/Core/Services/SharedAppCoordinator.swift b/Shared/Core/Services/SharedAppCoordinator.swift index f4bb0593..370867a8 100644 --- a/Shared/Core/Services/SharedAppCoordinator.swift +++ b/Shared/Core/Services/SharedAppCoordinator.swift @@ -423,13 +423,15 @@ class SharedAppCoordinator: ObservableObject { } catch is CancellationError { return } catch SavedChecksumCheck.CheckError.notFound { - guard leftURL == url else { return } + guard !Task.isCancelled, leftURL == url else { return } savedChecksumAvailability = .notFound checkAgainst = .defaultChoice(for: savedChecksumAvailability) } catch { - guard leftURL == url else { return } + guard !Task.isCancelled, leftURL == url else { return } SharedLogger.warning("Could not read saved checksums at \(url.path): \(error)", category: .transfer) - savedChecksumAvailability = .notFound + savedChecksumAvailability = .unreadable(message: error is SavedChecksumCheck.CheckError + ? error.localizedDescription + : "Saved checksums could not be read. Compare with another folder instead.") checkAgainst = .defaultChoice(for: savedChecksumAvailability) } } diff --git a/Shared/Views/Compare/CompareScreen.swift b/Shared/Views/Compare/CompareScreen.swift index aae6c07b..9f7212b4 100644 --- a/Shared/Views/Compare/CompareScreen.swift +++ b/Shared/Views/Compare/CompareScreen.swift @@ -173,6 +173,14 @@ struct CompareScreen: View { .foregroundStyle(.secondary) case .found: checkAgainstPicker + case .unreadable(let message): + VStack(alignment: .leading, spacing: 6) { + LabeledContent("Check against", value: CheckAgainstChoice.anotherFolder.rawValue) + Label(message, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(ResultStatusTone.warning.color) + .fixedSize(horizontal: false, vertical: true) + } case .notFound: VStack(alignment: .leading, spacing: 6) { LabeledContent("Check against", value: CheckAgainstChoice.anotherFolder.rawValue) diff --git a/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md b/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md new file mode 100644 index 00000000..1899907d --- /dev/null +++ b/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md @@ -0,0 +1,41 @@ +# Saved-evidence compatibility + +October 10, 2026. This batch expands Check and saved-report regressions. It does not change media copying, destination readback, MHL publication or source handling. + +## Reproduced failures + +The first focused run compiled and executed seven new test functions (including parameterized cases), then failed with 28 assertion issues against the prior reader. It demonstrated: + +- Leading/trailing filename whitespace was removed from MHL paths. +- Nested inventories were resolved from the selected ancestor instead of their own card folder. +- Namespace-prefixed ASC inventories were not understood. +- Corrupt, unsupported, duplicate, unsafe-path and failed-action evidence could be skipped beside a readable record, leaving a partial check. +- Unknown report algorithms could be treated as Standard verification. + +The reader now preserves paths, resolves each inventory from its own folder, processes XML namespaces and refuses ambiguous/unsupported file evidence. A failed discovery is explained in Check rather than shown as “no saved checksums.” Successful ASC results state that only file contents were checked, not history chains or directory structure. Report JSON and MHL reads use the existing 64 MiB report limit. + +## Independent corpus + +The committed corpus is under `Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility`. Its README records provenance and scope. Media bytes are original synthetic payloads; no footage, client metadata or personal hostname is included. + +The official ASC reference implementation is pinned in the validation record to `ascmitc/mhl@0fb61f1e4c7c1c3ff422449aa6f091ce0d3b7687`. Its CLI generated the MD5, XXH64 and C4 inventories. All passed its inventory/chain XSDs, `ascmhl-debug verify` and `ascmhl diff`. Tests include two generations, zero-byte files, Unicode/XML escaping, filename whitespace and nested card folders. Negative variants add missing/changed media, unsupported hashes, malformed XML, conflicting duplicates, traversal, failed actions, foreign namespaces, future versions and entity declarations. Checks leave the copied media and histories unchanged. + +Handcrafted JSON fixtures cover current, legacy, unknown extra fields, Quick, unsafe, unknown method, unknown algorithm and truncated reports. The classic-MHL example establishes an unsupported boundary; it is not claimed as external-tool output. + +The old `validate_reference.sh` pointed at the generator's pre-package location and outdated API. It now uses a package executable linked to the real engine. Besides validating the permanent corpus, it validates freshly generated BitMatch output, lets the official CLI append a generation, independently checks C4 chain digests, and confirms that corrupted media is rejected. + +## Validation + +The final focused engine corpus and existing saved-checksum run passed all 21 test functions (including parameterized cases). The focused Mac run passed all 47 tests in ComparePresentationTests and ReportScannerTests. + +The first full engine run passed 123 XCTest cases (15 intentional skips) but failed one Swift Testing setup assertion: FanOutTests' two-second wait for its injected stall. The same case failed when the whole fan-out suite ran concurrently, passed alone in 0.187 seconds, and all 18 fan-out tests passed with explicit `--no-parallel`. No later safety assertion failed. `test.sh engine-test` now passes that flag explicitly; tests still exercise concurrent workers internally. Failed logs are retained, not counted as passes. The complete rerun with explicit `--no-parallel` passed: 123 XCTest cases, 15 intentional skips, zero failures; the 145-test Swift Testing run passed, with six opt-in tests skipped. Those include large-media/memory and external-fixture workflows. The mounted exFAT handoff and 54-transfer AppleDouble filesystem matrix ran and passed. + +The two new Mac presentation/integration tests were also mutation-checked. Replacing unreadable discovery with “not found” and removing the ASC scope text produced two behavioral failures; the production code was restored automatically afterward. The initial unmodified focused Mac run had passed both tests. + +The full Mac suite passed: 1,092 passed, 12 opt-in/hardware skips, zero failures. Both new warning captures rendered at 580 and 1,000 points. AppKit bitmap captures omit some native layers, so these are layout evidence, not a substitute for the signed-app walkthrough. + +The initial reference environment used the shell's Python 3.9 and failed the tool's Python 3.11+ requirement. The successful reference run uses a separate Python 3.12 environment. That setup failure is retained with the test logs under `dist/validation/evidence-compat/`. + +## Limits + +This is file-content interoperability coverage, not certification against every offload tool. BitMatch still does not discover standalone classic MHL, verify ASC directory/root hashes or chains, interpret rename history, or extend existing histories. Those roadmap items remain open. Supported BitMatch reports still take precedence over ASC inventories for Check. The saved-report scanner summarizes past evidence; it does not freshly verify media. Synthetic fixtures do not establish physical cable, dock or drive reliability. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 6f4beaac..aeb400b2 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -35,6 +35,8 @@ The option follows the transfer into the queue and history. Reports list the exc Choose **Check**, then pick the folder or drive you want to check. Use **Its saved checksums** when BitMatch finds supported records, such as a BitMatch JSON report or an ASC MHL inventory. It reads the files again and compares them with those records; the original card does not need to be connected. +For ASC MHL, this checks recorded file contents, including inventories inside card subfolders. It does not verify the history chain or directory hashes. If a record is damaged or includes file checksums BitMatch cannot read, Check says so instead of silently checking only part of it. Standalone classic MHL and C4-only file checksums aren't supported yet. + Use **Another folder** to compare two copies instead. Standard compares contents with SHA-256; Quick checks sizes only. The results separate changed files from files missing or extra in the other folder. An extra file alone does not mean the matching files are damaged. Check does not copy, delete, or repair anything. The differences export includes filenames and paths. For a bug report without those details, use **History → Export diagnostics…** instead. diff --git a/docs/TODO.md b/docs/TODO.md index 6f9ecf23..a015b43b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -42,7 +42,7 @@ Investigate #16 first. Then follow evidence and actual user reports. Preserve so - [ ] **Upstream MHL / source provenance.** Reuse `SavedChecksumCheck` where appropriate to offer source verification against accompanying classic MHL or ASC MHL evidence. Record “source arrived intact” separately from “BitMatch created verified backups.” Preserve original manifests. Current Check discovers ASC inventories under `ascmhl/`; standalone classic-MHL discovery and an upstream transfer verdict still need work. **Acceptance:** incomplete, mismatched, malformed and unsupported evidence gets an honest separate verdict; a failed provenance check never becomes success and never prevents preservation of damaged-but-irreplaceable media. -- [ ] **MHL interoperability corpus.** Expand the existing generated-fixture/reference checks and multi-file saved-record regressions with permanent, redistributable classic/ASC manifests and representative external-tool output. Cover multiple generations, relative paths, renames, directory changes, unsupported algorithms, corrupt/malformed records, Unicode and unusual filenames. **Acceptance:** validate with independent reference implementations, record fixture provenance/licensing and expected outcomes, and never silently treat unsupported evidence as fully verified. +- [ ] **MHL interoperability corpus.** The first permanent corpus now covers official ASC reference-tool MD5/XXH64 output, two generations, unsupported C4, Unicode/whitespace paths, nested inventories, damaged records and report compatibility. See [the validation record](EVIDENCE_COMPATIBILITY_VALIDATION.md). Expand the existing generated-fixture/reference checks and multi-file saved-record regressions with permanent, redistributable classic/ASC manifests and representative external-tool output. Cover multiple generations, relative paths, renames, directory changes, unsupported algorithms, corrupt/malformed records, Unicode and unusual filenames. **Acceptance:** validate with independent reference implementations, record fixture provenance/licensing and expected outcomes, and never silently treat unsupported evidence as fully verified. - [ ] **ASC MHL structural verification.** After the corpus establishes expected semantics, extend Check to validate supported root/directory hashes and detect renamed, moved, missing or structurally altered content. **Acceptance:** distinguish file-content verification from structural verification; older manifests without structural evidence remain usable with an explicit scope. Do not claim full history verification before the chain semantics are implemented and independently tested. diff --git a/docs/validation/ascmhl/README.md b/docs/validation/ascmhl/README.md index 3d6f6208..9673faec 100644 --- a/docs/validation/ascmhl/README.md +++ b/docs/validation/ascmhl/README.md @@ -2,7 +2,7 @@ BitMatch creates **initial ASC MHL 2.0 destination inventories**. This is a deliberately limited first implementation, not full chain-of-custody management. -Each inventoried destination file is streamed again and checked against the SHA-256 recorded by the successful copy operation. The same read produces an MD5 digest for the ASC record. SHA-256 is not an ASC MHL 2.0 hash field and is never relabeled as one. These new destination records use `in-place` and `original`: they do not claim an inherited source-side MD5 verification history. The manifest lives in `ascmhl/` with a C4-protected `ascmhl_chain.xml`. +Each inventoried destination file must match the SHA-256 recorded by the successful copy operation. On supported filesystems, unchanged pinned readback evidence can supply the SHA-256 and MD5; otherwise the destination is streamed again. exFAT deliberately takes the fresh-read path. SHA-256 is not an ASC MHL 2.0 hash field and is never relabeled as one. These new destination records use `in-place` and `original`: they do not claim an inherited source-side MD5 verification history. The manifest lives in `ascmhl/` with a C4-protected `ascmhl_chain.xml`. Only the listed verified files are covered. No directory or root completeness hashes are claimed. Existing destination files or reports outside the transfer inventory remain outside this record; the reference tool's whole-folder `diff` will identify such additional files. The executor refuses to generate a destination record when its results contain failed or missing files; it does not shorten the inventory to successful rows. A fully verified destination may receive its record even if another destination failed. Cancellation is propagated rather than reported as successful record generation. Record-generation issues are shown separately from the unchanged copy results. @@ -10,13 +10,13 @@ Existing, nested, and ancestor ASC histories are refused and preserved, includin When the caller supplies its source URL, source and destination descriptors are pinned and checked for equal, ancestor, or descendant relationships before reads, staging writes, and publication. Descriptor-resolved paths catch ancestor symlinks that lead into the source. -Publication uses a pinned destination directory descriptor, no-follow file opens, exclusive staging writes, and an exclusive directory rename. Missing, changed, duplicate, unsafe-path and symbolic-link entries are rejected before publication. Cancellation is checked during reads and before publication. Generating the record adds a full destination read, so this work can materially extend completion time on slow storage. Paranoid transfers no longer automatically emit the old proprietary MHL companions; optional handoff records are generated centrally through this ASC implementation. +Publication uses a pinned destination directory descriptor, no-follow file opens, exclusive staging writes and no-replace directory publication. On exFAT, an exclusively claimed empty directory permits publication without replacing an existing nonempty history. Missing, changed, duplicate, unsafe-path and symbolic-link entries are rejected before publication. Cancellation is checked during reads and before publication. When fresh readback is needed, generating the record adds a full destination read, so this work can materially extend completion time on slow storage. Paranoid transfers no longer automatically emit the old proprietary MHL companions; optional handoff records are generated centrally through this ASC implementation. ## Independent validation Validated 2026-09-08 against the official [ASC reference implementation](https://github.com/ascmitc/mhl) at commit `0fb61f1e4c7c1c3ff422449aa6f091ce0d3b7687`, using its published schemas. The specification is maintained in [ascmitc/mhl-specification](https://github.com/ascmitc/mhl-specification). -The repeatable harness compiles the actual shared Swift generator, creates real nested media with an ampersand and accented filename, and checks: +The repeatable harness builds a Swift package executable linked to the actual engine generator, creates real nested media with an ampersand and accented filename, and checks: - Manifest and chain against the official XSDs. - File bytes with `ascmhl-debug verify`; inventory with `ascmhl diff`. @@ -31,3 +31,9 @@ Scripts/ascmhl/validate_reference.sh /path/to/ascmitc/mhl /path/to/venv/bin ``` [Recorded output](reference-validation.txt) includes the expected corruption error followed by the passing negative-control assertion. This is synthetic interoperability evidence, not validation against other offload and DIT tools, a DIT's production media, or physical iOS storage. Those acceptance checks remain outstanding. + +## October 2026 compatibility corpus + +The reference checks were rerun on October 10, 2026 at the same pinned reference revision. A permanent corpus now covers official-tool MD5/XXH64 records, two generations, C4-only unsupported evidence, nested inventories and unusual filenames, plus saved-report compatibility and damaged-input regressions. [Findings and current validation](../../EVIDENCE_COMPATIBILITY_VALIDATION.md). + +Check verifies recorded file contents. It now says explicitly that it has not verified the ASC history chain or directory structure. Unreadable or unsupported file evidence blocks a saved-checksum verdict rather than silently shortening the check. This does not add classic-MHL discovery, chain verification or history extension. diff --git a/test.sh b/test.sh index 78ebe3ff..73d8f582 100755 --- a/test.sh +++ b/test.sh @@ -28,12 +28,14 @@ case "$JOB" in run_xcodebuild ipad-test test -scheme BitMatch-iPad -destination "$IOS_SIMULATOR_DESTINATION" -only-testing:BitMatch-iPadTests ;; engine-test) + # Keep independent disk fixtures from racing short setup deadlines. Each + # test still exercises its own concurrent copy/verify workers. # Match the Apple Silicon test host to the package's arm64 bundle. Newer # Xcode hosts may otherwise choose arm64e and refuse to load XCTest. if [[ "$(uname -m)" == arm64 ]]; then - arch -arm64 swift test --package-path "$ROOT/Packages/BitMatchEngine" --build-system native + arch -arm64 swift test --package-path "$ROOT/Packages/BitMatchEngine" --build-system native --no-parallel else - swift test --package-path "$ROOT/Packages/BitMatchEngine" --build-system native + swift test --package-path "$ROOT/Packages/BitMatchEngine" --build-system native --no-parallel fi ;; release-builds) From ce689bf3837866d2a9112df23b7a8606ee6ce443 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 10 Oct 2026 21:08:46 -0400 Subject: [PATCH 2/2] Record combined queue and evidence compatibility validation --- CHANGELOG.md | 3 +++ docs/EVIDENCE_COMPATIBILITY_VALIDATION.md | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0cce36..aeaca8a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Fixed saved-checksum checks for nested card folders, leading/trailing filename spaces and namespace-prefixed ASC MHL files. +- Damaged or unsupported checksum records now get a clear explanation instead of silently checking only the readable part. Unknown report algorithms no longer count as verified. ASC results state that file contents were checked, not history chains or directory structure. +- Added permanent synthetic report/MHL compatibility fixtures, checked with the official ASC tools, and repaired the independent reference-validation script. - Recheck the original source and backup folders when editing or starting a transfer. Replaced or disconnected folders are refused before copying, even when the path looks unchanged. - Catch queued cards that would share or nest their output folders, including two cards named “NO NAME.” - Expanded queue and History details show reused, newly copied, failed and pending files for each backup. Retries link to the previous attempt, which stays in History; unknown older counts stay unknown. JSON and CSV history exports retain the recovery details. diff --git a/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md b/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md index 1899907d..5fa429a6 100644 --- a/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md +++ b/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md @@ -34,8 +34,14 @@ The two new Mac presentation/integration tests were also mutation-checked. Repla The full Mac suite passed: 1,092 passed, 12 opt-in/hardware skips, zero failures. Both new warning captures rendered at 580 and 1,000 points. AppKit bitmap captures omit some native layers, so these are layout evidence, not a substitute for the signed-app walkthrough. +The iPhone 17 Pro simulator passed all 71 tests on the compatibility branch. After merging #20 into that branch, the iPad Air 11-inch (M4) simulator passed all 72 tests. Both runs included opt-in workflow renders. The new warning was inspected at 320 and 820 points; text and actions remained readable. These are simulated/seeded views, not physical-device file-provider testing. + The initial reference environment used the shell's Python 3.9 and failed the tool's Python 3.11+ requirement. The successful reference run uses a separate Python 3.12 environment. That setup failure is retained with the test logs under `dist/validation/evidence-compat/`. +## Combined pre-release checks + +After merging #20 into this branch, the full Mac run passed: 1099 passed, 13 skipped, zero failures. `test.sh engine-test` also passed: 123 XCTest cases, 15 intentional skips, zero failures; the 150-test Swift Testing run passed, with the same six opt-in skips listed above. The mounted exFAT handoff and 54-transfer filesystem matrix ran. The final repaired reference script passed its corpus, fresh output, second-generation and corruption controls. + ## Limits This is file-content interoperability coverage, not certification against every offload tool. BitMatch still does not discover standalone classic MHL, verify ASC directory/root hashes or chains, interpret rename history, or extend existing histories. Those roadmap items remain open. Supported BitMatch reports still take precedence over ASC inventories for Check. The saved-report scanner summarizes past evidence; it does not freshly verify media. Synthetic fixtures do not establish physical cable, dock or drive reliability.