From 17fdaa150df380d4f1af6cc9945f1681a3041f78 Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 20:14:28 +0000 Subject: [PATCH] =?UTF-8?q?offload:=20MHL=20v2-shape=20manifest=20?= =?UTF-8?q?=E2=80=94=20generate/write/verify,=20tamper+missing+size=20dete?= =?UTF-8?q?ction,=20XML=20escaping=20=E2=80=94=207=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeOffload/MHL.swift | 135 +++++++++++++++++++++++++ Tests/ForgeOffloadTests/MHLTests.swift | 83 +++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 Sources/ForgeOffload/MHL.swift create mode 100644 Tests/ForgeOffloadTests/MHLTests.swift diff --git a/Sources/ForgeOffload/MHL.swift b/Sources/ForgeOffload/MHL.swift new file mode 100644 index 0000000..1c81220 --- /dev/null +++ b/Sources/ForgeOffload/MHL.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Media Hash List (ASC MHL-style) manifest: per-file path/size/xxh64. +/// Structure follows MHL v2 hashlist shape; full ASC MHL chain/directory +/// hashes can layer on later without breaking this format. +public enum MHL { + + public struct Failure: Equatable, Sendable { + public enum Reason: Equatable, Sendable { + case missing + case hashMismatch + case sizeMismatch + } + public let path: String + public let reason: Reason + } + + public struct VerifyResult: Sendable { + public let checked: Int + public let failures: [Failure] + public var passed: Bool { failures.isEmpty } + } + + static func escape(_ s: String) -> String { + s.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } + + static func unescape(_ s: String) -> String { + s.replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "&", with: "&") + } + + /// Build manifest XML for files (relative paths) under root. + public static func generate(root: String, creator: String, files: [String]) throws -> String { + let fm = FileManager.default + let dateFormatter = ISO8601DateFormatter() + var out = """ + + + + \(dateFormatter.string(from: Date())) + \(escape(creator)) + + + + """ + for rel in files.sorted() { + let full = root + "/" + rel + guard fm.fileExists(atPath: full) else { + throw OffloadError.fileNotFound(full) + } + let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0 + let hash = try FileHasher.xxh64(path: full) + out += """ + + \(escape(rel)) + \(size) + \(hash) + + + """ + } + out += " \n\n" + return out + } + + /// Write manifest to file. + public static func write(root: String, creator: String, files: [String], to path: String) throws { + let xml = try generate(root: root, creator: creator, files: files) + try xml.write(toFile: path, atomically: true, encoding: .utf8) + } + + /// Verify manifest against tree at root. + public static func verify(manifest: String, root: String) throws -> VerifyResult { + let entries = try parse(manifest) + let fm = FileManager.default + var failures = [Failure]() + for e in entries { + let full = root + "/" + e.path + guard fm.fileExists(atPath: full) else { + failures.append(Failure(path: e.path, reason: .missing)) + continue + } + let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0 + if size != e.size { + failures.append(Failure(path: e.path, reason: .sizeMismatch)) + continue + } + let hash = try FileHasher.xxh64(path: full) + if hash != e.xxh64 { + failures.append(Failure(path: e.path, reason: .hashMismatch)) + } + } + return VerifyResult(checked: entries.count, failures: failures) + } + + struct Entry { + let path: String + let size: UInt64 + let xxh64: String + } + + /// Scanner-based parse of blocks. + static func parse(_ xml: String) throws -> [Entry] { + guard xml.contains("", range: searchRange), + let blockEnd = xml.range(of: "", range: blockStart.upperBound.. String? { + guard let open = block.range(of: "<\(tag)>"), + let close = block.range(of: ""), + open.upperBound <= close.lowerBound else { return nil } + return String(block[open.upperBound.. entry") + } + entries.append(Entry(path: unescape(path), size: size, xxh64: hash)) + searchRange = blockEnd.upperBound..\n")) + XCTAssertTrue(xml.contains("")) + XCTAssertTrue(xml.contains("Forge 0.1")) + XCTAssertTrue(xml.contains("CLIPS/C001.mov")) + XCTAssertTrue(xml.contains("17")) + XCTAssertTrue(xml.contains("")) + XCTAssertTrue(xml.hasSuffix("\n")) + } + + // Verify: clean tree passes. + func testVerifyClean() throws { + let xml = try MHL.generate(root: root, creator: "Forge", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"]) + let result = try MHL.verify(manifest: xml, root: root) + XCTAssertTrue(result.passed) + XCTAssertEqual(result.checked, 2) + XCTAssertTrue(result.failures.isEmpty) + } + + // Tamper detection: flipped byte fails that file. + func testVerifyTamperDetected() throws { + let xml = try MHL.generate(root: root, creator: "Forge", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"]) + try Data("clip one CONTENTS".utf8).write(to: URL(fileURLWithPath: root + "/CLIPS/C001.mov")) + let result = try MHL.verify(manifest: xml, root: root) + XCTAssertFalse(result.passed) + XCTAssertEqual(result.failures.count, 1) + XCTAssertEqual(result.failures[0].path, "CLIPS/C001.mov") + XCTAssertEqual(result.failures[0].reason, .hashMismatch) + } + + // Missing file fails verify. + func testVerifyMissingFile() throws { + let xml = try MHL.generate(root: root, creator: "Forge", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"]) + try FileManager.default.removeItem(atPath: root + "/CLIPS/C002.mov") + let result = try MHL.verify(manifest: xml, root: root) + XCTAssertFalse(result.passed) + XCTAssertEqual(result.failures[0].reason, .missing) + } + + // Round trip through file on disk. + func testManifestFileRoundTrip() throws { + let mhlPath = root + "/A001.mhl" + try MHL.write(root: root!, creator: "Forge", files: ["CLIPS/C001.mov"], to: mhlPath) + let loaded = try String(contentsOfFile: mhlPath, encoding: .utf8) + let result = try MHL.verify(manifest: loaded, root: root) + XCTAssertTrue(result.passed) + } + + // Malformed manifest rejected. + func testMalformedManifestRejected() { + XCTAssertThrowsError(try MHL.verify(manifest: "", root: root)) + } + + // XML escaping in paths. + func testPathEscaping() throws { + try Data("x".utf8).write(to: URL(fileURLWithPath: root + "/a&b.mov")) + let xml = try MHL.generate(root: root, creator: "Forge", files: ["a&b.mov"]) + XCTAssertTrue(xml.contains("a&b.mov")) + let result = try MHL.verify(manifest: xml, root: root) + XCTAssertTrue(result.passed) + } +}