From 381a51ffc0c448b55d0c8e2733878d41533f825c Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Mon, 19 Oct 2026 20:06:42 -0400 Subject: [PATCH] Reject contradictory digests in legacy named-mode reports --- BitMatchTests/ReportScannerTests.swift | 16 +++++ .../Evidence/EvidenceReader.swift | 34 +++++++---- .../VerificationScopeEvidenceTests.swift | 58 +++++++++++++++++++ 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/BitMatchTests/ReportScannerTests.swift b/BitMatchTests/ReportScannerTests.swift index 8fa202b7..78e8629b 100644 --- a/BitMatchTests/ReportScannerTests.swift +++ b/BitMatchTests/ReportScannerTests.swift @@ -178,6 +178,22 @@ struct ReportScannerTests { #expect(MasterReportPresentation.statusText(for: card).contains("mode not recorded")) } + @Test(arguments: ["standard", "thorough", "paranoid", "byte-compare"]) + func legacyNamedMethodCannotHideAnUnsupportedDigest(method: String) 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["method"] = method + verification["algorithm"] = "ROT13" + verification.removeValue(forKey: "scope") + 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 contradictoryQuickScopeCannotBecomeVerified() throws { let root = try makeTemporaryFolder() defer { try? FileManager.default.removeItem(at: root) } diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift index 0219137a..5adaf40b 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift @@ -179,18 +179,34 @@ public enum EvidenceReader: Sendable { /// 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 algorithms: Set + if let algorithm { + 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 } + algorithms = Set(names) + } else { + algorithms = [] + } + let hasSHA256 = !algorithms.isDisjoint(with: ["sha-256", "sha256"]) let mode: VerificationMode switch method.lowercased() { 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 } + guard algorithm != nil else { return nil } + mode = .standard + // Older named modes may omit the algorithm. A supplied digest list + // must support that mode rather than contradict its recorded plan. + case "standard": + guard algorithm == nil || hasSHA256 else { return nil } mode = .standard - case "standard": mode = .standard - case "thorough": mode = .thorough - case "checksum-and-byte-compare", "paranoid", + case "thorough": + guard algorithm == nil || (hasSHA256 && algorithms.contains("md5")) else { return nil } + mode = .thorough + case "paranoid": + guard algorithm == nil || hasSHA256 else { return nil } + mode = .paranoid + case "checksum-and-byte-compare", "byte-compare", "byte-to-byte", "byte_compare": mode = .paranoid default: return nil @@ -199,10 +215,6 @@ public enum EvidenceReader: Sendable { 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 } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift index d3865deb..13754969 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift @@ -109,3 +109,61 @@ struct VerificationScopeEvidenceTests { #expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: filtered, sourceFileCount: 2)) } } + +struct LegacyReportDigestRegressionTests { + @Test func suppliedUnsupportedDigestCannotBypassAnExplicitLegacyMode() { + let mode = EvidenceReader.verificationMode(method: "standard", algorithm: "ROT13") + #expect(mode == nil) + #expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: mode, totalResults: 1, safeToErase: true)) + #expect(EvidenceReader.recordedVerificationMode(method: "standard", algorithm: "ROT13") == nil) + } +} + +struct LegacyReportDigestCompatibilityTests { + @Test(arguments: ["standard", "thorough", "paranoid", "byte-compare"], + ["ROT13", "SHA-1", "", "SHA-256, UNKNOWN", "SHA-256,"]) + func suppliedUnsupportedOrMalformedDigestsAreRejected(method: String, algorithm: String) { + #expect(EvidenceReader.verificationMode(method: method, algorithm: algorithm) == nil) + #expect(EvidenceReader.recordedVerificationMode(method: method, algorithm: algorithm) == nil) + } + + @Test func suppliedDigestsMustSupportTheExplicitNamedMode() { + for method in ["standard", "paranoid"] { + #expect(EvidenceReader.verificationMode(method: method, algorithm: "MD5") == nil) + #expect(EvidenceReader.verificationMode(method: method, algorithm: "XXH64") == nil) + #expect(EvidenceReader.verificationMode(method: method, algorithm: "SHA-256") != nil) + } + #expect(EvidenceReader.verificationMode(method: "thorough", algorithm: "MD5") == nil) + #expect(EvidenceReader.verificationMode(method: "thorough", algorithm: "SHA-256") == nil) + #expect(EvidenceReader.verificationMode(method: "thorough", algorithm: "SHA-256, MD5") == .thorough) + #expect(EvidenceReader.verificationMode(method: "standard", algorithm: "SHA-256, MD5") == .standard) + } + + @Test func genuineLegacyMissingAlgorithmsAndGenericContentChecksRemainReadable() { + for method in ["standard", "thorough", "paranoid", "byte-compare", "byte-to-byte", "byte_compare", "checksum-and-byte-compare"] { + let mode = EvidenceReader.verificationMode(method: method, algorithm: nil) + #expect(mode != nil) + #expect(EvidenceReader.isVerified(matches: 1, issues: 0, mode: mode, totalResults: 1, safeToErase: true)) + let recordedMode = EvidenceReader.recordedVerificationMode(method: method, algorithm: nil) + #expect(recordedMode == (["standard", "thorough", "paranoid"].contains(method) ? mode : nil)) + } + for algorithm in ["MD5", "XXH64", "SHA-256, MD5"] { + #expect(EvidenceReader.verificationMode(method: "checksum", algorithm: algorithm) == .standard) + #expect(EvidenceReader.recordedVerificationMode(method: "checksum", algorithm: algorithm) == nil) + } + } + + @Test(arguments: VerificationMode.allCases) + func currentWriterMethodsRemainReadableWithoutInventingAnUnrecordedMode(mode: VerificationMode) throws { + let description = EvidenceWriter.verificationDescription(for: ReportPrefs(verificationMode: mode)) + let written = EnhancedJSONReport.Verification(method: description.method, algorithm: description.algorithm, + issuesByType: [:], checksumCache: nil) + let read = try JSONDecoder().decode(EvidenceReader.Snapshot.Verification.self, from: JSONEncoder().encode(written)) + let resolved = EvidenceReader.verificationMode(method: read.method, algorithm: read.algorithm) + #expect(resolved == (mode == .thorough ? .standard : mode)) + if mode == .quick { + #expect(read.algorithm == nil) + #expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: resolved, totalResults: 1, safeToErase: true)) + } + } +}