offload: ASC MHL v2.0 conformance — urn:ASC:MHL:v2.0 namespace, creatorinfo+processinfo, path@size attr, xxh64@action@hashdate; validates against official ascmitc XSD via xmllint
This commit is contained in:
parent
05693dfece
commit
70e0290eda
2 changed files with 92 additions and 36 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Media Hash List (ASC MHL-style) manifest: per-file path/size/xxh64.
|
/// ASC Media Hash List v2.0 (urn:ASC:MHL:v2.0) manifest.
|
||||||
/// Structure follows MHL v2 hashlist shape; full ASC MHL chain/directory
|
/// Structure per official XSD (github.com/ascmitc/mhl, xsd/ASCMHL.xsd):
|
||||||
/// hashes can layer on later without breaking this format.
|
/// 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 enum MHL {
|
||||||
|
|
||||||
public struct Failure: Equatable, Sendable {
|
public struct Failure: Equatable, Sendable {
|
||||||
|
|
@ -33,17 +35,35 @@ public enum MHL {
|
||||||
.replacingOccurrences(of: "&", with: "&")
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build manifest XML for files (relative paths) under root.
|
private static func isoNow() -> String {
|
||||||
public static func generate(root: String, creator: String, files: [String]) throws -> 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 fm = FileManager.default
|
||||||
let dateFormatter = ISO8601DateFormatter()
|
let now = isoNow()
|
||||||
var out = """
|
var out = """
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<hashlist version="2.0">
|
<hashlist version="2.0" xmlns="urn:ASC:MHL:v2.0">
|
||||||
<creatorinfo>
|
<creatorinfo>
|
||||||
<creationdate>\(dateFormatter.string(from: Date()))</creationdate>
|
<creationdate>\(now)</creationdate>
|
||||||
<tool>\(escape(creator))</tool>
|
<hostname>\(escape(hostname()))</hostname>
|
||||||
|
<tool version="0.1">\(escape(creator))</tool>
|
||||||
</creatorinfo>
|
</creatorinfo>
|
||||||
|
<processinfo>
|
||||||
|
<process>\(process)</process>
|
||||||
|
</processinfo>
|
||||||
<hashes>
|
<hashes>
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
@ -56,9 +76,8 @@ public enum MHL {
|
||||||
let hash = try FileHasher.xxh64(path: full)
|
let hash = try FileHasher.xxh64(path: full)
|
||||||
out += """
|
out += """
|
||||||
<hash>
|
<hash>
|
||||||
<path>\(escape(rel))</path>
|
<path size="\(size)">\(escape(rel))</path>
|
||||||
<size>\(size)</size>
|
<xxh64 action="original" hashdate="\(now)">\(hash)</xxh64>
|
||||||
<xxh64>\(hash)</xxh64>
|
|
||||||
</hash>
|
</hash>
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
@ -68,8 +87,14 @@ public enum MHL {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write manifest to file.
|
/// Write manifest to file.
|
||||||
public static func write(root: String, creator: String, files: [String], to path: String) throws {
|
public static func write(
|
||||||
let xml = try generate(root: root, creator: creator, files: files)
|
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)
|
try xml.write(toFile: path, atomically: true, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,11 +109,13 @@ public enum MHL {
|
||||||
failures.append(Failure(path: e.path, reason: .missing))
|
failures.append(Failure(path: e.path, reason: .missing))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if let expectedSize = e.size {
|
||||||
let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0
|
let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0
|
||||||
if size != e.size {
|
if size != expectedSize {
|
||||||
failures.append(Failure(path: e.path, reason: .sizeMismatch))
|
failures.append(Failure(path: e.path, reason: .sizeMismatch))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let hash = try FileHasher.xxh64(path: full)
|
let hash = try FileHasher.xxh64(path: full)
|
||||||
if hash != e.xxh64 {
|
if hash != e.xxh64 {
|
||||||
failures.append(Failure(path: e.path, reason: .hashMismatch))
|
failures.append(Failure(path: e.path, reason: .hashMismatch))
|
||||||
|
|
@ -99,11 +126,12 @@ public enum MHL {
|
||||||
|
|
||||||
struct Entry {
|
struct Entry {
|
||||||
let path: String
|
let path: String
|
||||||
let size: UInt64
|
let size: UInt64?
|
||||||
let xxh64: String
|
let xxh64: String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scanner-based parse of <hash> blocks.
|
/// 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] {
|
static func parse(_ xml: String) throws -> [Entry] {
|
||||||
guard xml.contains("<hashlist") else {
|
guard xml.contains("<hashlist") else {
|
||||||
throw OffloadError.readFailed("not an MHL manifest")
|
throw OffloadError.readFailed("not an MHL manifest")
|
||||||
|
|
@ -113,18 +141,37 @@ public enum MHL {
|
||||||
while let blockStart = xml.range(of: "<hash>", range: searchRange),
|
while let blockStart = xml.range(of: "<hash>", range: searchRange),
|
||||||
let blockEnd = xml.range(of: "</hash>", range: blockStart.upperBound..<xml.endIndex) {
|
let blockEnd = xml.range(of: "</hash>", range: blockStart.upperBound..<xml.endIndex) {
|
||||||
let block = String(xml[blockStart.upperBound..<blockEnd.lowerBound])
|
let block = String(xml[blockStart.upperBound..<blockEnd.lowerBound])
|
||||||
func field(_ tag: String) -> String? {
|
|
||||||
guard let open = block.range(of: "<\(tag)>"),
|
// Path element with optional attributes: <path size="N" ...>rel</path>
|
||||||
let close = block.range(of: "</\(tag)>"),
|
guard let pathOpen = block.range(of: "<path"),
|
||||||
open.upperBound <= close.lowerBound else { return nil }
|
let pathTagEnd = block.range(of: ">", range: pathOpen.upperBound..<block.endIndex),
|
||||||
return String(block[open.upperBound..<close.lowerBound])
|
let pathClose = block.range(of: "</path>", range: pathTagEnd.upperBound..<block.endIndex) else {
|
||||||
|
throw OffloadError.readFailed("malformed <hash> entry: no path")
|
||||||
}
|
}
|
||||||
guard let path = field("path"),
|
let pathAttrs = String(block[pathOpen.upperBound..<pathTagEnd.lowerBound])
|
||||||
let sizeStr = field("size"), let size = UInt64(sizeStr),
|
let path = unescape(String(block[pathTagEnd.upperBound..<pathClose.lowerBound]))
|
||||||
let hash = field("xxh64") else {
|
|
||||||
throw OffloadError.readFailed("malformed <hash> entry")
|
// 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])
|
||||||
}
|
}
|
||||||
entries.append(Entry(path: unescape(path), size: size, xxh64: hash))
|
} 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
|
searchRange = blockEnd.upperBound..<xml.endIndex
|
||||||
}
|
}
|
||||||
guard !entries.isEmpty else {
|
guard !entries.isEmpty else {
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,23 @@ final class MHLTests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MHL XML structure: hashlist root, creatorinfo, hash entries with file/size/xxh64.
|
// MHL XML structure: hashlist root, creatorinfo, hash entries with file/size/xxh64.
|
||||||
|
// ASC MHL v2.0 XSD shape (urn:ASC:MHL:v2.0):
|
||||||
|
// hashlist > creatorinfo (creationdate, hostname, tool) + processinfo (process)
|
||||||
|
// + hashes > hash > path[@size] + xxh64[@action][@hashdate].
|
||||||
func testWriteStructure() throws {
|
func testWriteStructure() throws {
|
||||||
let xml = try MHL.generate(root: root, creator: "Forge 0.1", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
|
let xml = try MHL.generate(root: root, creator: "Forge 0.1", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
|
||||||
XCTAssertTrue(xml.hasPrefix("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<hashlist version=\"2.0\">"))
|
XCTAssertTrue(xml.hasPrefix("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<hashlist version=\"2.0\" xmlns=\"urn:ASC:MHL:v2.0\">"))
|
||||||
XCTAssertTrue(xml.contains("<creatorinfo>"))
|
XCTAssertTrue(xml.contains("<creatorinfo>"))
|
||||||
XCTAssertTrue(xml.contains("<tool>Forge 0.1</tool>"))
|
XCTAssertTrue(xml.contains("<creationdate>"))
|
||||||
XCTAssertTrue(xml.contains("<path>CLIPS/C001.mov</path>"))
|
XCTAssertTrue(xml.contains("<hostname>"))
|
||||||
XCTAssertTrue(xml.contains("<size>17</size>"))
|
XCTAssertTrue(xml.contains("<tool version=\"0.1\">Forge 0.1</tool>"))
|
||||||
XCTAssertTrue(xml.contains("<xxh64>"))
|
// processinfo required by XSD; offload = transfer.
|
||||||
|
XCTAssertTrue(xml.contains("<processinfo>"))
|
||||||
|
XCTAssertTrue(xml.contains("<process>transfer</process>"))
|
||||||
|
// size is a path ATTRIBUTE per XSD, not an element.
|
||||||
|
XCTAssertTrue(xml.contains("<path size=\"17\">CLIPS/C001.mov</path>"))
|
||||||
|
XCTAssertTrue(xml.contains("<xxh64 action=\"original\" hashdate="))
|
||||||
|
XCTAssertFalse(xml.contains("<size>"))
|
||||||
XCTAssertTrue(xml.hasSuffix("</hashlist>\n"))
|
XCTAssertTrue(xml.hasSuffix("</hashlist>\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +85,7 @@ final class MHLTests: XCTestCase {
|
||||||
func testPathEscaping() throws {
|
func testPathEscaping() throws {
|
||||||
try Data("x".utf8).write(to: URL(fileURLWithPath: root + "/a&b.mov"))
|
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"])
|
let xml = try MHL.generate(root: root, creator: "Forge", files: ["a&b.mov"])
|
||||||
XCTAssertTrue(xml.contains("<path>a&b.mov</path>"))
|
XCTAssertTrue(xml.contains(">a&b.mov</path>"))
|
||||||
let result = try MHL.verify(manifest: xml, root: root)
|
let result = try MHL.verify(manifest: xml, root: root)
|
||||||
XCTAssertTrue(result.passed)
|
XCTAssertTrue(result.passed)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue