2026-07-10 16:14:28 -04:00
|
|
|
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.
|
2026-07-11 00:36:41 -04:00
|
|
|
// 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].
|
2026-07-10 16:14:28 -04:00
|
|
|
func testWriteStructure() throws {
|
|
|
|
|
let xml = try MHL.generate(root: root, creator: "Forge 0.1", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
|
2026-07-11 00:36:41 -04:00
|
|
|
XCTAssertTrue(xml.hasPrefix("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<hashlist version=\"2.0\" xmlns=\"urn:ASC:MHL:v2.0\">"))
|
2026-07-10 16:14:28 -04:00
|
|
|
XCTAssertTrue(xml.contains("<creatorinfo>"))
|
2026-07-11 00:36:41 -04:00
|
|
|
XCTAssertTrue(xml.contains("<creationdate>"))
|
|
|
|
|
XCTAssertTrue(xml.contains("<hostname>"))
|
|
|
|
|
XCTAssertTrue(xml.contains("<tool version=\"0.1\">Forge 0.1</tool>"))
|
|
|
|
|
// 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>"))
|
2026-07-10 16:14:28 -04:00
|
|
|
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"])
|
2026-07-11 00:36:41 -04:00
|
|
|
XCTAssertTrue(xml.contains(">a&b.mov</path>"))
|
2026-07-10 16:14:28 -04:00
|
|
|
let result = try MHL.verify(manifest: xml, root: root)
|
|
|
|
|
XCTAssertTrue(result.passed)
|
|
|
|
|
}
|
|
|
|
|
}
|