import Foundation /// 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 { 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: "&") } 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 now = isoNow() var out = """ \(now) \(escape(hostname())) \(escape(creator)) \(process) """ 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)) \(hash) """ } out += " \n\n" return out } /// Write manifest to file. 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) } /// 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 } 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 { 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. 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..rel guard let pathOpen = block.range(of: "", range: pathOpen.upperBound..", range: pathTagEnd.upperBound.. entry: no path") } 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..