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:
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user