This commit is contained in:
parent
8e71537d57
commit
fcc18ddcc8
96 changed files with 8074 additions and 214 deletions
199
pipeline/package.ts
Normal file
199
pipeline/package.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue