import { createHash } from "node:crypto"; import { closeSync, constants as fsConstants, fchmodSync, fsyncSync, mkdirSync, openSync, writeFileSync, } from "node:fs"; import { dirname, isAbsolute, join } from "node:path"; import { GitWorkspaceRepository } from "./git-repository.js"; export interface EvidenceMaterializationLimits { maxEntries: number; maxTotalBytes: number; maxFileBytes: number; maxPathBytes: number; maxManifestBytes: number; } export const DEFAULT_EVIDENCE_MATERIALIZATION_LIMITS: EvidenceMaterializationLimits = { maxEntries: 4096, maxTotalBytes: 64 * 1024 * 1024, maxFileBytes: 8 * 1024 * 1024, maxPathBytes: 4096, maxManifestBytes: 1024 * 1024, }; export interface EvidenceManifestFile { mode: "100644" | "100755"; oid: string; digest: string; bytes: number; } export interface EvidenceManifest { schemaVersion: 1; workspace: string; commit: string; tree: string; entryCount: number; totalBytes: number; files: Record; } export interface MaterializedEvidence { root: string; manifestPath: string; manifest: EvidenceManifest; /** 64-hex sha256 of the manifest bytes, for the snapshot manifest integrity chain. */ manifestDigest: string; } function sha256Hex(value: Buffer | string): string { return createHash("sha256").update(value).digest("hex"); } function sha256Prefixed(value: Buffer | string): string { return `sha256:${sha256Hex(value)}`; } function writeExclusiveNoFollow(path: string, contents: Buffer, mode: number): void { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const fd = openSync( path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600, ); let closed = false; try { writeFileSync(fd, contents); fsyncSync(fd); fchmodSync(fd, mode); closeSync(fd); closed = true; } catch (error) { if (!closed) try { closeSync(fd); } catch { /* preserve original failure */ } throw error; } } export interface MaterializeEvidenceTreeOptions { repository: GitWorkspaceRepository; revision: string; id: string; /** The workspace directory (e.g. `/`) that will receive `evidence/` and the manifest. */ targetDirectory: string; limits?: Partial; } /** * Materialize a canonical `/evidence` tree from an exact commit into an owned staging * directory with a bounded manifest. Never follows symlinks; a bound violation or unsafe object * aborts before any atomic publication. The caller is responsible for the final atomic rename. */ export async function materializeEvidenceTree(options: MaterializeEvidenceTreeOptions): Promise { const limits: EvidenceMaterializationLimits = { ...DEFAULT_EVIDENCE_MATERIALIZATION_LIMITS, ...options.limits }; if (!/^[0-9a-f]{40}$/.test(options.revision)) throw new Error("evidence revision is invalid"); if (!/^[a-z][a-z0-9-]{2,62}$/.test(options.id)) throw new Error("evidence workspace id is invalid"); if (!isAbsolute(options.targetDirectory)) throw new Error("evidence target directory must be absolute"); const objects = await options.repository.evidenceTreeObjects(options.revision, options.id); if (objects.length > limits.maxEntries) throw new Error("evidence entry count exceeds the bound"); const tree = await options.repository.evidenceTreeId(options.revision, options.id); // Disk-space preflight: sum the real object sizes before writing anything. const sizes = new Map(); let totalBytes = 0; for (const entry of objects) { if (Buffer.byteLength(entry.posixPath, "utf8") > limits.maxPathBytes) { throw new Error("evidence path exceeds the bound"); } const size = await options.repository.gitObjectSize(entry.oid); if (size > limits.maxFileBytes) throw new Error("evidence file exceeds the bound"); sizes.set(entry.oid, size); totalBytes += size; if (totalBytes > limits.maxTotalBytes) throw new Error("evidence total bytes exceed the bound"); } const root = join(options.targetDirectory, "evidence"); mkdirSync(root, { recursive: true, mode: 0o700 }); const files: Record = {}; for (const entry of objects) { const contents = await options.repository.evidenceBlobBytes(entry.oid, limits.maxFileBytes); if (contents.length !== sizes.get(entry.oid)) { throw new Error("evidence object changed while materializing"); } const target = join(root, ...entry.posixPath.split("/")); writeExclusiveNoFollow(target, contents, entry.mode === "100755" ? 0o755 : 0o644); files[entry.posixPath] = { mode: entry.mode, oid: entry.oid, digest: sha256Prefixed(contents), bytes: contents.length, }; } const manifest: EvidenceManifest = { schemaVersion: 1, workspace: options.id, commit: options.revision, tree, entryCount: objects.length, totalBytes, files, }; const manifestJson = `${JSON.stringify(manifest)}\n`; if (Buffer.byteLength(manifestJson, "utf8") > limits.maxManifestBytes) { throw new Error("evidence manifest exceeds the bound"); } const manifestPath = join(options.targetDirectory, "evidence.manifest.json"); writeExclusiveNoFollow(manifestPath, Buffer.from(manifestJson, "utf8"), 0o600); return { root, manifestPath, manifest, manifestDigest: sha256Hex(manifestJson) }; }