rainbow-dragon/Sources/ForgeOffload/MHL.swift

182 lines
7.1 KiB
Swift

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] + <xxh64 action hashdate>.
/// 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
static func unescape(_ s: String) -> String {
s.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&amp;", 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 = """
<?xml version="1.0" encoding="UTF-8"?>
<hashlist version="2.0" xmlns="urn:ASC:MHL:v2.0">
<creatorinfo>
<creationdate>\(now)</creationdate>
<hostname>\(escape(hostname()))</hostname>
<tool version="0.1">\(escape(creator))</tool>
</creatorinfo>
<processinfo>
<process>\(process)</process>
</processinfo>
<hashes>
"""
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 += """
<hash>
<path size="\(size)">\(escape(rel))</path>
<xxh64 action="original" hashdate="\(now)">\(hash)</xxh64>
</hash>
"""
}
out += " </hashes>\n</hashlist>\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 <hash> blocks. Accepts v2.0 attribute-style size
/// and (legacy, our v1) element-style <size>.
static func parse(_ xml: String) throws -> [Entry] {
guard xml.contains("<hashlist") else {
throw OffloadError.readFailed("not an MHL manifest")
}
var entries = [Entry]()
var searchRange = xml.startIndex..<xml.endIndex
while let blockStart = xml.range(of: "<hash>", range: searchRange),
let blockEnd = xml.range(of: "</hash>", range: blockStart.upperBound..<xml.endIndex) {
let block = String(xml[blockStart.upperBound..<blockEnd.lowerBound])
// Path element with optional attributes: <path size="N" ...>rel</path>
guard let pathOpen = block.range(of: "<path"),
let pathTagEnd = block.range(of: ">", range: pathOpen.upperBound..<block.endIndex),
let pathClose = block.range(of: "</path>", range: pathTagEnd.upperBound..<block.endIndex) else {
throw OffloadError.readFailed("malformed <hash> entry: no path")
}
let pathAttrs = String(block[pathOpen.upperBound..<pathTagEnd.lowerBound])
let path = unescape(String(block[pathTagEnd.upperBound..<pathClose.lowerBound]))
// size attribute (v2.0) or legacy <size> 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[..<q])
}
} else if let sizeOpen = block.range(of: "<size>"),
let sizeClose = block.range(of: "</size>") {
size = UInt64(block[sizeOpen.upperBound..<sizeClose.lowerBound].trimmingCharacters(in: .whitespacesAndNewlines))
}
// xxh64 element with optional attributes.
guard let hOpen = block.range(of: "<xxh64"),
let hTagEnd = block.range(of: ">", range: hOpen.upperBound..<block.endIndex),
let hClose = block.range(of: "</xxh64>", range: hTagEnd.upperBound..<block.endIndex) else {
throw OffloadError.readFailed("malformed <hash> entry: no xxh64")
}
let hash = String(block[hTagEnd.upperBound..<hClose.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
entries.append(Entry(path: path, size: size, xxh64: hash))
searchRange = blockEnd.upperBound..<xml.endIndex
}
guard !entries.isEmpty else {
throw OffloadError.readFailed("no hash entries in manifest")
}
return entries
}
}