#!/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; } } export function reconcileScan({ expectedChecks, reports, matcherResults, candidate, }) { if ( !Array.isArray(expectedChecks) || !Array.isArray(reports) || !Array.isArray(matcherResults) || expectedChecks.length === 0 ) throw new Error("Reconciliation input is invalid."); const ordered = [...expectedChecks].sort((a, b) => Buffer.from(`${a.inputDigest}\0${a.checkId}`).compare( Buffer.from(`${b.inputDigest}\0${b.checkId}`), ), ); if ( !stringsEqual(expectedChecks, ordered) || new Set(expectedChecks.map((v) => `${v.inputDigest}\0${v.checkId}`)) .size !== expectedChecks.length ) throw new Error("Expected check closure must be unique and byte sorted."); if ( reports.length !== expectedChecks.length * 2 || matcherResults.length !== expectedChecks.length ) throw new Error("Missing or extra privacy evidence."); const checks = []; expectedChecks.forEach((expected, index) => { if (!isSha256Hex(expected.inputDigest) || !safeToken.test(expected.checkId)) throw new Error("Expected check is invalid."); const a = validateWorker(reports[index * 2], "worker-a"); const b = validateWorker(reports[index * 2 + 1], "worker-b"); const match = validateMatcher(matcherResults[index]); for (const item of [a, b, match]) if ( item.inputDigest !== expected.inputDigest || item.checkId !== expected.checkId ) throw new Error( "Privacy evidence is missing, reordered, or bound to another check.", ); if (a.imageDigest === b.imageDigest || a.toolchain === b.toolchain) throw new Error("Privacy workers are not heterogeneous."); if ( a.verdict !== "pass" || b.verdict !== "pass" || match.verdict !== "pass" ) throw new Error("Every worker and matcher must independently pass."); if ( a.normalizedOutputDigest !== b.normalizedOutputDigest || a.normalizedOutputDigest !== match.normalizedOutputDigest || !stringsEqual(a.normalizedMembers, b.normalizedMembers) || !stringsEqual(a.normalizedMembers, match.normalizedMembers) || !stringsEqual(match.workerImageDigests, [a.imageDigest, b.imageDigest]) ) throw new Error("Independent normalized evidence disagrees."); checks.push({ inputDigest: expected.inputDigest, checkId: expected.checkId, normalizedOutputDigest: a.normalizedOutputDigest, normalizedMembers: a.normalizedMembers, workerReports: [ canonicalJsonDigest("portfolio-privacy-worker-report/v1", a), canonicalJsonDigest("portfolio-privacy-worker-report/v1", b), ], matcherResult: canonicalJsonDigest( "portfolio-privacy-matcher-result/v1", match, ), ruleSetDigest: match.ruleSetDigest, appliedRuleIds: match.appliedRuleIds, }); }); const evidence = { schema: "portfolio-scan-evidence/v1", 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; }); }