diff --git a/Sources/ForgeOffload/MHL.swift b/Sources/ForgeOffload/MHL.swift index 1c81220..021381d 100644 --- a/Sources/ForgeOffload/MHL.swift +++ b/Sources/ForgeOffload/MHL.swift @@ -1,8 +1,10 @@ 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. +/// ASC Media Hash List v2.0 (urn:ASC:MHL:v2.0) manifest. +/// Structure per official XSD (github.com/ascmitc/mhl, xsd/ASCMHL.xsd): +/// hashlist > creatorinfo (creationdate, hostname, tool) + processinfo (process) +/// + hashes > hash > path[@size] + . +/// Findings documented in docs/research/red-rcp2-protocol.md (MHL section). public enum MHL { public struct Failure: Equatable, Sendable { @@ -33,17 +35,35 @@ public enum MHL { .replacingOccurrences(of: "&", with: "&") } - /// Build manifest XML for files (relative paths) under root. - public static func generate(root: String, creator: String, files: [String]) throws -> String { + private static func isoNow() -> String { + ISO8601DateFormatter().string(from: Date()) + } + + private static func hostname() -> String { + ProcessInfo.processInfo.hostName + } + + /// Build ASC MHL v2.0 manifest XML for files (relative paths) under root. + /// process: "transfer" for offloads, "in-place" for verification-only seals. + public static func generate( + root: String, + creator: String, + files: [String], + process: String = "transfer" + ) throws -> String { let fm = FileManager.default - let dateFormatter = ISO8601DateFormatter() + let now = isoNow() var out = """ - + - \(dateFormatter.string(from: Date())) - \(escape(creator)) + \(now) + \(escape(hostname())) + \(escape(creator)) + + \(process) + """ @@ -56,9 +76,8 @@ public enum MHL { let hash = try FileHasher.xxh64(path: full) out += """ - \(escape(rel)) - \(size) - \(hash) + \(escape(rel)) + \(hash) """ @@ -68,8 +87,14 @@ public enum MHL { } /// 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) + public static func write( + root: String, + creator: String, + files: [String], + to path: String, + process: String = "transfer" + ) throws { + let xml = try generate(root: root, creator: creator, files: files, process: process) try xml.write(toFile: path, atomically: true, encoding: .utf8) } @@ -84,10 +109,12 @@ public enum MHL { 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 + if let expectedSize = e.size { + let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0 + if size != expectedSize { + failures.append(Failure(path: e.path, reason: .sizeMismatch)) + continue + } } let hash = try FileHasher.xxh64(path: full) if hash != e.xxh64 { @@ -99,11 +126,12 @@ public enum MHL { struct Entry { let path: String - let size: UInt64 + let size: UInt64? let xxh64: String } - /// Scanner-based parse of blocks. + /// Scanner-based parse of blocks. Accepts v2.0 attribute-style size + /// and (legacy, our v1) element-style . 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..rel + guard let pathOpen = block.range(of: "", range: pathOpen.upperBound..", range: pathTagEnd.upperBound.. entry: no path") } - guard let path = field("path"), - let sizeStr = field("size"), let size = UInt64(sizeStr), - let hash = field("xxh64") else { - throw OffloadError.readFailed("malformed entry") + let pathAttrs = String(block[pathOpen.upperBound.. element. + var size: UInt64? + if let m = pathAttrs.range(of: "size=\"") { + let rest = pathAttrs[m.upperBound...] + if let q = rest.firstIndex(of: "\"") { + size = UInt64(rest[.."), + let sizeClose = block.range(of: "") { + size = UInt64(block[sizeOpen.upperBound..", range: hOpen.upperBound..", range: hTagEnd.upperBound.. entry: no xxh64") + } + let hash = String(block[hTagEnd.upperBound.. creatorinfo (creationdate, hostname, tool) + processinfo (process) + // + hashes > hash > path[@size] + xxh64[@action][@hashdate]. func testWriteStructure() throws { let xml = try MHL.generate(root: root, creator: "Forge 0.1", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"]) - XCTAssertTrue(xml.hasPrefix("\n")) + XCTAssertTrue(xml.hasPrefix("\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.contains("")) + XCTAssertTrue(xml.contains("")) + XCTAssertTrue(xml.contains("Forge 0.1")) + // processinfo required by XSD; offload = transfer. + XCTAssertTrue(xml.contains("")) + XCTAssertTrue(xml.contains("transfer")) + // size is a path ATTRIBUTE per XSD, not an element. + XCTAssertTrue(xml.contains("CLIPS/C001.mov")) + XCTAssertTrue(xml.contains("")) XCTAssertTrue(xml.hasSuffix("\n")) } @@ -76,7 +85,7 @@ final class MHLTests: XCTestCase { 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")) + XCTAssertTrue(xml.contains(">a&b.mov")) let result = try MHL.verify(manifest: xml, root: root) XCTAssertTrue(result.passed) }