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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions BitMatchTests/ReportScannerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
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
Expand All @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>.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))
}
}
}
Loading