#!/usr/bin/env node import { mkdir, open, readFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { canonicalJsonDigest, canonicalValueBytes, isSha256Hex, parseCanonicalJson, } from "../../src/security/canonical-digest.ts"; const workerFields = [ "schema", "workerId", "imageDigest", "toolchain", "inputDigest", "checkId", "normalizedOutputDigest", "normalizedMembers", "verdict", "findingsDigest", ]; const matcherFields = [ "schema", "inputDigest", "checkId", "normalizedOutputDigest", "normalizedMembers", "ruleSetDigest", "appliedRuleIds", "verdict", "workerImageDigests", ]; const safeToken = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u; function object(value, label) { if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`); return value; } function exact(value, fields, label) { if ( JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...fields].sort()) ) throw new Error(`${label} contains missing or extra fields.`); } function stringsEqual(left, right) { return JSON.stringify(left) === JSON.stringify(right); } function validateMembers(value) { if ( !Array.isArray(value) || value.some( (member) => typeof member !== "string" || member !== member.normalize("NFC") || !/^[a-z][a-z0-9-]*:[a-f0-9]{64}$/u.test(member), ) ) throw new Error("Normalized members must contain typed digests only."); const sorted = [...value].sort((a, b) => Buffer.from(a).compare(Buffer.from(b)), ); if (!stringsEqual(value, sorted) || new Set(value).size !== value.length) throw new Error("Normalized members must be unique and byte sorted."); } function validateWorker(value, expectedWorker) { const report = object(value, "worker report"); exact(report, workerFields, "worker report"); if ( report.schema !== "portfolio-privacy-worker-report/v1" || report.workerId !== expectedWorker || !isSha256Hex(report.imageDigest) || !safeToken.test(report.toolchain) || !isSha256Hex(report.inputDigest) || !safeToken.test(report.checkId) || !isSha256Hex(report.normalizedOutputDigest) || !isSha256Hex(report.findingsDigest) || !["pass", "fail", "unsupported"].includes(report.verdict) ) throw new Error("Worker report is invalid."); validateMembers(report.normalizedMembers); return report; } function validateMatcher(value) { const result = object(value, "matcher result"); exact(result, matcherFields, "matcher result"); if ( result.schema !== "portfolio-privacy-matcher-result/v1" || !isSha256Hex(result.inputDigest) || !safeToken.test(result.checkId) || !isSha256Hex(result.normalizedOutputDigest) || !isSha256Hex(result.ruleSetDigest) || !["pass", "fail"].includes(result.verdict) || !Array.isArray(result.workerImageDigests) || result.workerImageDigests.length !== 2 || result.workerImageDigests.some((v) => !isSha256Hex(v)) ) throw new Error("Matcher result is invalid."); validateMembers(result.normalizedMembers); if ( !Array.isArray(result.appliedRuleIds) || result.appliedRuleIds.length === 0 || result.appliedRuleIds.some((value) => !safeToken.test(value)) || new Set(result.appliedRuleIds).size !== result.appliedRuleIds.length || !stringsEqual( result.appliedRuleIds, [...result.appliedRuleIds].sort((a, b) => Buffer.from(a).compare(Buffer.from(b)), ), ) ) throw new Error("Matcher rule IDs must be unique and byte sorted."); return result; } async function writeOnce(root, digest, bytes) { await mkdir(root, { recursive: true }); const path = join(root, digest); try { const handle = await open(path, "wx", 0o400); try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } } catch (error) { if (error && error.code === "EEXIST") { const existing = await readFile(path); if (Buffer.compare(existing, Buffer.from(bytes)) !== 0) throw new Error( "Content-addressed object is immutable and differs from existing bytes.", ); return; } throw error; } } function closureDigest(value, schema, label) { const closure = object(value, label); exact(closure, ["schema", "members"], label); if (closure.schema !== schema || !Array.isArray(closure.members)) throw new Error(`${label} is invalid.`); return canonicalJsonDigest(schema, closure); } export function reconcileScan(input) { const request = object(input, "reconciliation request"); exact( request, [ "sourceClosure", "publicObjectClosure", "reports", "matcherResults", "candidate", ], "reconciliation request", ); const { sourceClosure, publicObjectClosure, reports, matcherResults, candidate, } = request; if (!Array.isArray(reports) || !Array.isArray(matcherResults)) throw new Error("Reconciliation input is invalid."); const sourceClosureDigest = closureDigest( sourceClosure, "portfolio-publication-closure/v1", "source closure", ); const publicObjectClosureDigest = closureDigest( publicObjectClosure, "portfolio-public-object-closure/v1", "public object closure", ); if ( candidate?.sourceClosureDigest !== sourceClosureDigest || candidate?.publicObjectClosureDigest !== publicObjectClosureDigest ) { throw new Error( "Candidate does not bind the exact canonical source/public closure.", ); } if (publicObjectClosure.members.length !== 0) { throw new Error( "Verified privacy scanners are unavailable; every non-empty public object closure fails closed.", ); } if (reports.length !== 0 || matcherResults.length !== 0) throw new Error( "Empty public object closure cannot carry fabricated scan evidence.", ); const checks = []; const evidence = { schema: "portfolio-scan-evidence/v1", sourceClosureDigest, publicObjectClosureDigest, coverageCount: checks.length, passedCount: checks.length, checks, }; const evidenceBytes = canonicalValueBytes(evidence); const evidenceDigest = canonicalJsonDigest( "portfolio-scan-evidence/v1", evidence, ); const candidateRecord = object(candidate, "candidate"); if (candidateRecord.schema !== "portfolio-candidate-release-manifest/v1") throw new Error("Candidate schema is invalid."); const candidateBytes = canonicalValueBytes(candidateRecord); const candidateDigest = canonicalJsonDigest( "portfolio-candidate-release-manifest/v1", candidateRecord, ); return { evidence, evidenceBytes, evidenceDigest, candidate: candidateRecord, candidateBytes, candidateDigest, async writeContentAddressed(root) { await writeOnce(root, evidenceDigest, evidenceBytes); await writeOnce(root, candidateDigest, candidateBytes); }, }; } async function main() { if (process.argv.length !== 4 || process.argv[2] !== "--request") throw new Error( "Usage: reconcile-scan.mjs --request ", ); const request = parseCanonicalJson(await readFile(process.argv[3])); const result = reconcileScan(request); process.stdout.write( Buffer.from( canonicalValueBytes({ schema: "portfolio-reconciliation-result/v1", evidenceDigest: result.evidenceDigest, candidateDigest: result.candidateDigest, }), ), ); process.stdout.write("\n"); } if ( process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href ) { main().catch((error) => { process.stderr.write( `Privacy reconciliation failed: ${error instanceof Error ? error.message : "unknown error"}\n`, ); process.exitCode = 1; }); }