diff --git a/BitMatchTests/ComparePresentationTests.swift b/BitMatchTests/ComparePresentationTests.swift index b386446a..2a36e24e 100644 --- a/BitMatchTests/ComparePresentationTests.swift +++ b/BitMatchTests/ComparePresentationTests.swift @@ -100,6 +100,17 @@ struct ComparePresentationTests { #expect(verdict.detail.contains("File contents checked; MHL history and directory structure were not verified.")) } + @Test func validatedASCChainDoesNotClaimDirectoryOrAuthenticityVerification() { + let history = Self.card.appendingPathComponent("ascmhl") + let result = SavedChecksumCheck.Result(root: Self.card, matchingPaths: ["a.mov"], changedPaths: [], + missingPaths: [], newPaths: [], records: [.init(url: history.appendingPathComponent("one.mhl"), + date: Date(), kind: .ascMHL)], validatedHistoryDirectories: [history]) + let verdict = CompareVerdictPresentation.makeSaved(result) + #expect(verdict.tone == .verified) + #expect(verdict.detail.contains("File contents and supported MHL inventory chains checked")) + #expect(verdict.detail.contains("directory structure and authenticity 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/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLChainValidator.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLChainValidator.swift new file mode 100644 index 00000000..301ebb78 --- /dev/null +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLChainValidator.swift @@ -0,0 +1,177 @@ +import CryptoKit +import Darwin +import Foundation +#if canImport(FoundationXML) +import FoundationXML +#endif + +/// Read-only validation of supported ASC directory records. C4 binds the listed +/// inventory bytes; it is not authentication, directory-hash validation, or proof +/// that an unanchored external chain has never had a tail generation removed. +enum ASCMHLChainValidator { + static let maxGenerations = 4096 + static let maxChainBytes = 4 * 1024 * 1024 + + struct FileIdentity: Equatable, Sendable { + let device: Int32 + let inode: UInt64 + let size: Int64 + let modificationSeconds: Int64 + let modificationNanoseconds: Int64 + let changeSeconds: Int64 + let changeNanoseconds: Int64 + let sha256: String + } + struct Snapshot: Equatable, Sendable { + let directory: URL + let names: [String] + let device: Int32 + let inode: UInt64 + let evidence: [String: FileIdentity] + } + + static func validate(directory: URL) throws -> Snapshot { + try Task.checkCancellation() + let fd = open(directory.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard fd >= 0 else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + defer { close(fd) } + var before = stat() + guard fstat(fd, &before) == 0 else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + let (bytes, identity) = try read("ascmhl_chain.xml", directoryFD: fd, maxBytes: maxChainBytes) + guard String(data: bytes, encoding: .utf8)?.contains("() + for url in listed where url.pathExtension.caseInsensitiveCompare("mhl") == .orderedSame { + try Task.checkCancellation() + if url.lastPathComponent.hasPrefix("._"), names.contains(String(url.lastPathComponent.dropFirst(2))) { + let companionFD = openat(fd, url.lastPathComponent, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard companionFD >= 0 else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + defer { close(companionFD) } + var info = stat() + guard fstat(companionFD, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + if try AppleDoubleSelection.isMetadataOnlyCompanion(fd: companionFD, size: info.st_size) { continue } + } + actual.insert(url.lastPathComponent) + } + guard actual == Set(names) else { throw SavedChecksumCheck.CheckError.unsupportedEvidence } + var after = stat() + var named = stat() + guard fstat(fd, &after) == 0, lstat(directory.path, &named) == 0, + before.st_dev == after.st_dev, before.st_ino == after.st_ino, + after.st_dev == named.st_dev, after.st_ino == named.st_ino, + (named.st_mode & S_IFMT) == S_IFDIR else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + return Snapshot(directory: directory, names: names, device: after.st_dev, inode: UInt64(after.st_ino), evidence: evidence) + } + + private static func safeName(_ name: String) -> Bool { + !name.isEmpty && name != "." && name != ".." && !name.contains("/") && !name.contains("\\") + && !name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + && name.lowercased().hasSuffix(".mhl") && !name.hasPrefix("._") + } + + private static func read(_ name: String, directoryFD: Int32, maxBytes: Int) throws -> (Data, FileIdentity) { + try Task.checkCancellation() + let fd = openat(directoryFD, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard fd >= 0 else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + defer { try? handle.close() } + var before = stat() + guard fstat(fd, &before) == 0, (before.st_mode & S_IFMT) == S_IFREG, + before.st_size >= 0, before.st_size <= maxBytes else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + var data = Data() + while data.count < Int(before.st_size) { + try Task.checkCancellation() + let count = min(64 * 1024, Int(before.st_size) - data.count) + let chunk = try autoreleasepool { try handle.read(upToCount: count) ?? Data() } + guard !chunk.isEmpty else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + data.append(chunk) + } + guard (try handle.read(upToCount: 1) ?? Data()).isEmpty else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + var after = stat() + var named = stat() + guard fstat(fd, &after) == 0, fstatat(directoryFD, name, &named, AT_SYMLINK_NOFOLLOW) == 0, + identity(before, digest: "") == identity(after, digest: ""), + after.st_dev == named.st_dev, after.st_ino == named.st_ino, + (named.st_mode & S_IFMT) == S_IFREG else { throw SavedChecksumCheck.CheckError.unreadableEvidence } + return (data, identity(after, digest: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined())) + } + + private static func identity(_ s: stat, digest: String) -> FileIdentity { + FileIdentity(device: s.st_dev, inode: UInt64(s.st_ino), size: s.st_size, + modificationSeconds: Int64(s.st_mtimespec.tv_sec), modificationNanoseconds: Int64(s.st_mtimespec.tv_nsec), + changeSeconds: Int64(s.st_ctimespec.tv_sec), changeNanoseconds: Int64(s.st_ctimespec.tv_nsec), sha256: digest) + } + + private final class ChainParser: NSObject, XMLParserDelegate { + struct Item { var sequence: Int; var path = ""; var c4 = "" } + var items: [Item] = [] + var failure: SavedChecksumCheck.CheckError? + private var stack: [String] = [] + private var text = "" + private var fields = Set() + private var current: Item? + private func reject(_ parser: XMLParser, _ error: SavedChecksumCheck.CheckError = .unreadableEvidence) { + failure = error; parser.abortParsing() + } + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) { + if Task.isCancelled { parser.abortParsing(); return } + guard namespaceURI == "urn:ASC:MHL:DIRECTORY:v2.0" else { reject(parser, .unsupportedEvidence); return } + let expected: Bool + switch stack { + case []: expected = name == "ascmhldirectory" && attributes.isEmpty + case ["ascmhldirectory"]: + expected = name == "hashlist" && attributes.count == 1 && attributes["sequencenr"].flatMap(Int.init) != nil + if expected { + guard items.count < maxGenerations else { reject(parser, .unsupportedEvidence); return } + current = Item(sequence: Int(attributes["sequencenr"]!)!); fields.removeAll() + } + case ["ascmhldirectory", "hashlist"]: + expected = (name == "path" && fields.isEmpty || name == "c4" && fields == ["path"]) && attributes.isEmpty && fields.insert(name).inserted + default: expected = false + } + guard expected else { reject(parser, .unsupportedEvidence); return } + stack.append(name); text = "" + } + func parser(_ parser: XMLParser, foundCharacters string: String) { + if stack.count != 3 && !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { reject(parser); return } + text += string + } + func parser(_ parser: XMLParser, foundCDATA data: Data) { reject(parser, .unsupportedEvidence) } + 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 name: String, namespaceURI: String?, qualifiedName: String?) { + defer { if !stack.isEmpty { stack.removeLast() }; text = "" } + if stack.count == 3 { + if name == "path" { current?.path = text } + if name == "c4" { current?.c4 = text } + } else if name == "hashlist" { + guard let current, fields == ["path", "c4"] else { reject(parser); return } + items.append(current); self.current = nil + } + } + } +} diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLGenerator.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLGenerator.swift index ddf38fd1..7e5d8ccf 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLGenerator.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLGenerator.swift @@ -19,6 +19,19 @@ public enum ASCMHLGenerator: Sendable { return Data("COMPLETE \(name) \(manifest.count) \(manifestHash) \(chain.count) \(chainHash)\n".utf8) } + /// Exact v1 writer bytes, also used to verify the original FAT completion + /// witness after a different tool appends supported generations. Equivalent + /// XML is insufficient: size and SHA-256 must match the original witness. + static func initialChain(name: String, manifest: Data) -> String { + """ + + + \(name)\(c4(manifest)) + + + """ + } + private static func isGeneratedFATManifestName(_ name: String) -> Bool { let match = name.range( of: #"^0001_BitMatch_FAT_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}Z\.mhl$"#, @@ -295,13 +308,7 @@ public enum ASCMHLGenerator: Sendable { timestamp.dateFormat = "yyyy-MM-dd_HHmmss'Z'" let fatFamily = ["exfat", "msdos"].contains(destinationFileSystemType?.lowercased() ?? "") let name = "\(fatFamily ? fatManifestPrefix : "0001_BitMatch_")\(timestamp.string(from: Date())).mhl" - let chain = """ - - - \(name)\(c4(Data(xml.utf8))) - - - """ + let chain = initialChain(name: name, manifest: Data(xml.utf8)) try Task.checkCancellation() try rejectExistingHistory(root) var currentIdentity = stat() @@ -496,8 +503,12 @@ public enum ASCMHLGenerator: Sendable { /// Check's reader uses the same exact status format as the writer. A /// missing, truncated, replaced or mismatching completion stays unsafe. - static func publicationIsComplete(at statusURL: URL) throws -> Bool { - let directoryFD = open(statusURL.deletingLastPathComponent().path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + static func publicationIsComplete( + at statusURL: URL, + beforeFinalIdentityCheck: (@Sendable () throws -> Void)? = nil + ) throws -> Bool { + let directory = statusURL.deletingLastPathComponent() + let directoryFD = open(directory.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) guard directoryFD >= 0 else { return false } defer { close(directoryFD) } do { @@ -516,9 +527,32 @@ public enum ASCMHLGenerator: Sendable { let manifestName = String(fields[1]) guard let manifest = try boundedFile(manifestName, directoryFD: directoryFD, maxBytes: EvidenceReader.maxReportBytes), let chain = try boundedFile("ascmhl_chain.xml", directoryFD: directoryFD, maxBytes: EvidenceReader.maxReportBytes), - manifest.count == manifestSize, chain.count == chainSize else { return false } - return hex(SHA256.hash(data: manifest)) == fields[3].lowercased() - && hex(SHA256.hash(data: chain)) == fields[5].lowercased() + manifest.count == manifestSize, + hex(SHA256.hash(data: manifest)) == fields[3].lowercased() else { return false } + if chain.count != chainSize || hex(SHA256.hash(data: chain)) != fields[5].lowercased() { + let original = Data(initialChain(name: manifestName, manifest: manifest).utf8) + guard original.count == chainSize, hex(SHA256.hash(data: original)) == fields[5].lowercased() else { return false } + let validated = try ASCMHLChainValidator.validate(directory: directory) + var pinnedDirectory = stat() + guard fstat(directoryFD, &pinnedDirectory) == 0, + validated.device == pinnedDirectory.st_dev, validated.inode == UInt64(pinnedDirectory.st_ino), + validated.evidence[manifestName]?.size == Int64(manifestSize), + validated.evidence[manifestName]?.sha256 == fields[3].lowercased(), + validated.evidence["ascmhl_chain.xml"]?.sha256 == hex(SHA256.hash(data: chain)), + validated.evidence[publicationStatusName]?.sha256 == hex(SHA256.hash(data: status)), + validated.names.count > 1, validated.names.first == manifestName else { return false } + } + // Test seam only: production never supplies a hook. Both the + // original-chain and extended-chain paths must still name the + // directory whose descriptors supplied this completion evidence. + try beforeFinalIdentityCheck?() + var pinnedDirectory = stat() + var namedDirectory = stat() + guard fstat(directoryFD, &pinnedDirectory) == 0, lstat(directory.path, &namedDirectory) == 0, + (namedDirectory.st_mode & S_IFMT) == S_IFDIR, + pinnedDirectory.st_dev == namedDirectory.st_dev, + pinnedDirectory.st_ino == namedDirectory.st_ino else { return false } + return true } catch is CancellationError { throw CancellationError() } catch { diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift index a36837cd..dc21cb45 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift @@ -1,5 +1,6 @@ // SavedChecksumCheck.swift - Re-checks a folder against recorded evidence. import Foundation +import CryptoKit #if canImport(FoundationXML) import FoundationXML #endif @@ -26,11 +27,13 @@ public struct SavedChecksumCheck: Sendable { public let url: URL public let date: Date public let kind: RecordKind + public let generation: Int? - public init(url: URL, date: Date, kind: RecordKind) { + public init(url: URL, date: Date, kind: RecordKind, generation: Int? = nil) { self.url = url self.date = date self.kind = kind + self.generation = generation } } @@ -53,6 +56,7 @@ public struct SavedChecksumCheck: Sendable { public let expectedFiles: [String: ExpectedFile] public let records: [Record] public let publicationStatuses: [URL] + var chainEvidence: [ASCMHLChainValidator.Snapshot] = [] public init(root: URL, expectedFiles: [String: ExpectedFile], records: [Record], publicationStatuses: [URL] = []) { self.root = root @@ -71,6 +75,8 @@ public struct SavedChecksumCheck: Sendable { public let missingPaths: [String] public let newPaths: [String] public let records: [Record] + /// Validated inventory-byte chains only; no authenticity or directory/root hashes. + public let validatedHistoryDirectories: [URL] public init( root: URL, @@ -78,7 +84,8 @@ public struct SavedChecksumCheck: Sendable { changedPaths: [String], missingPaths: [String], newPaths: [String], - records: [Record] + records: [Record], + validatedHistoryDirectories: [URL] = [] ) { self.root = root self.matchingPaths = matchingPaths @@ -86,6 +93,7 @@ public struct SavedChecksumCheck: Sendable { self.missingPaths = missingPaths self.newPaths = newPaths self.records = records + self.validatedHistoryDirectories = validatedHistoryDirectories } public var isIntact: Bool { changedPaths.isEmpty && missingPaths.isEmpty } @@ -135,16 +143,23 @@ public struct SavedChecksumCheck: Sendable { } let histories = files.filter { Self.isMHLHistory($0, under: canonicalRoot) } + let chains = try Self.validateHistoryChains(in: Self.chainCandidates(in: files, under: canonicalRoot)) + let ranks = Dictionary(uniqueKeysWithValues: chains.flatMap { chain in + chain.names.enumerated().map { (chain.directory.appendingPathComponent($0.element), $0.offset + 1) } + }) 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)) + let chain = chains.first { $0.directory == historyURL.deletingLastPathComponent() } + mhlEntries.append(contentsOf: try Self.readMHL(historyURL, root: canonicalRoot, generation: ranks[historyURL], expectedDigest: chain?.evidence[historyURL.lastPathComponent]?.sha256)) } guard !mhlEntries.isEmpty else { throw CheckError.notFound } - return Self.makeDiscovery(root: canonicalRoot, entries: mhlEntries, publicationStatuses: statuses) + var discovery = Self.makeDiscovery(root: canonicalRoot, entries: mhlEntries, publicationStatuses: statuses) + discovery.chainEvidence = chains + return discovery } /// Re-reads every recorded file, reports ordered progress, and identifies @@ -163,6 +178,7 @@ public struct SavedChecksumCheck: Sendable { guard statuses == discovery.publicationStatuses else { throw CheckError.unreadableEvidence } + try Self.requireUnchangedChains(discovery, files: files) let resolver = RelativePathResolver(base: root) var actual: [String: URL] = [:] actual.reserveCapacity(files.count) @@ -208,6 +224,7 @@ public struct SavedChecksumCheck: Sendable { guard try Self.validPublicationStatuses(in: finalFiles, under: root) == discovery.publicationStatuses else { throw CheckError.unreadableEvidence } + try Self.requireUnchangedChains(discovery, files: finalFiles) let newPaths = Set(actual.keys).subtracting(expected.keys).sorted() let used = Set(expected.values.map(\.record.url)) return Result( @@ -216,7 +233,8 @@ public struct SavedChecksumCheck: Sendable { changedPaths: changed, missingPaths: missing, newPaths: newPaths, - records: discovery.records.filter { used.contains($0.url) } + records: discovery.records.filter { used.contains($0.url) }, + validatedHistoryDirectories: discovery.chainEvidence.map(\.directory) ) } @@ -413,6 +431,41 @@ public struct SavedChecksumCheck: Sendable { return statuses } + private static func chainCandidates(in files: [URL], under root: URL) -> [URL] { + files.filter { isMHLHistory($0, under: root) || ($0.lastPathComponent.caseInsensitiveCompare("ascmhl_chain.xml") == .orderedSame + && $0.deletingLastPathComponent().lastPathComponent.caseInsensitiveCompare("ascmhl") == .orderedSame) } + } + + private static func validateHistoryChains(in histories: [URL]) throws -> [ASCMHLChainValidator.Snapshot] { + let directories = Set(histories.map { $0.deletingLastPathComponent() }).sorted { $0.path < $1.path } + var chains: [ASCMHLChainValidator.Snapshot] = [] + for directory in directories { + try Task.checkCancellation() + // A case variant on a case-sensitive volume must not downgrade a + // damaged chain into legacy content-only evidence. + let chainNames = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.caseInsensitiveCompare("ascmhl_chain.xml") == .orderedSame } + guard chainNames.count <= 1, chainNames.allSatisfy({ $0.lastPathComponent == "ascmhl_chain.xml" }) else { + throw CheckError.unsupportedEvidence + } + // Legacy inventories without a chain remain content-only evidence. + let chain = directory.appendingPathComponent("ascmhl_chain.xml") + var info = stat() + if lstat(chain.path, &info) == 0 { + chains.append(try ASCMHLChainValidator.validate(directory: directory)) + } else if errno != ENOENT { throw CheckError.unreadableEvidence } + } + return chains + } + + private static func requireUnchangedChains(_ discovery: Discovery, files: [URL]) throws { + // Report-only discovery does not acquire an ASC-chain claim. FAT + // publication status is still validated separately before reports. + guard discovery.records.contains(where: { $0.kind == .ascMHL }) else { return } + let histories = chainCandidates(in: files, under: discovery.root) + guard try validateHistoryChains(in: histories) == discovery.chainEvidence else { throw CheckError.unreadableEvidence } + } + 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 { @@ -422,17 +475,22 @@ public struct SavedChecksumCheck: Sendable { return try !AppleDoubleSelection.companions(in: [entry]).isEmpty } - private static func readMHL(_ url: URL, root: URL) throws -> [ExpectedFile] { - let parser = XMLParser(data: try boundedEvidenceData(url)) + private static func readMHL(_ url: URL, root: URL, generation: Int? = nil, expectedDigest: String? = nil) throws -> [ExpectedFile] { + let bytes = try boundedEvidenceData(url) + if let expectedDigest { + guard SHA256.hash(data: bytes).map({ String(format: "%02x", $0) }).joined() == expectedDigest else { throw CheckError.unreadableEvidence } + } + let parser = XMLParser(data: bytes) let delegate = MHLParser() parser.shouldProcessNamespaces = true parser.shouldResolveExternalEntities = false parser.delegate = delegate let parsed = parser.parse() + try Task.checkCancellation() 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) + let record = Record(url: url, date: delegate.creationDate ?? fallbackDate, kind: .ascMHL, generation: generation) // ASC paths belong to the folder containing ascmhl/, not whichever // ancestor the user selected in Check. let historyRoot = url.deletingLastPathComponent().deletingLastPathComponent() @@ -474,6 +532,7 @@ public struct SavedChecksumCheck: Sendable { } func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { + if Task.isCancelled { parser.abortParsing(); return } let name = elementName.lowercased() if stack.isEmpty && (name != "hashlist" || namespaceURI != namespace || attributeDict["version"] != "2.0") { reject(parser, .unsupportedEvidence) @@ -482,8 +541,9 @@ public struct SavedChecksumCheck: Sendable { // A later ASC generation can move a file or whole directory and // record the old location in previousPath. Until Check interprets // that history, treating each generation as unrelated file paths - // would incorrectly call valid renamed media "missing". - if name == "previouspath" { + // would incorrectly call valid renamed media "missing". Child-history + // references also require lineage semantics outside this flat slice. + if ["previouspath", "references", "hashlistreference"].contains(name) { reject(parser, .unsupportedEvidence) return } @@ -552,7 +612,22 @@ public struct SavedChecksumCheck: Sendable { } private static func makeDiscovery(root: URL, entries: [ExpectedFile], publicationStatuses: [URL]) -> Discovery { - let sorted = entries.sorted { + // Resolve generations within each history in chain order, independent + // of timestamps supplied by a different computer's clock. Then retain + // the existing date precedence between separate evidence roots. + let groups = Dictionary(grouping: entries) { $0.record.url.deletingLastPathComponent() } + var selected: [ExpectedFile] = [] + for group in groups.values { + let sorted = group.sorted { + if let left = $0.record.generation, let right = $1.record.generation, left != right { return left < right } + if $0.record.date != $1.record.date { return $0.record.date < $1.record.date } + return $0.record.url.path < $1.record.url.path + } + var latest: [String: ExpectedFile] = [:] + for entry in sorted { latest[entry.relativePath] = entry } + selected.append(contentsOf: latest.values) + } + let sorted = selected.sorted { if $0.record.date != $1.record.date { return $0.record.date < $1.record.date } return $0.record.url.path < $1.record.url.path } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLChainValidationTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLChainValidationTests.swift new file mode 100644 index 00000000..27c395f5 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLChainValidationTests.swift @@ -0,0 +1,247 @@ +import CryptoKit +import Foundation +import Testing +@testable import BitMatchEngine + +struct ASCMHLChainValidationTests { + private var checker: SavedChecksumCheck { + SavedChecksumCheck(fileAccess: LocalFileAccess(), checksum: ChecksumEngine.shared) + } + private func copy(_ dataset: String = "md5") throws -> URL { + let source = try #require(Bundle.module.resourceURL).appendingPathComponent("Fixtures/EvidenceCompatibility/reference/\(dataset)") + let root = FileManager.default.temporaryDirectory.appendingPathComponent("bitmatch-chain-\(UUID())") + try FileManager.default.copyItem(at: source, to: root) + return root + } + private func snapshot(_ root: URL) throws -> [String: Data] { + try Dictionary(uniqueKeysWithValues: CardSource.enumerateRegularFiles(base: root).map { ($0.relativePath, try Data(contentsOf: $0.url)) }) + } + private func chain(_ root: URL) -> URL { root.appendingPathComponent("ascmhl/ascmhl_chain.xml") } + private func manifests(_ root: URL) throws -> [URL] { + try FileManager.default.contentsOfDirectory(at: root.appendingPathComponent("ascmhl"), includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "mhl" && !$0.lastPathComponent.hasPrefix("._") }.sorted { $0.path < $1.path } + } + + @Test(arguments: ["md5", "xxh64", "xxh64-boundaries", "bitmatch-fat-extended"]) + func independentReferenceChainsAreReadOnly(dataset: String) async throws { + let root = try copy(dataset) + defer { try? FileManager.default.removeItem(at: root) } + let before = try snapshot(root) + let discovery = try await checker.discover(under: root) + #expect(discovery.chainEvidence.count == 1) + let result = try await checker.check(discovery) { _ in } + #expect(result.isIntact) + #expect(result.validatedHistoryDirectories == [root.appendingPathComponent("ascmhl")]) + #expect(try snapshot(root) == before) + } + + @Test(arguments: ["missing-generation", "missing-first", "sequence-gap", "reordered", "duplicate", "traversal", "absolute", "backslash", "namespace", "unsupported", "truncated", "modified-manifest", "unlisted-manifest", "symlink-manifest", "symlink-chain", "oversized-chain", "oversized-manifest", "entity", "uppercase-chain", "mixed-case-chain"]) + func damagedOrUnsupportedChainsCannotBecomeGreen(kind: String) async throws { + let root = try copy() + defer { try? FileManager.default.removeItem(at: root) } + let paths = try manifests(root) + let first = try #require(paths.first) + let second = try #require(paths.last) + let original = try String(contentsOf: chain(root), encoding: .utf8) + var modified = original + let entries = original.components(separatedBy: "")[0] + "" } + switch kind { + case "missing-generation": try FileManager.default.removeItem(at: second) + case "missing-first": modified = modified.replacingOccurrences(of: entries[0], with: "") + case "sequence-gap": modified = modified.replacingOccurrences(of: "sequencenr=\"2\"", with: "sequencenr=\"3\"") + case "reordered": modified = modified.replacingOccurrences(of: entries[0], with: "PLACEHOLDER").replacingOccurrences(of: entries[1], with: entries[0]).replacingOccurrences(of: "PLACEHOLDER", with: entries[1]) + case "duplicate": modified = modified.replacingOccurrences(of: "", with: entries[1] + "") + case "traversal": modified = modified.replacingOccurrences(of: first.lastPathComponent, with: "../" + first.lastPathComponent) + case "absolute": modified = modified.replacingOccurrences(of: first.lastPathComponent, with: "/" + first.lastPathComponent) + case "backslash": modified = modified.replacingOccurrences(of: first.lastPathComponent, with: "dir\\" + first.lastPathComponent) + case "namespace": modified = modified.replacingOccurrences(of: "urn:ASC:MHL:DIRECTORY:v2.0", with: "urn:foreign") + case "unsupported": modified = modified.replacingOccurrences(of: "", with: "") + case "truncated": modified = String(modified.dropLast(20)) + case "modified-manifest": try (Data(contentsOf: first) + Data("".utf8)).write(to: first) + case "unlisted-manifest": try Data(contentsOf: first).write(to: root.appendingPathComponent("ascmhl/0003_unlisted.mhl")) + case "symlink-manifest": + let bytes = try Data(contentsOf: first) + try bytes.write(to: root.appendingPathComponent("outside.mhl")) + try FileManager.default.removeItem(at: first) + try FileManager.default.createSymbolicLink(at: first, withDestinationURL: root.appendingPathComponent("outside.mhl")) + case "symlink-chain": + try Data(contentsOf: chain(root)).write(to: root.appendingPathComponent("outside.xml")) + try FileManager.default.removeItem(at: chain(root)) + try FileManager.default.createSymbolicLink(at: chain(root), withDestinationURL: root.appendingPathComponent("outside.xml")) + case "oversized-chain", "oversized-manifest": + let url = kind == "oversized-chain" ? chain(root) : first + let maximum = kind == "oversized-chain" ? ASCMHLChainValidator.maxChainBytes : EvidenceReader.maxReportBytes + let handle = try FileHandle(forWritingTo: url) + try handle.truncate(atOffset: UInt64(maximum + 1)); try handle.close() + case "uppercase-chain", "mixed-case-chain": + let name = kind == "uppercase-chain" ? "ASCMHL_CHAIN.XML" : "AscMhl_Chain.xml" + try FileManager.default.moveItem(at: chain(root), to: root.appendingPathComponent("ascmhl/" + name)) + case "entity": modified = modified.replacingOccurrences(of: "]>", + with: "Child/ascmhl/0001_child.mhlunsupported") + try Data(xml.utf8).write(to: manifest) + let chainXML = try String(contentsOf: chain(root), encoding: .utf8) + .replacingOccurrences(of: ASCMHLGenerator.c4(original), with: ASCMHLGenerator.c4(Data(xml.utf8))) + try Data(chainXML.utf8).write(to: chain(root)) + await #expect(throws: SavedChecksumCheck.CheckError.unsupportedEvidence) { _ = try await checker.discover(under: root) } + } + + @Test func safeCustomInventoryBasenameIsSupported() async throws { + let root = try copy() + defer { try? FileManager.default.removeItem(at: root) } + let first = try #require(manifests(root).first) + let custom = "Packing list & café.mhl" + try FileManager.default.moveItem(at: first, to: first.deletingLastPathComponent().appendingPathComponent(custom)) + let xml = try String(contentsOf: chain(root), encoding: .utf8) + .replacingOccurrences(of: first.lastPathComponent, with: "Packing list & café.mhl") + try Data(xml.utf8).write(to: chain(root)) + let result = try await checker.check(try await checker.discover(under: root)) { _ in } + #expect(result.isIntact) + #expect(result.validatedHistoryDirectories.count == 1) + } + + /// Without a trusted external tip, an otherwise valid older chain does + /// not prove that no later generation was removed before discovery. + @Test func unanchoredTailRemovalDoesNotClaimHistoricalAuthenticity() async throws { + let root = try copy() + defer { try? FileManager.default.removeItem(at: root) } + let second = try #require(manifests(root).last) + try FileManager.default.removeItem(at: second) + let xml = try String(contentsOf: chain(root), encoding: .utf8) + let entry = try #require(xml.components(separatedBy: "")[0] + "" + try Data(xml.replacingOccurrences(of: removed, with: "").utf8).write(to: chain(root)) + let result = try await checker.check(try await checker.discover(under: root)) { _ in } + #expect(result.isIntact) + #expect(result.newPaths == ["added.txt"]) + } + + @Test func cancelledChainReadPropagatesCancellation() async throws { + let root = try copy() + defer { try? FileManager.default.removeItem(at: root) } + let task = Task { + try? await Task.sleep(for: .seconds(60)) + return try ASCMHLChainValidator.validate(directory: root.appendingPathComponent("ascmhl")) + } + task.cancel() + await #expect(throws: CancellationError.self) { _ = try await task.value } + } + + @Test func chainOrderWinsOverAnIncorrectCreationClock() async throws { + let root = try copy() + defer { try? FileManager.default.removeItem(at: root) } + let second = try #require(manifests(root).last) + let oldBytes = try Data(contentsOf: second) + let old = String(decoding: oldBytes, as: UTF8.self) + let hash = Insecure.MD5.hash(data: Data("New media".utf8)).map { String(format: "%02x", $0) }.joined() + let modified = old.replacingOccurrences(of: "2026-10-10T20:40:36-04:00", with: "2020-01-01T00:00:00Z") + .replacingOccurrences(of: "0aabfb59f1df1f0ba91437d0439130bc", with: hash) + try Data(modified.utf8).write(to: second) + let chainXML = try String(contentsOf: chain(root), encoding: .utf8) + .replacingOccurrences(of: ASCMHLGenerator.c4(oldBytes), with: ASCMHLGenerator.c4(Data(modified.utf8))) + try Data(chainXML.utf8).write(to: chain(root)) + try Data("New media".utf8).write(to: root.appendingPathComponent("Camera A/clip & café.txt")) + let result = try await checker.check(try await checker.discover(under: root)) { _ in } + #expect(result.isIntact) + } +} diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLGeneratorTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLGeneratorTests.swift index 8ec711ce..1bf42ff3 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLGeneratorTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLGeneratorTests.swift @@ -390,7 +390,7 @@ final class ASCMHLGeneratorTests: XCTestCase { } } - func testExternalChainExtensionMakesBitMatchCheckFailClosed() async throws { + func testSupportedExternalChainExtensionPreservesInitialCompletionWitness() async throws { let (root, file) = try fixture() let manifest = try ASCMHLGenerator.generateInitialHistory( destinationURL: root, files: [file], startTime: Date(), toolVersion: "test", @@ -409,15 +409,16 @@ final class ASCMHLGeneratorTests: XCTestCase { XCTAssertNotEqual(extendedChain, originalChain) try extendedChain.write(to: chainURL, atomically: false, encoding: .utf8) let status = history.appendingPathComponent(ASCMHLGenerator.publicationStatusName) - XCTAssertFalse(try ASCMHLGenerator.publicationIsComplete(at: status), - "The status binds generation one; a later chain needs its own validation") + XCTAssertTrue(try ASCMHLGenerator.publicationIsComplete(at: status)) let checker = SavedChecksumCheck(fileAccess: LocalFileAccess(), checksum: ChecksumEngine.shared) - do { - _ = try await checker.discover(under: root) - XCTFail("BitMatch Check must not claim an unvalidated external chain is intact") - } catch let error as SavedChecksumCheck.CheckError { - XCTAssertEqual(error, .unreadableEvidence) - } + let discovery = try await checker.discover(under: root) + let result = try await checker.check(discovery) { _ in } + XCTAssertTrue(result.isIntact) + XCTAssertEqual(result.validatedHistoryDirectories, [history]) + // The original status remains an exact witness, not a resealed history. + let expectedStatus = ASCMHLGenerator.publicationOpening + ASCMHLGenerator.publicationCompletion( + name: manifest.lastPathComponent, manifest: manifestBytes, chain: Data(originalChain.utf8)) + XCTAssertEqual(try Data(contentsOf: status), expectedStatus) } func testNestedHistoryAndUnsafePathsAreRejected() throws { diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift index 78692c99..d850945b 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/EvidenceCompatibilityTests.swift @@ -122,6 +122,12 @@ struct EvidenceCompatibilityTests { #expect(modified != original) #expect(modified.contains("Reel B")) try modified.write(to: manifest, atomically: true, encoding: .utf8) + // Keep the derived chain self-consistent: this targets unsupported + // directory lineage, rather than the now-checked inventory C4. + let chain = history.appendingPathComponent("ascmhl_chain.xml") + let chainXML = try String(contentsOf: chain, encoding: .utf8) + .replacingOccurrences(of: ASCMHLGenerator.c4(Data(original.utf8)), with: ASCMHLGenerator.c4(Data(modified.utf8))) + try chainXML.write(to: chain, atomically: true, encoding: .utf8) await #expect(throws: SavedChecksumCheck.CheckError.unsupportedEvidence) { _ = try await checker.discover(under: root) } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/README.md b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/README.md index a8e94da6..17521476 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/README.md +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/README.md @@ -29,3 +29,12 @@ The runner requires the pinned, unmodified checkout, uses its code and schemas, Generate just new datasets without replacing existing originals using `--datasets sha1 xxh128 xxh64-boundaries md5-renames`. Default generation creates all seven datasets. Generation suppresses the reference CLI's online update check as well. `classic-v1.mhl` is a handcrafted boundary fixture, not external-tool output. It is rejected inside an ASC history both alone and beside a valid reference history; standalone classic-MHL discovery is still unsupported. This batch does not claim full chain, rename or directory-hash verification. ASC checks explicitly describe their file-content scope. Histories are never rewritten by these checks. + + +October 19 addition: `reference/bitmatch-fat-extended/` is a **sanitized derivative** of the actual engine's initial FAT-v1 history generated by `ASCMHLFixture` on a disposable mounted exFAT image. The real mounted sample separately passed official extension/verification/diff with its original status unchanged. For the public fixture, the initial hostname was replaced with `bitmatch-fixture-host` and the synthetic clip filename changed to ASCII `Camera A/clip.txt`, and its exact v1 single-generation chain/status was rebuilt during fixture creation only. The pinned official `ascmhl create -h md5 -i '._*'` then appended generation two to that sanitized copy; this is not a byte-for-byte mounted-volume capture. The first media payload comes from the original/MIT fixture executable; `added.txt` is original synthetic text. The new reference output retains the ASC MIT notice above. The official CLI hostname is the synthetic `bitmatch-fixture-host`. Its explicit metadata pattern excludes only test-volume AppleDouble companions from this portable dataset; it does not define BitMatch's production filtering policy. No existing fixture was rewritten. + +Both manifest schemas, the chain schema, two ordered reference C4 digests, original media verification and inventory diff passed independently. The first-generation `BITMATCH_HISTORY_STATUS.txt` is byte-identical after the official extension. Run `python Scripts/ascmhl/validate_fat_chain_fixture.py /path/to/pinned/ascmitc/mhl` in its Python 3.11+ venv to revalidate the immutable v1 witness, schemas, C4, media/diff and a modified-inventory negative control without editing the originals. The Swift reader tests this fixture plus missing, replaced, reordered, malformed and unsupported generations; it checks chain records, not directory hashes or historical authenticity. Valid custom basenames are supported. Removing a self-consistent unanchored tail cannot be detected as a custody violation and is expressly not claimed. + +Regenerate a new sanitized derivative with `python Scripts/ascmhl/create_fat_chain_fixture.py /path/to/pinned/reference /path/to/new/initial-only/ASCMHLFixture-output /tmp/new-fat-extended-fixture`. The input must contain only the fixture tool's known synthetic media and a complete initial FAT-v1 history. It is never edited; output must not exist. The script sanitizes before the official append and asserts that append leaves the sanitized initial witness unchanged. + +The ASCII derivative name is deliberate: the mounted sample exposed a composed/decomposed Unicode filename mismatch after Git materialization. Production path handling and the existing external Unicode corpus were not changed. The independent FAT harness also runs against a fresh Git archive of the committed fixture, rather than relying on APFS's Unicode-equivalent lookup in the creation directory. diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/Camera A/clip.txt b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/Camera A/clip.txt new file mode 100644 index 00000000..051cbe33 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/Camera A/clip.txt @@ -0,0 +1 @@ +BitMatch interoperability test diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/added.txt b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/added.txt new file mode 100644 index 00000000..d2bbfe69 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/added.txt @@ -0,0 +1 @@ +Added by the independent ASC tool after privacy sanitization diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/0001_BitMatch_FAT_2026-10-19_234711Z.mhl b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/0001_BitMatch_FAT_2026-10-19_234711Z.mhl new file mode 100644 index 00000000..f6b99378 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/0001_BitMatch_FAT_2026-10-19_234711Z.mhl @@ -0,0 +1,13 @@ + + + + 2026-10-19T23:47:11Z + bitmatch-fixture-host + BitMatch + Initial destination inventory. MD5 computed after checking destination bytes against the transfer SHA-256. Includes listed files only; no inherited history or directory hashes. + + in-place + + Camera A/clip.txtd5c4370f48d86076c9c0ba937db6f1a3 + + diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/0002_bitmatch-mhl-chain-portable-result_2026-10-20_000226Z.mhl b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/0002_bitmatch-mhl-chain-portable-result_2026-10-20_000226Z.mhl new file mode 100644 index 00000000..98b811f5 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/0002_bitmatch-mhl-chain-portable-result_2026-10-20_000226Z.mhl @@ -0,0 +1,44 @@ + + + + 2026-10-19T20:02:26-04:00 + bitmatch-fixture-host + ascmhl + + + in-place + + + 1b1de8d4e26d1853e8bff50b8e429d3c + + + f08e3a83aab94b13776ce775b678241c + + + + .DS_Store + ascmhl + ascmhl/ + ._* + + + + + Camera A/clip.txt + d5c4370f48d86076c9c0ba937db6f1a3 + + + Camera A + + 323880c79e9e12e1ab922e86ec30e0d6 + + + f36945abd5b2e95cf00fdedb97d65452 + + + + added.txt + 18ef47a11e477414c21679f4be4ea8f5 + + + diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/BITMATCH_HISTORY_STATUS.txt b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/BITMATCH_HISTORY_STATUS.txt new file mode 100644 index 00000000..75247154 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/BITMATCH_HISTORY_STATUS.txt @@ -0,0 +1,3 @@ +BitMatch ASC MHL publication v1 +INCOMPLETE until a valid COMPLETE record follows. Keep the source. +COMPLETE 0001_BitMatch_FAT_2026-10-19_234711Z.mhl 768 d92b5c9630ff501fe0ece8e225274f0b71906281c92c4d1965fd0cf2cb8ce47a 302 58b8f754fb600b56cb446a15b304a569afae3c33c9d83b6128bc6dde1a7b67c9 diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/ascmhl_chain.xml b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/ascmhl_chain.xml new file mode 100644 index 00000000..c76f6f62 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended/ascmhl/ascmhl_chain.xml @@ -0,0 +1,11 @@ + + + + 0001_BitMatch_FAT_2026-10-19_234711Z.mhl + c42SfazAxautKSkd36qAf4nCBDYLXQHoTFps31A9pJSVgBzFZHjSsNpcZya2rf8nYrfCHpFcarFRhq6nqWiGzMQtoz + + + 0002_bitmatch-mhl-chain-portable-result_2026-10-20_000226Z.mhl + c417WXirvRwVXCP8BTibgTSGiEWfkGzdrDv4FrjMXP1YABMZFgQd3ssKzsWRzvZkfEyVBmK2ykvyD46PKRuwAbj9Rm + + diff --git a/Scripts/ascmhl/create_fat_chain_fixture.py b/Scripts/ascmhl/create_fat_chain_fixture.py new file mode 100644 index 00000000..29f9ee5b --- /dev/null +++ b/Scripts/ascmhl/create_fat_chain_fixture.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Create a public, privacy-sanitized derivative of an ASCMHLFixture sample. + +Run in the pinned reference Python 3.11+ venv. Input must be a synthetic, +initial-only BitMatch FAT sample; output must not exist. Input is never edited. +The initial witness is rebuilt ONLY during fixture creation, before the official +ASC tool appends generation two. BitMatch's reader never reseals a history. +""" +import argparse +import hashlib +from xml.sax.saxutils import escape +import pathlib +import re +import subprocess +import sys +import unicodedata +from unittest.mock import patch + +PIN = '0fb61f1e4c7c1c3ff422449aa6f091ce0d3b7687' +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('reference', type=pathlib.Path) +parser.add_argument('initial', type=pathlib.Path) +parser.add_argument('output', type=pathlib.Path) +args = parser.parse_args() +reference, source, output = args.reference.resolve(), args.initial.resolve(), args.output.resolve() +assert subprocess.check_output(['git', '-C', str(reference), 'rev-parse', 'HEAD'], text=True).strip() == PIN +assert not subprocess.check_output(['git', '-C', str(reference), 'status', '--porcelain', '--untracked-files=no'], text=True).strip() +sys.path.insert(0, str(reference)) +from lxml import etree +from click.testing import CliRunner +from ascmhl.cli.update import Updater +with patch.object(Updater, '_get_latest_version', return_value=None): + from ascmhl.cli.ascmhl import mhltool_cli +from ascmhl.hasher import C4 + +opening = b'BitMatch ASC MHL publication v1\nINCOMPLETE until a valid COMPLETE record follows. Keep the source.\n' +status = (source / 'ascmhl/BITMATCH_HISTORY_STATUS.txt').read_bytes() +assert status.startswith(opening) +word, name, size, digest, chain_size, chain_digest = status[len(opening):].decode().strip().split(' ') +assert word == 'COMPLETE' and re.fullmatch(r'0001_BitMatch_FAT_\d{4}-\d{2}-\d{2}_\d{6}Z\.mhl', name) +original = (source / 'ascmhl' / name).read_bytes() +original_chain = (source / 'ascmhl/ascmhl_chain.xml').read_bytes() +assert len(original) == int(size) and hashlib.sha256(original).hexdigest() == digest +assert len(original_chain) == int(chain_size) and hashlib.sha256(original_chain).hexdigest() == chain_digest +xml = etree.fromstring(original) +namespace = {'m': 'urn:ASC:MHL:v2.0'} +paths = xml.findall('m:hashes/m:hash/m:path', namespace) +assert len(paths) == 1 +path = paths[0].text +assert unicodedata.normalize('NFC', path) == 'Camera A/clip & café.txt' +media = (source / path).read_bytes() +assert media == b'BitMatch interoperability test\n', 'Only original ASCMHLFixture media is allowed' +# Preserve writer formatting while replacing the one machine-specific field. +manifest, replacements = re.subn(rb'[^<]+', b'bitmatch-fixture-host', original) +assert replacements == 1 +# exFAT presents the source filename decomposed on macOS, while Git may store +# it composed. Use ASCII only in this synthetic derivative so a fresh checkout +# is portable; production recorded paths and existing Unicode corpus stay intact. +portable_path = 'Camera A/clip.txt' +recorded = escape(path).encode() +assert manifest.count(recorded) == 1 +manifest = manifest.replace(recorded, portable_path.encode()) +hasher = C4(); hasher.update(manifest) +chain = (f'\n' + f'\n' + f' {name}{hasher.string_digest()}\n' + f'\n').encode() +completion = opening + f'COMPLETE {name} {len(manifest)} {hashlib.sha256(manifest).hexdigest()} {len(chain)} {hashlib.sha256(chain).hexdigest()}\n'.encode() +output.mkdir(parents=True, exist_ok=False) +(output / portable_path).parent.mkdir(parents=True) +(output / portable_path).write_bytes(media) +(output / 'ascmhl').mkdir() +(output / 'ascmhl' / name).write_bytes(manifest) +(output / 'ascmhl/ascmhl_chain.xml').write_bytes(chain) +(output / 'ascmhl/BITMATCH_HISTORY_STATUS.txt').write_bytes(completion) +(output / 'added.txt').write_bytes(b'Added by the independent ASC tool after privacy sanitization\n') +with patch('platform.node', return_value='bitmatch-fixture-host'): + result = CliRunner().invoke(mhltool_cli, ['create', '-h', 'md5', '-i', '._*', str(output)]) +assert result.exit_code == 0, result.output +assert (output / 'ascmhl/BITMATCH_HISTORY_STATUS.txt').read_bytes() == completion +print('PASS privacy-sanitized derivative; official generation two preserves initial witness') diff --git a/Scripts/ascmhl/validate_fat_chain_fixture.py b/Scripts/ascmhl/validate_fat_chain_fixture.py new file mode 100644 index 00000000..384c4648 --- /dev/null +++ b/Scripts/ascmhl/validate_fat_chain_fixture.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Independently validate BitMatch FAT-v1 publication + official ASC extension. + +Requires the pinned ASC checkout installed in the active Python 3.11+ venv. +All negative controls run on disposable copies; original corpus is read-only. +""" +import argparse +import hashlib +import pathlib +import shutil +import subprocess +import sys +import tempfile +from unittest.mock import patch + +PIN = '0fb61f1e4c7c1c3ff422449aa6f091ce0d3b7687' +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('reference', type=pathlib.Path) +args = parser.parse_args() +reference = args.reference.resolve() +assert subprocess.check_output(['git', '-C', str(reference), 'rev-parse', 'HEAD'], text=True).strip() == PIN +assert not subprocess.check_output(['git', '-C', str(reference), 'status', '--porcelain', '--untracked-files=no'], text=True).strip() +sys.path.insert(0, str(reference)) +from lxml import etree +from click.testing import CliRunner +from ascmhl.cli.update import Updater +with patch.object(Updater, '_get_latest_version', return_value=None): + from ascmhl.cli.ascmhl import mhltool_cli + from ascmhl.cli.ascmhl_debug import mhldebugtool_cli +from ascmhl.hasher import C4 + +source = pathlib.Path(__file__).resolve().parents[2] / 'Packages/BitMatchEngine/Tests/BitMatchEngineTests/Fixtures/EvidenceCompatibility/reference/bitmatch-fat-extended' +def snapshot(root): + return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in root.rglob('*') if p.is_file()} +before = snapshot(source) +runner = CliRunner() +with tempfile.TemporaryDirectory(prefix='bitmatch-fat-chain-reference-') as temporary: + root = pathlib.Path(temporary) / 'media' + shutil.copytree(source, root) + history = root / 'ascmhl' + assert (root / 'Camera A/clip.txt').is_file() + assert all(str(p.relative_to(root)).isascii() for p in root.rglob('*')), 'Synthetic derivative filenames must remain Git-portable ASCII' + chain = etree.parse(str(history / 'ascmhl_chain.xml')) + etree.XMLSchema(etree.parse(str(reference / 'xsd/ASCMHLDirectory__combined.xsd'))).assertValid(chain) + schema = etree.XMLSchema(etree.parse(str(reference / 'xsd/ASCMHL.xsd'))) + entries = chain.findall('{urn:ASC:MHL:DIRECTORY:v2.0}hashlist') + assert len(entries) == 2 + for index, entry in enumerate(entries, 1): + assert int(entry.attrib['sequencenr']) == index + name = entry.findtext('{urn:ASC:MHL:DIRECTORY:v2.0}path') + schema.assertValid(etree.parse(str(history / name))) + hasher = C4(); hasher.update((history / name).read_bytes()) + assert hasher.string_digest() == entry.findtext('{urn:ASC:MHL:DIRECTORY:v2.0}c4') + print('PASS schemas, two ordered generations and reference C4') + status = (history / 'BITMATCH_HISTORY_STATUS.txt').read_bytes() + opening = b'BitMatch ASC MHL publication v1\nINCOMPLETE until a valid COMPLETE record follows. Keep the source.\n' + assert status.startswith(opening) + word, name, size, digest, chain_size, chain_digest = status[len(opening):].decode().strip().split(' ') + assert word == 'COMPLETE' + manifest = (history / name).read_bytes() + assert len(manifest) == int(size) and hashlib.sha256(manifest).hexdigest() == digest + c4 = C4(); c4.update(manifest) + original_chain = (f'\n' + f'\n' + f' {name}{c4.string_digest()}\n' + f'\n').encode() + assert len(original_chain) == int(chain_size) and hashlib.sha256(original_chain).hexdigest() == chain_digest + assert entries[0].findtext('{urn:ASC:MHL:DIRECTORY:v2.0}path') == name + print('PASS immutable original FAT-v1 completion witness') + for label, cli, arguments in [('media verification', mhldebugtool_cli, ['verify', '-v', str(root)]), + ('inventory diff', mhltool_cli, ['diff', str(root)])]: + result = runner.invoke(cli, arguments) + assert result.exit_code == 0, result.output + print('PASS official ' + label) + second = entries[1].findtext('{urn:ASC:MHL:DIRECTORY:v2.0}path') + second_bytes = (history / second).read_bytes() + (history / second).write_bytes(second_bytes + b'changed') + result = runner.invoke(mhltool_cli, ['diff', str(root)]) + assert result.exit_code != 0, 'Reference accepted a modified inventory' + print('PASS reference rejects modified appended inventory') +assert snapshot(source) == before +print('PASS original fixture untouched; pinned reference ' + PIN) diff --git a/Scripts/ascmhl/validate_reference.sh b/Scripts/ascmhl/validate_reference.sh index 22caa23c..b28ba57a 100755 --- a/Scripts/ascmhl/validate_reference.sh +++ b/Scripts/ascmhl/validate_reference.sh @@ -34,6 +34,7 @@ PY # This subset also runs on Linux without Swift. Includes unchanged originals, # unsupported formats, block-boundary vectors and disposable negative controls. "$cli/python" "$project/Scripts/ascmhl/validate_corpus.py" "$reference" +"$cli/python" "$project/Scripts/ascmhl/validate_fat_chain_fixture.py" "$reference" # Corruption must be detected by the independent reference verifier. printf 'corrupted' > "$workspace/media/Camera A/clip & café.txt" diff --git a/Shared/Core/Models/ComparePresentation.swift b/Shared/Core/Models/ComparePresentation.swift index a102333a..885e68f2 100644 --- a/Shared/Core/Models/ComparePresentation.swift +++ b/Shared/Core/Models/ComparePresentation.swift @@ -287,9 +287,17 @@ 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." - : "" + let ascDirectories = Set(result.records.filter { $0.kind == .ascMHL }.map { $0.url.deletingLastPathComponent() }) + let scope: String + if ascDirectories.isEmpty { + scope = "" + } else if ascDirectories.isSubset(of: Set(result.validatedHistoryDirectories)) { + scope = " File contents and MHL inventory chains checked; directory structure and history authenticity were not verified." + } else if result.validatedHistoryDirectories.isEmpty { + scope = " File contents checked; MHL history and directory structure were not verified." + } else { + scope = " File contents and supported MHL inventory chains checked; other history chains, directory structure and authenticity were not verified." + } if result.isIntact { let age = SavedChecksumCheck.agePhrase(since: result.newestRecordDate, now: now, calendar: calendar) let newFiles = result.newPaths.isEmpty diff --git a/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md b/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md index 212c2549..f9dd582d 100644 --- a/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md +++ b/docs/EVIDENCE_COMPATIBILITY_VALIDATION.md @@ -44,7 +44,7 @@ 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. +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, interpret rename history, or extend existing histories. The October 19 read-only chain slice below adds a narrower inventory-chain check. 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 @@ -61,3 +61,14 @@ The pinned ASC reference CLI generated a new, redistributable `md5-renames/` fix This fixture exposed a reader gap: BitMatch Check ignored `` and interpreted a valid recorded move as missing old files. A new Swift regression failed before the guard because discovery silently accepted the history; it passed after Check began reporting `unsupportedEvidence` for rename lineage. That is an honest boundary until structural/history interpretation exists. A classic-v1 record placed beside a valid reference history is also tested as unsupported rather than letting the valid neighbour produce a partial green. The classic file remains handcrafted and standalone discovery remains open. The updated corpus runner passed **82 independent checks** against the pinned reference commit `0fb61f1e4c7c1c3ff422449aa6f091ce0d3b7687`, including schema, chain C4, original-media, rename/directory-move and disposable corruption controls. The focused Swift `EvidenceCompatibilityTests` suite passed 18 tests after the guard. These checks do not validate BitMatch's ASC chain/directory hashes, interpret rename history, or establish physical-drive reliability. + + +## October 19 read-only inventory chains + +Check now validates supported ASC chain records read-only, including C4 for every referenced inventory, contiguous sequence/order, safe basename paths and exact inventory coverage. Legacy no-chain evidence remains content-only. Rename lineage and root/directory hashes remain unsupported; chain records are not authenticated custody evidence and cannot prove that an unanchored external tail was never removed. Discovery captures the validated files' identity and SHA-256, then rechecks them before and after media reads. A replacement with identical bytes still invalidates that discovery. Chain order, rather than another computer's creation clock, determines the current file evidence within one history. + +The FAT-v1 completion status remains an immutable witness. An external extension must preserve the initial inventory and the exact original v1 single-generation chain size/SHA-256, reconstructed using the writer's original format, before its current chain can be trusted. Equivalent XML is insufficient. Incomplete, missing and altered original witnesses remain unsafe; Check never writes or reseals a history. + +The real engine's mounted-exFAT sample was extended on that volume by the pinned official ASC CLI with its explicit `._*` metadata ignore pattern. The public `bitmatch-fat-extended` fixture is a sanitized derivative: the hostname was replaced, the synthetic media filename mapped to ASCII, and the initial witness rebuilt during fixture creation, then the official tool independently appended its second generation. It is not a byte-for-byte mounted sample. That fixture pattern does not broaden BitMatch's production filter. Independent schemas, reference C4, file verifier and diff passed; the original mounted-sample status and the sanitized fixture's initial status each stayed byte-identical through their respective official extensions. The permanent fixture and reusable reference script are read-only inputs; negatives use disposable copies. Physical power loss, drive/dock behavior and broad third-party-reader certification remain outside this synthetic evidence. + +A fresh Git materialization exposed Unicode composition differences between the mounted sample's recorded path and its Git-stored filename. Only the synthetic derivative now uses `Camera A/clip.txt`; its initial witness is constructed before the official append, and the independent harness is rerun from a fresh Git archive. Existing Unicode fixtures and production recorded paths stay unchanged. A final reader regression also substitutes the named history directory immediately before success on both original and extended FAT paths; completion is refused and both directories are preserved. diff --git a/docs/validation/ascmhl/README.md b/docs/validation/ascmhl/README.md index 4d6fb43f..7804ac9d 100644 --- a/docs/validation/ascmhl/README.md +++ b/docs/validation/ascmhl/README.md @@ -6,13 +6,13 @@ Each inventoried destination file must match the SHA-256 recorded by the success 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. -Existing, nested, and ancestor ASC histories are refused and preserved, including histories copied from a source. BitMatch does not yet append, merge, flatten, import, or validate inherited histories. It never writes to the source. Use the official ASC tooling for those workflows until BitMatch supports and tests them. MD5 here serves interoperability and accidental-corruption checking; SHA-256 remains the independent copy verification check. +Existing, nested, and ancestor ASC histories are refused and preserved, including histories copied from a source. BitMatch does not append, merge, flatten or import inherited histories. Check can validate supported inventory chains read-only, as described below. It never writes to the source. Use the official ASC tooling for those workflows until BitMatch supports and tests them. MD5 here serves interoperability and accidental-corruption checking; SHA-256 remains the independent copy verification check. 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 and no-follow file opens. Filesystems supporting exclusive directory rename publish a synchronized private stage with `RENAME_EXCL`; failures leave that private stage untouched. FAT-family volumes instead claim `ascmhl` exclusively and write through its pinned descriptor. They retain `BITMATCH_HISTORY_STATUS.txt`: BitMatch Check requires its completed record to match the exact initial manifest and chain bytes, and refuses interrupted or altered histories. The FAT manifest filename is distinct so a missing status cannot turn a partial new history into apparently valid legacy evidence. Missing, changed, duplicate, unsafe-path and symbolic-link entries are rejected before publication. Cancellation is checked during reads and 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. -The pinned official ASC CLI accepts a completed exFAT-generated history with the extra BitMatch status file, verifies its bytes, and can append a second generation. BitMatch Check currently binds that status to the **initial** chain bytes, so it intentionally refuses a chain extended by another tool until read-only chain-lineage validation is implemented. Use the official ASC validator for externally extended histories. FAT32, physical-drive power loss and other vendors' readers have not been validated here. +The pinned official ASC CLI accepts a completed exFAT-generated history with the extra BitMatch status file, verifies its bytes, and can append a second generation. BitMatch Check preserves that status as an immutable **initial** completion witness. It can accept a supported externally extended chain only when its initial manifest and the exact original v1 single-generation chain still match that witness, and every current generation passes the read-only checks below. The status is never rewritten or resealed. FAT32, physical-drive power loss and other vendors' readers have not been validated here. ## Independent validation @@ -38,4 +38,8 @@ Scripts/ascmhl/validate_reference.sh /path/to/ascmitc/mhl /path/to/venv/bin 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. +Check verifies recorded file contents. With an `ascmhl_chain.xml`, it also checks supported chain records: exact ASC namespace/shape, contiguous ordered sequence numbers, safe same-directory manifest basenames, complete inventory coverage and every C4 digest. Custom and Unicode basenames are supported; case-variant chain filenames on a case-sensitive filesystem are refused rather than treated as legacy evidence. Reads are cancellable and bounded to 4 MiB per chain, 64 MiB per inventory and 4,096 generations. File and directory identities plus byte fingerprints are rechecked before and after media hashing, so a stale discovery cannot authorize a changed chain. Generation order takes precedence over different computers' creation clocks. + +The chain check does **not** verify directory/root hashes, interpret `` rename lineage or child-history references, authenticate a history, or establish complete custody. In particular, a valid older external chain cannot prove that an unanchored tail generation was never removed before discovery. Those limits remain explicit in Check. Legacy inventories without chains retain the file-content-only verdict; malformed, missing referenced, truncated, reordered, duplicated, unsafe or unsupported chain evidence is refused. BitMatch reports retain precedence and acquire no ASC-chain claim. + +On October 19, 2026, the real engine generated an initial history on a disposable mounted exFAT image and the pinned ASC CLI appended generation two on that volume using `create -h md5 -i '._*'`. Its original FAT completion status remained byte-identical; schemas, C4, media verification and diff passed. `reference/bitmatch-fat-extended` is a **sanitized derivative**, not the byte-for-byte mounted sample: the machine hostname was replaced and the synthetic media filename mapped to ASCII, the synthetic initial v1 witness was rebuilt during fixture creation, and the official CLI independently appended generation two to that sanitized copy. The rebuilt witness stayed byte-identical through that extension. The ASCII-only derivative avoids macOS exFAT decomposition versus Git composition differences; existing independent Unicode fixtures and production path handling are unchanged. This fixture-only operation never occurs in Check. The explicit metadata pattern does not change BitMatch's narrower production AppleDouble policy. `Scripts/ascmhl/validate_fat_chain_fixture.py` repeats the independent checks in disposable copies. This does not add classic-MHL discovery or history writes/extensions. diff --git a/docs/validation/ascmhl/chain-validation-2026-10-19.txt b/docs/validation/ascmhl/chain-validation-2026-10-19.txt new file mode 100644 index 00000000..7326c473 --- /dev/null +++ b/docs/validation/ascmhl/chain-validation-2026-10-19.txt @@ -0,0 +1,59 @@ +Read-only ASC inventory-chain slice — 2026-10-19 + +Reference: ascmitc/mhl@0fb61f1e4c7c1c3ff422449aa6f091ce0d3b7687 +Python: isolated 3.12 environment; official reference tracked tree unchanged. + +Mounted-driver probe: +- Disposable mounted volume confirmed FilesystemType=exfat. +- Actual ASCMHLFixture executable generated initial FAT-v1 history. +- Official create -h md5 -i '._*' appended generation two on that volume. +- Official byte verifier and diff accepted the result. +- Initial completion status remained byte-identical through append. +- Probe detached and removed; this is synthetic-driver evidence, not physical + drive/dock reliability or sudden-power-loss evidence. + +Public fixture: +- Sanitized derivative of that initial engine output; not a byte-for-byte + mounted-volume capture. Synthetic hostname replaces machine hostname; synthetic clip path is ASCII + to avoid exFAT/Git Unicode-composition portability differences. +- Initial chain/status rebuilt during fixture creation only, before the + official CLI append. Check never writes or reseals histories. +- Reproducible create_fat_chain_fixture.py run accepted the known synthetic + media and preserved its newly constructed witness through official append. +- validate_fat_chain_fixture.py: PASS schemas; two ordered generation C4s; + exact v1 witness; official media verification; official diff; rejected + modified appended inventory; original public fixture unchanged. +- Existing validate_corpus.py: 82 independent checks passed. + +Local focused tests: +- Swift Testing: ASCMHLChainValidationTests, EvidenceCompatibilityTests, + SavedChecksumCheckTests — 49 test functions passed, zero issues, including + their parameterized positive/adversarial cases. +- XCTest: ASCMHLGeneratorTests — 28 executed, zero failures. +- git diff --check: clean. +- Full engine, Mac app and iOS validation are separate integration gates. + +Discriminating negative controls in an isolated package copy: +- Remove only C4 check: valid-XML modified-inventory assertion fails. +- Remove only ordered sequence check: gap/reordered assertions fail. +- Remove only Discovery snapshot comparison: same-byte evidence replacement + assertions fail. +Each was an observed assertion failure, not a compiler failure or timeout. +The working implementation was never mutated by these controls. + +Scope: +C4/sequence/path/coverage checks bind current inventory bytes. They do not +verify directory/root hashes, interpret rename or child-history references, +authenticate custody, or prove that an unanchored historical tail was never +removed. Unsupported evidence is explicit; legacy no-chain evidence retains +its file-content-only scope. No source or history writes were added. + +Final follow-up: +- Original and extended FAT completion paths both recheck the final named + directory against the original open descriptor before returning success. +- Deterministic replacement-directory tests pass for both paths and preserve + the displaced evidence plus replacement writer's marker. +- Removing only that identity comparison in an isolated copy caused both + assertions to fail; the positive implementation was untouched. +- New fixture validated from a fresh Git archive to expose checkout-dependent + filename behavior; production Unicode handling was unchanged.