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: "\(tag)>"),
+ 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)
+ }
+}