offload: MHL v2-shape manifest — generate/write/verify, tamper+missing+size detection, XML escaping — 7 tests
This commit is contained in:
parent
7894ecf033
commit
17fdaa150d
2 changed files with 218 additions and 0 deletions
135
Sources/ForgeOffload/MHL.swift
Normal file
135
Sources/ForgeOffload/MHL.swift
Normal file
|
|
@ -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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<hashlist version="2.0">
|
||||||
|
<creatorinfo>
|
||||||
|
<creationdate>\(dateFormatter.string(from: Date()))</creationdate>
|
||||||
|
<tool>\(escape(creator))</tool>
|
||||||
|
</creatorinfo>
|
||||||
|
<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>\(escape(rel))</path>
|
||||||
|
<size>\(size)</size>
|
||||||
|
<xxh64>\(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) 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 <hash> blocks.
|
||||||
|
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])
|
||||||
|
func field(_ tag: String) -> 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..<close.lowerBound])
|
||||||
|
}
|
||||||
|
guard let path = field("path"),
|
||||||
|
let sizeStr = field("size"), let size = UInt64(sizeStr),
|
||||||
|
let hash = field("xxh64") else {
|
||||||
|
throw OffloadError.readFailed("malformed <hash> entry")
|
||||||
|
}
|
||||||
|
entries.append(Entry(path: unescape(path), size: size, xxh64: hash))
|
||||||
|
searchRange = blockEnd.upperBound..<xml.endIndex
|
||||||
|
}
|
||||||
|
guard !entries.isEmpty else {
|
||||||
|
throw OffloadError.readFailed("no hash entries in manifest")
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Tests/ForgeOffloadTests/MHLTests.swift
Normal file
83
Tests/ForgeOffloadTests/MHLTests.swift
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
@testable import ForgeOffload
|
||||||
|
|
||||||
|
final class MHLTests: XCTestCase {
|
||||||
|
|
||||||
|
var root: String!
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
root = NSTemporaryDirectory() + "forge-mhl-\(UUID().uuidString)"
|
||||||
|
try FileManager.default.createDirectory(atPath: root + "/CLIPS", withIntermediateDirectories: true)
|
||||||
|
try Data("clip one contents".utf8).write(to: URL(fileURLWithPath: root + "/CLIPS/C001.mov"))
|
||||||
|
try Data("clip two contents, longer".utf8).write(to: URL(fileURLWithPath: root + "/CLIPS/C002.mov"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(atPath: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MHL XML structure: hashlist root, creatorinfo, hash entries with file/size/xxh64.
|
||||||
|
func testWriteStructure() throws {
|
||||||
|
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.contains("<creatorinfo>"))
|
||||||
|
XCTAssertTrue(xml.contains("<tool>Forge 0.1</tool>"))
|
||||||
|
XCTAssertTrue(xml.contains("<path>CLIPS/C001.mov</path>"))
|
||||||
|
XCTAssertTrue(xml.contains("<size>17</size>"))
|
||||||
|
XCTAssertTrue(xml.contains("<xxh64>"))
|
||||||
|
XCTAssertTrue(xml.hasSuffix("</hashlist>\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: "<not-mhl/>", 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("<path>a&b.mov</path>"))
|
||||||
|
let result = try MHL.verify(manifest: xml, root: root)
|
||||||
|
XCTAssertTrue(result.passed)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue