199 lines
7.3 KiB
TypeScript
199 lines
7.3 KiB
TypeScript
/**
|
|
* Package builder — validate → build → hash → manifest → sign → latest → floor.
|
|
* Orchestrates gates 1-5; fails closed on any violation.
|
|
* Trace: IMPLEMENTATION-CONTRACT.md Stage 6, SPIKE-04 §3, ARCH 10.3
|
|
*/
|
|
import { canonicalJson } from "./canonical-json.js";
|
|
import { sha256HexOfString, sha256Hex } from "./hash.js";
|
|
import { BUDGETS, checkBudgets } from "./budgets.js";
|
|
import type { PipelineInput, BuiltPackage, SectionFile, AssetFile } from "./types.js";
|
|
import type { FestivalManifest } from "../src/data/festival-package/types.js";
|
|
import { buildManifest } from "./manifest.js";
|
|
import { deriveEmergencyFloor } from "./emergency.js";
|
|
import {
|
|
gate1Schema,
|
|
gate2StableIds,
|
|
gate3TimeSanity,
|
|
gate4Budgets,
|
|
gate5HashPresent,
|
|
validateManifestGates,
|
|
} from "./gates.js";
|
|
import { signManifest, type KeyPair } from "./sign.js";
|
|
|
|
export type BuildResult =
|
|
| { readonly ok: true; readonly pkg: BuiltPackage }
|
|
| { readonly ok: false; readonly reason: string };
|
|
|
|
function sectionToBytes(obj: unknown): { bytes: Uint8Array; str: string } {
|
|
const str = canonicalJson(obj);
|
|
return { bytes: new TextEncoder().encode(str), str };
|
|
}
|
|
|
|
export function buildPackage(
|
|
input: PipelineInput,
|
|
opts?: { signWith?: KeyPair | null },
|
|
): BuildResult {
|
|
// Basic monotonic check — informational; caller may check latest pointer
|
|
if (!Number.isInteger(input.packageVersion) || input.packageVersion < 1)
|
|
return { ok: false, reason: "packageVersion must be integer >=1" };
|
|
if (input.previousPackageVersion !== undefined && input.previousPackageVersion !== null) {
|
|
if (input.packageVersion <= input.previousPackageVersion)
|
|
return {
|
|
ok: false,
|
|
reason: `packageVersion ${input.packageVersion} must be > previous ${input.previousPackageVersion}`,
|
|
};
|
|
}
|
|
// Early gates that don't need hashes
|
|
const g1 = gate1Schema(input.content);
|
|
if (!g1.ok) return g1;
|
|
const g2 = gate2StableIds(input);
|
|
if (!g2.ok) return g2;
|
|
const g3 = gate3TimeSanity(input);
|
|
if (!g3.ok) return g3;
|
|
|
|
// Serialize sections deterministically and hash
|
|
const files = new Map<string, SectionFile>();
|
|
const assetMap = input.content.assets.blobs;
|
|
|
|
// Build section JSONs
|
|
const emergencyBytes = sectionToBytes(input.content.emergency);
|
|
const scheduleBytes = sectionToBytes(input.content.schedule);
|
|
const mapBytes = sectionToBytes(input.content.map);
|
|
const infoBytes = sectionToBytes(input.content.info);
|
|
// assets.json carries the hashes and byte lengths of the actual asset blobs.
|
|
const assetsInventory = {
|
|
assets: input.content.assets.assets.map((asset) => {
|
|
const blob = input.content.assets.blobs.get(asset.id);
|
|
if (!blob) throw new Error(`asset ${asset.id} missing blob`);
|
|
return { ...asset, bytes: blob.length, sha256: sha256Hex(blob) };
|
|
}),
|
|
};
|
|
const assetsJsonBytes = sectionToBytes(assetsInventory);
|
|
|
|
// Asset files — map asset id -> blob bytes
|
|
const assetFiles: AssetFile[] = [];
|
|
for (const a of input.content.assets.assets) {
|
|
const blob = assetMap.get(a.id);
|
|
if (!blob) return { ok: false, reason: `asset ${a.id} missing blob` };
|
|
if (blob.length > BUDGETS.MAX_FILE_BYTES)
|
|
return { ok: false, reason: `asset ${a.id} exceeds 6MB` };
|
|
const sha = sha256Hex(blob);
|
|
assetFiles.push({
|
|
id: a.id,
|
|
file: a.file,
|
|
bytes: blob.length,
|
|
sha256: sha,
|
|
kind: a.kind,
|
|
role: a.role,
|
|
bytesContent: blob,
|
|
});
|
|
}
|
|
|
|
// Create section files entries
|
|
const sections: Array<[string, Uint8Array]> = [
|
|
["emergency.json", emergencyBytes.bytes],
|
|
["schedule.json", scheduleBytes.bytes],
|
|
["map.json", mapBytes.bytes],
|
|
["info.json", infoBytes.bytes],
|
|
["assets.json", assetsJsonBytes.bytes],
|
|
];
|
|
for (const [file, bytes] of sections) {
|
|
if (bytes.length > BUDGETS.MAX_FILE_BYTES)
|
|
return { ok: false, reason: `section ${file} exceeds 6MB` };
|
|
const sha = sha256Hex(bytes);
|
|
files.set(file, {
|
|
file,
|
|
bytes: bytes.length,
|
|
sha256: sha,
|
|
json: JSON.parse(new TextDecoder().decode(bytes)),
|
|
canonicalBytes: bytes,
|
|
});
|
|
}
|
|
// Also include assets as files for budget check (assets themselves)
|
|
const budgetMap = new Map<string, { bytes: number; kind?: string; sha256: string }>();
|
|
for (const [k, v] of files) budgetMap.set(k, { bytes: v.bytes, sha256: v.sha256 });
|
|
for (const af of assetFiles)
|
|
budgetMap.set(af.file, { bytes: af.bytes, kind: af.kind, sha256: af.sha256 });
|
|
|
|
const budgetCheck = checkBudgets(budgetMap);
|
|
if (!budgetCheck.ok) return { ok: false, reason: budgetCheck.reason! };
|
|
const g4 = gate4Budgets(budgetMap, input.content.map);
|
|
if (!g4.ok) return g4;
|
|
const g5 = gate5HashPresent(budgetMap);
|
|
if (!g5.ok) return g5;
|
|
|
|
// Build manifest
|
|
const totalBytes = [...budgetMap.values()].reduce((sum, v) => sum + v.bytes, 0);
|
|
let manifest: FestivalManifest;
|
|
try {
|
|
manifest = buildManifest(input, files, totalBytes);
|
|
} catch (e) {
|
|
return { ok: false, reason: String((e as Error).message) };
|
|
}
|
|
const manifestGates = validateManifestGates(manifest);
|
|
if (!manifestGates.ok) return manifestGates;
|
|
|
|
// Derive floor from same source (must succeed and ≤16KB)
|
|
let floorDerived: ReturnType<typeof deriveEmergencyFloor>;
|
|
try {
|
|
const floorVersion = `${input.appCompatibility.minAppVersion}+${input.packageVersion}`;
|
|
floorDerived = deriveEmergencyFloor(input.content.emergency, {
|
|
floorVersion,
|
|
generatedAt: input.generatedAt,
|
|
});
|
|
} catch (e) {
|
|
return { ok: false, reason: `floor: ${String((e as Error).message)}` };
|
|
}
|
|
|
|
// Sign if requested (test seam) — otherwise signature is absent (pipeline still produces package)
|
|
let signature: BuiltPackage["signature"] = null;
|
|
let latest: BuiltPackage["latest"];
|
|
const manifestBytes = new TextEncoder().encode(canonicalJson(manifest));
|
|
const manifestSha = sha256Hex(manifestBytes);
|
|
if (opts?.signWith) {
|
|
const kp = opts.signWith;
|
|
try {
|
|
signature = signManifest(manifestBytes, kp.privateKeyPem, kp.fingerprint);
|
|
// sanity: manifestSha matches signature.manifestSha256
|
|
if (signature.manifestSha256 !== manifestSha)
|
|
return { ok: false, reason: "manifestSha mismatch after sign" };
|
|
} catch (e) {
|
|
return { ok: false, reason: `sign: ${String((e as Error).message)}` };
|
|
}
|
|
} else {
|
|
// unsigned — still include latest pointer but no signature
|
|
signature = null;
|
|
}
|
|
latest = {
|
|
edition: input.edition,
|
|
packageVersion: input.packageVersion,
|
|
manifestUrl: `/editions/${input.edition}/packages/${input.packageVersion}/manifest.json`,
|
|
generatedAt: input.generatedAt,
|
|
};
|
|
|
|
return {
|
|
ok: true,
|
|
pkg: {
|
|
manifest,
|
|
signature,
|
|
latest,
|
|
files,
|
|
assets: assetFiles,
|
|
emergencyFloor: floorDerived.floor,
|
|
floorBytes: floorDerived.bytes,
|
|
floorSha256: floorDerived.sha256,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Totally pure deterministic check — build twice with same input yields same manifest bytes/sha.
|
|
*/
|
|
export function isDeterministic(input: PipelineInput, kp?: KeyPair | null): boolean {
|
|
const a = buildPackage(input, kp ? { signWith: kp } : undefined);
|
|
const b = buildPackage(input, kp ? { signWith: kp } : undefined);
|
|
if (!a.ok || !b.ok) return false;
|
|
const aBytes = canonicalJson(a.pkg.manifest);
|
|
const bBytes = canonicalJson(b.pkg.manifest);
|
|
return aBytes === bBytes && sha256HexOfString(aBytes) === sha256HexOfString(bBytes);
|
|
}
|