fix: bind complete P1 manual dist graph and snapshot identity

prepare records an immutable manifest of every regular backend/dist file
(path/size/sha256/dev/ino) in the owned root and binds its record identity
in ownership; serve revalidates record and every file before spawn, passes
the manifest to the child on fd 4, and the immutable preload hash-verifies
all files at startup and serves only cached verified bytes for any import
below backend/dist, so imported dependency replacement is refused before
RUNNING or never executes. The render command validates the commit
snapshot.json manifest, binds snapshot bytes to the manifest digest and the
installed Git blob, and passes the expected digest to the renderer, which
revalidates head/files digest with bounded no-follow reads and renders only
verified bytes with lease release on refusal.
This commit is contained in:
2026-08-10 17:36:24 +02:00
parent 1c11d61f78
commit c7338969d7
6 changed files with 323 additions and 73 deletions
+48 -18
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { lstatSync, realpathSync } from "node:fs";
import { lstat, mkdir, readFile, realpath } from "node:fs/promises";
import { constants, lstatSync, realpathSync } from "node:fs";
import { lstat, mkdir, open, readFile, realpath } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
@@ -81,7 +81,41 @@ async function atomicCopy(source,output) {
if(result.error||result.status!==0)throw new Error("anchored output publication refused; rendered parent identity changed or output is unsafe");
}
export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRoot, ownershipPath, snapshotPath, outputPath, env = process.env, beforePublish }) {
function sameEntry(actual, expected) { return actual.dev === expected.dev && actual.ino === expected.ino; }
async function readBounded(path, max, label) {
let handle;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const before = await handle.stat(), pathEntry = await lstat(path);
if (!before.isFile() || pathEntry.isSymbolicLink() || !pathEntry.isFile() || !sameEntry(before, pathEntry)) throw new Error(`${label} is unsafe`);
if (before.size < 1 || before.size > max) throw new Error(`${label} is unbounded`);
const bytes = Buffer.alloc(before.size); let offset = 0;
while (offset < bytes.length) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
if (bytesRead < 1) throw new Error(`${label} changed while reading`);
offset += bytesRead;
}
const after = await handle.stat();
if (!sameEntry(before, after) || after.size !== before.size) throw new Error(`${label} changed while reading`);
return bytes;
} finally {
if (handle) await handle.close().catch(() => {});
}
}
async function readSnapshotManifest(root, manifestPath, commit, yamlName, expectedDigest) {
let manifestEntry;
try { assertNoSymlinks(root, manifestPath); manifestEntry = await lstat(manifestPath); }
catch (error) { if (error?.code === "ENOENT") throw new Error("snapshot manifest is missing or unbounded"); throw error; }
if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink() || await realpath(manifestPath) !== manifestPath) throw new Error("snapshot manifest is unsafe");
const bytes = await readBounded(manifestPath, 1048576, "snapshot manifest");
let manifest; try { manifest = JSON.parse(bytes.toString("utf8")); } catch { throw new Error("snapshot manifest is malformed"); }
const files = manifest?.files;
if (manifest?.head !== commit || !files || typeof files !== "object" || Array.isArray(files)) throw new Error("snapshot manifest identity is unsafe");
if (!HEX64.test(files[yamlName] ?? "") || files[yamlName] !== expectedDigest) throw new Error("snapshot manifest digest is unsafe");
return manifest;
}
export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRoot, ownershipPath, snapshotPath, outputPath, snapshotSha256, env = process.env, beforePublish }) {
const repo = realpathSync(repositoryRoot); const { root } = await ownership(repo, resolve(repo, ownershipPath));
const snapshot = resolve(repo, snapshotPath); const output = resolve(repo, outputPath);
const snapshotsRoot = join(root, "installation", "registry", "snapshots");
@@ -89,18 +123,14 @@ export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRo
if (!isAbsolute(snapshotPath) || !below(snapshotsRoot, snapshot)) throw new Error("snapshot is not an owned absolute path");
const match = /^([0-9a-f]{40})\/([a-z][a-z0-9-]{2,62})\.yaml$/.exec(relative(snapshotsRoot, snapshot).split(sep).join("/"));
if (!match || !HEX40.test(match[1])) throw new Error("snapshot is not commit addressed");
if (!HEX64.test(snapshotSha256 ?? "")) throw new Error("snapshot digest identity is unsafe");
assertNoSymlinks(root, snapshot); const snapshotEntry = await lstat(snapshot);
if (!snapshotEntry.isFile() || snapshotEntry.isSymbolicLink() || await realpath(snapshot) !== snapshot) throw new Error("snapshot is unsafe");
const snapshotBytes = await readFile(snapshot);
const snapshotDigest = createHash("sha256").update(snapshotBytes).digest("hex");
const manifestPath = join(dirname(snapshot), "snapshot.json");
assertNoSymlinks(root, manifestPath);
const manifestEntry = await lstat(manifestPath);
if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink() || await realpath(manifestPath) !== manifestPath) throw new Error("snapshot manifest is unsafe");
let manifest;
try { manifest = JSON.parse(await readFile(manifestPath, "utf8")); } catch { throw new Error("snapshot manifest is malformed"); }
const yamlName = `${match[2]}.yaml`;
if (manifest?.head !== match[1] || manifest?.files?.[yamlName] !== snapshotDigest) throw new Error("snapshot content does not match its immutable manifest");
const manifestPath = join(snapshotsRoot, match[1], "snapshot.json");
await readSnapshotManifest(root, manifestPath, match[1], yamlName, snapshotSha256);
const snapshotBytes = await readBounded(snapshot, 1048576, "snapshot");
if (createHash("sha256").update(snapshotBytes).digest("hex") !== snapshotSha256) throw new Error("snapshot bytes changed");
if (!below(renderedRoot, output) || dirname(output) !== renderedRoot || !output.endsWith(".yaml")) throw new Error("output is not an owned rendered path");
assertNoSymlinks(root, dirname(output));
try { if ((await lstat(output)).isSymbolicLink()) throw new Error("output is unsafe"); } catch (error) { if (error.code !== "ENOENT") throw error; }
@@ -117,8 +147,8 @@ export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRo
try {
lease = runner.acquireWorkspaceRuntime(snapshot);
const verifySnapshot = async () => {
const current = await readFile(snapshot);
if (createHash("sha256").update(current).digest("hex") !== snapshotDigest) throw new Error("snapshot content changed during rendering");
const current = await readBounded(snapshot, 1048576, "snapshot");
if (createHash("sha256").update(current).digest("hex") !== snapshotSha256) throw new Error("snapshot content changed during rendering");
};
await verifySnapshot();
if(beforePublish)await beforePublish({output,renderedRoot});
@@ -132,11 +162,11 @@ export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRo
return output;
}
function parseArgs(argv) {
if (argv.length !== 6) throw new Error("usage: p1-render-snapshot.mjs --ownership PATH --snapshot ABSOLUTE_PATH --output PATH");
const result = {}; for (let i=0;i<argv.length;i+=2) { if (!["--ownership","--snapshot","--output"].includes(argv[i]) || result[argv[i]]) throw new Error("invalid arguments"); result[argv[i]]=argv[i+1]; }
if (!result["--ownership"] || !result["--snapshot"] || !result["--output"]) throw new Error("missing arguments"); return result;
if (argv.length !== 8) throw new Error("usage: p1-render-snapshot.mjs --ownership PATH --snapshot ABSOLUTE_PATH --output PATH --snapshot-sha256 HEX");
const result = {}; for (let i=0;i<argv.length;i+=2) { if (!["--ownership","--snapshot","--output","--snapshot-sha256"].includes(argv[i]) || result[argv[i]]) throw new Error("invalid arguments"); result[argv[i]]=argv[i+1]; }
if (!result["--ownership"] || !result["--snapshot"] || !result["--output"] || !result["--snapshot-sha256"]) throw new Error("missing arguments"); return result;
}
if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) {
try { const args=parseArgs(process.argv.slice(2)); await renderOwnedSnapshot({ ownershipPath:args["--ownership"], snapshotPath:args["--snapshot"], outputPath:args["--output"] }); console.log(`rendered ${resolve(args["--output"])}`); }
try { const args=parseArgs(process.argv.slice(2)); await renderOwnedSnapshot({ ownershipPath:args["--ownership"], snapshotPath:args["--snapshot"], outputPath:args["--output"], snapshotSha256:args["--snapshot-sha256"] }); console.log(`rendered ${resolve(args["--output"])}`); }
catch(error) { console.error(`p1 render refused: ${error.message}`); process.exitCode=1; }
}