From 297477f34279b39582df94359c2cd5a004e58460 Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 20:16:52 +0000 Subject: [PATCH] =?UTF-8?q?offload:=20CSV/HTML=20reports=20+=20forge=20off?= =?UTF-8?q?load/verify=20CLI=20w/=20MHL=20manifests=20per=20destination;?= =?UTF-8?q?=20end-to-end=20verified=20incl=20tamper=20detection=20?= =?UTF-8?q?=E2=80=94=202=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ForgeOffload/OffloadReportWriter.swift | 50 ++++++++++++++ Sources/forge-cli/Main.swift | 65 ++++++++++++++++++- Tests/ForgeOffloadTests/ReportTests.swift | 40 ++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 Sources/ForgeOffload/OffloadReportWriter.swift create mode 100644 Tests/ForgeOffloadTests/ReportTests.swift diff --git a/Sources/ForgeOffload/OffloadReportWriter.swift b/Sources/ForgeOffload/OffloadReportWriter.swift new file mode 100644 index 0000000..cd02b60 --- /dev/null +++ b/Sources/ForgeOffload/OffloadReportWriter.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Offload report renderers: CSV (machine) + HTML (producer handoff). +public enum OffloadReportWriter { + + public static func csv(_ report: OffloadReport) -> String { + var out = "relative_path,size,xxh64,verified,skipped\n" + for f in report.files { + out += "\(f.relativePath),\(f.size),\(f.xxh64),\(f.verified),\(f.skipped)\n" + } + return out + } + + public static func formatBytes(_ bytes: UInt64) -> String { + let mb = Double(bytes) / 1_000_000 + if mb >= 1000 { + return String(format: "%.2f GB", mb / 1000) + } + return String(format: "%.1f MB", mb) + } + + public static func html(_ report: OffloadReport) -> String { + let allVerified = report.files.allSatisfy(\.verified) + var rows = "" + for f in report.files { + rows += """ + \(f.relativePath)\(formatBytes(f.size))\ + \(f.xxh64)\ + \(f.verified ? "OK" : "FAIL")\ + \(f.skipped ? "skipped" : "copied")\n + """ + } + return """ + + Forge Offload Report + + + +

Offload Report — \(allVerified ? "VERIFIED" : "FAILURES PRESENT")

+

Source: \(report.source)

+

Destinations: \(report.destinations.joined(separator: ", "))

+

\(report.files.count) files, \(formatBytes(report.totalBytes)), \(String(format: "%.1f", report.duration))s

+ + + \(rows)
FileSizexxHash64VerifyAction
+ + + """ + } +} diff --git a/Sources/forge-cli/Main.swift b/Sources/forge-cli/Main.swift index b05ac16..f1dc084 100644 --- a/Sources/forge-cli/Main.swift +++ b/Sources/forge-cli/Main.swift @@ -7,13 +7,74 @@ import ForgeColor import ForgeSim import ForgeLibrary import ForgeExport +import ForgeOffload @main struct Forge: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "forge", - abstract: "On-set live grading: push looks to cameras over IP.", - subcommands: [Push.self, Status.self, Sim.self, Export.self]) + abstract: "On-set live grading + DIT toolkit: camera look push, clip logging, offload, proxies.", + subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self]) +} + +struct Offload: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Verified copy of a card to one or more destinations, with MHL manifest.") + + @Argument(help: "Source directory (camera card)") + var source: String + + @Option(name: [.customLong("to")], help: "Destination directory (repeatable)") + var destinations: [String] + + @Flag(name: .long, help: "Re-copy destination files with mismatched size (interrupted copies)") + var overwritePartial = false + + @Option(name: .long, help: "Write HTML report to this path") + var report: String? + + func run() async throws { + guard !destinations.isEmpty else { + throw ValidationError("at least one --to destination required") + } + let engine = CopyEngine() + let result = try engine.offload(source: source, destinations: destinations, overwritePartial: overwritePartial) + + // MHL manifest at each destination root. + let files = result.files.map(\.relativePath) + for dest in destinations { + try MHL.write(root: dest, creator: "Forge", files: files, to: dest + "/" + ((source as NSString).lastPathComponent) + ".mhl") + } + if let reportPath = report { + try OffloadReportWriter.html(result).write(toFile: reportPath, atomically: true, encoding: .utf8) + } + let copied = result.files.filter { !$0.skipped }.count + print("offloaded \(result.files.count) files (\(copied) copied, \(result.files.count - copied) skipped), \(OffloadReportWriter.formatBytes(result.totalBytes)) to \(destinations.count) destination(s) — all verified") + } +} + +struct Verify: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Verify a tree against an MHL manifest.") + + @Argument(help: "Path to .mhl manifest") + var manifest: String + + @Option(name: .long, help: "Tree root (default: manifest's directory)") + var root: String? + + func run() async throws { + let manifestText = try String(contentsOfFile: manifest, encoding: .utf8) + let treeRoot = root ?? (manifest as NSString).deletingLastPathComponent + let result = try MHL.verify(manifest: manifestText, root: treeRoot) + if result.passed { + print("PASSED: \(result.checked) files verified") + } else { + print("FAILED: \(result.failures.count)/\(result.checked) files:") + for f in result.failures { + print(" \(f.path): \(f.reason)") + } + throw ExitCode(1) + } + } } struct Export: AsyncParsableCommand { diff --git a/Tests/ForgeOffloadTests/ReportTests.swift b/Tests/ForgeOffloadTests/ReportTests.swift new file mode 100644 index 0000000..6770f95 --- /dev/null +++ b/Tests/ForgeOffloadTests/ReportTests.swift @@ -0,0 +1,40 @@ +import XCTest +import Foundation +@testable import ForgeOffload + +final class ReportTests: XCTestCase { + + func makeReport() -> OffloadReport { + OffloadReport( + source: "/Volumes/CARD_A001", + destinations: ["/RAID/A001", "/SHUTTLE/A001"], + files: [ + FileRecord(relativePath: "CLIPS/C001.mov", size: 1_000_000, xxh64: "abc123def4567890", verified: true, skipped: false), + FileRecord(relativePath: "CLIPS/C002.mov", size: 2_500_000, xxh64: "1111222233334444", verified: true, skipped: true), + ], + totalBytes: 3_500_000, + duration: 12.5, + startedAt: Date(timeIntervalSince1970: 1_780_000_000)) + } + + // CSV report golden shape. + func testCSVReport() { + let csv = OffloadReportWriter.csv(makeReport()) + let lines = csv.split(separator: "\n").map(String.init) + XCTAssertEqual(lines[0], "relative_path,size,xxh64,verified,skipped") + XCTAssertEqual(lines[1], "CLIPS/C001.mov,1000000,abc123def4567890,true,false") + XCTAssertEqual(lines[2], "CLIPS/C002.mov,2500000,1111222233334444,true,true") + } + + // HTML report contains summary + rows. + func testHTMLReport() { + let html = OffloadReportWriter.html(makeReport()) + XCTAssertTrue(html.contains("")) + XCTAssertTrue(html.contains("/Volumes/CARD_A001")) + XCTAssertTrue(html.contains("/RAID/A001")) + XCTAssertTrue(html.contains("CLIPS/C001.mov")) + XCTAssertTrue(html.contains("2 files")) + XCTAssertTrue(html.contains("3.5 MB") || html.contains("3,5 MB")) + XCTAssertTrue(html.contains("VERIFIED")) + } +}