diff --git a/CHANGELOG.md b/CHANGELOG.md index de4d3c52..67547aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. ## [0.2.8] - 2026-10-10 +- Check recognizes macOS metadata companions of MHL files on exFAT, so they no longer make an intact saved history look unreadable. Other `._` files are still validated normally. + - 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. diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift index cc243e60..ceeab3e2 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift @@ -135,6 +135,9 @@ public struct SavedChecksumCheck: Sendable { var mhlEntries: [ExpectedFile] = [] for historyURL in histories { try Task.checkCancellation() + // macOS may materialize extended attributes as ._manifest.mhl on + // exFAT. Prove it is metadata; a ._ name alone is not enough. + if try Self.isMHLMetadataCompanion(historyURL) { continue } mhlEntries.append(contentsOf: try Self.readMHL(historyURL, root: canonicalRoot)) } guard !mhlEntries.isEmpty else { throw CheckError.notFound } @@ -347,6 +350,15 @@ public struct SavedChecksumCheck: Sendable { return relative.split(separator: "/").contains { $0.caseInsensitiveCompare("ascmhl") == .orderedSame } } + private static func isMHLMetadataCompanion(_ url: URL) throws -> Bool { + guard url.lastPathComponent.hasPrefix("._") else { return false } + guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize else { + throw CheckError.unreadableEvidence + } + let entry = FileEntry(url: url, relativePath: url.lastPathComponent, size: Int64(size)) + return try !AppleDoubleSelection.companions(in: [entry]).isEmpty + } + private static func readMHL(_ url: URL, root: URL) throws -> [ExpectedFile] { let parser = XMLParser(data: try boundedEvidenceData(url)) let delegate = MHLParser() diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift index 77c268c5..ef6b0d78 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift @@ -216,6 +216,38 @@ struct EvidenceCompatibilityTests { #expect(result.skipped.first?.reason == .unreadable) } + /// Plant: parse metadata companions as MHL, or skip every ._ filename without validation. + @Test(arguments: ["metadata", "lookalike", "orphan", "data-fork", "legitimate-record"]) + func mhlMetadataCompanionsAreNotEvidence(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(xml(), path: "ascmhl/0001.mhl", root: root) + var bytes = [UInt8](repeating: 0, count: 42) + func put(_ value: UInt32, _ offset: Int) { + for index in 0..<4 { bytes[offset + index] = UInt8(truncatingIfNeeded: value >> (24 - 8 * index)) } + } + put(0x00051607, 0); put(0x00020000, 4); bytes[25] = 1 + put(kind == "data-fork" ? 1 : 2, 26); put(38, 30); put(4, 34) + let name = kind == "orphan" ? "._missing.mhl" : "._0001.mhl" + let companion = root.appendingPathComponent("ascmhl/" + name) + if kind == "lookalike" { try Data("damaged MHL".utf8).write(to: companion) } + else if kind == "legitimate-record" { + try write("A", path: "b.txt", root: root) + try Data(xml().replacingOccurrences(of: "a.txt", with: "b.txt").utf8).write(to: companion) + } + else { try Data(bytes).write(to: companion) } + let before = try Data(contentsOf: companion) + if kind == "metadata" || kind == "legitimate-record" { + let result = try await run(root) + #expect(result.isIntact) + #expect(result.records.count == (kind == "metadata" ? 1 : 2)) + } else { + await #expect(throws: (any Error).self) { _ = try await checker.discover(under: root) } + } + #expect(try Data(contentsOf: companion) == before) + } + /// Plant: treat any algorithm paired with "checksum" as Standard. @Test(arguments: ["BLAKE3", "future-SHA-256-ish", "", "SHA-256, UNKNOWN"]) func unknownReportAlgorithmsAreNotVerified(algorithm: String) { diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Issue10ExFATHandoffTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Issue10ExFATHandoffTests.swift index 2f9d2e66..59cb9d0b 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Issue10ExFATHandoffTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Issue10ExFATHandoffTests.swift @@ -121,6 +121,32 @@ final class Issue10ExFATHandoffTests: XCTestCase { } } + /// Plant: include a recognized ._manifest.mhl metadata companion in the XML inputs. + func testSavedCheckIgnoresRealExFATMHLMetadataCompanion() async throws { + let destination = mount.appendingPathComponent("Metadata", isDirectory: true) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + let data = Data("synthetic media".utf8) + try data.write(to: destination.appendingPathComponent("clip.bin")) + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + let manifest = try ASCMHLGenerator.generateInitialHistory(destinationURL: destination, + files: [.init(relativePath: "clip.bin", size: Int64(data.count), expectedSHA256: digest)], + startTime: Date(), toolVersion: "metadata-probe") + let attribute = Data("synthetic attribute".utf8) + let status = attribute.withUnsafeBytes { + setxattr(manifest.path, "com.bitmatch.fixture", $0.baseAddress, $0.count, 0, 0) + } + XCTAssertEqual(status, 0) + let companion = manifest.deletingLastPathComponent().appendingPathComponent("._" + manifest.lastPathComponent) + XCTAssertTrue(FileManager.default.fileExists(atPath: companion.path), "Must exercise a real macOS-generated companion") + let before = try Data(contentsOf: companion) + let checker = SavedChecksumCheck(fileAccess: LocalFileAccess(), checksum: ChecksumEngine.shared) + let result = try await checker.check(checker.discover(under: destination)) { _ in } + XCTAssertTrue(result.isIntact) + XCTAssertEqual(result.recordedFileCount, 1) + XCTAssertEqual(result.records.count, 1) + XCTAssertEqual(try Data(contentsOf: companion), before) + } + /// Extends the existing real-exFAT copy test through the handoff step /// performed by CopyVerifyExecutor AFTER TransferPipeline has returned. func testStandardCopyThenMHLOnRealExFAT() async throws { diff --git a/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md b/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md index 5fa429a6..6082d6e7 100644 --- a/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md +++ b/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md @@ -45,3 +45,11 @@ After merging #20 into this branch, the full Mac run passed: 1099 passed, 13 ski ## 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. + +## Signed-walkthrough follow-up + +The first signed 0.2.8 candidate completed a four-file Standard transfer to APFS and exFAT, with matching source/destination hashes, MHL on both backups and reports. Check then refused the valid exFAT history because macOS had created `._.mhl` for its extended attributes. The stricter reader was treating that metadata container as an XML document. This was reproduced in the signed UI before publication. + +Discovery now excludes an MHL input only when the existing bounded AppleDouble validator proves it is a metadata-only container with a real companion. This does not remove any file or change transfer filtering. A real mounted-exFAT regression uses `setxattr` to make macOS create the companion. A five-case synthetic test covers recognized metadata, a damaged lookalike, an orphan, an AppleDouble data-fork record and legitimate XML with a `._` name. Both metadata regressions failed before the fix and passed afterward; the other four controls remain correct. The first test draft incorrectly expected two records for duplicate file evidence; it was corrected to use distinct files before the retained red run. + +The official verifier initially rejected the signed exFAT folder for newly generated metadata files absent from the MHL. It verified all four recorded media files when run with its explicit `._*` ignore pattern. That reference invocation checks this known synthetic media set; BitMatch's production record filter is narrower and validates the container and companion.