191 lines
11 KiB
JavaScript
191 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
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";
|
|
|
|
import { ThtRunner } from "../dist/tht/tht-runner.js";
|
|
|
|
const modulePath = fileURLToPath(import.meta.url);
|
|
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
|
|
const HEX40 = /^[0-9a-f]{40}$/;
|
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
|
|
function fixedRoot(repositoryRoot) { return join(realpathSync(repositoryRoot), ".artifacts", "manual-acceptance", "p11"); }
|
|
function below(parent, child) { const rel = relative(parent, child); return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel); }
|
|
function assertNoSymlinks(root, path, allowMissingLeaf = false) {
|
|
const rel = relative(root, path);
|
|
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("path is outside owned root");
|
|
let cursor = root;
|
|
const parts = rel.split(sep).filter(Boolean);
|
|
for (const [index, part] of parts.entries()) {
|
|
cursor = join(cursor, part);
|
|
try { if (lstatSync(cursor).isSymbolicLink()) throw new Error("owned path contains a symlink"); }
|
|
catch (error) {
|
|
if (allowMissingLeaf && error?.code === "ENOENT" && index === parts.length - 1) return;
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
async function ownership(repositoryRoot, ownershipPath) {
|
|
const root = fixedRoot(repositoryRoot);
|
|
const expected = join(root, "ownership.json");
|
|
if (resolve(ownershipPath) !== expected) throw new Error("ownership path is not owned");
|
|
const rootEntry = await lstat(root); const ownershipEntry = await lstat(expected);
|
|
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || !ownershipEntry.isFile() || ownershipEntry.isSymbolicLink()) throw new Error("ownership is unsafe");
|
|
if (await realpath(root) !== root) throw new Error("ownership root is not canonical");
|
|
let value; try { value = JSON.parse(await readFile(expected, "utf8")); } catch { throw new Error("ownership is malformed"); }
|
|
if (value?.schemaVersion !== 1 || value.kind !== "p11-manual-acceptance" || !HEX64.test(value.nonce ?? "") || value.root !== root || value.repositoryRoot !== realpathSync(repositoryRoot)) {
|
|
throw new Error("ownership identity mismatch");
|
|
}
|
|
return { root, value };
|
|
}
|
|
const ANCHORED_PUBLISH_SOURCE=String.raw`import os,secrets,stat,sys
|
|
parent,name,expected_dev,expected_ino=sys.argv[1:]
|
|
pfd=fd=None;stage=".render-stage-"+secrets.token_hex(16);published=False
|
|
def fail(): raise RuntimeError("anchored publication refused")
|
|
try:
|
|
pfd=os.open(parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
|
|
identity=os.fstat(pfd)
|
|
if (identity.st_dev,identity.st_ino)!=(int(expected_dev),int(expected_ino)): fail()
|
|
try: os.stat(name,dir_fd=pfd,follow_symlinks=False); fail()
|
|
except FileNotFoundError: pass
|
|
fd=os.open(stage,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=pfd)
|
|
data=sys.stdin.buffer.read(33554433)
|
|
if len(data)>33554432: fail()
|
|
view=memoryview(data)
|
|
while view:
|
|
written=os.write(fd,view)
|
|
if written<=0: fail()
|
|
view=view[written:]
|
|
os.fsync(fd);os.close(fd);fd=None;os.rename(stage,name,src_dir_fd=pfd,dst_dir_fd=pfd);published=True;os.fsync(pfd)
|
|
current=os.stat(parent,follow_symlinks=False)
|
|
if not stat.S_ISDIR(current.st_mode) or (current.st_dev,current.st_ino)!=(identity.st_dev,identity.st_ino): fail()
|
|
except Exception:
|
|
if published:
|
|
try: os.unlink(name,dir_fd=pfd);os.fsync(pfd)
|
|
except Exception: pass
|
|
print("anchored output publication refused (details redacted)",file=sys.stderr);raise SystemExit(1)
|
|
finally:
|
|
if fd is not None: os.close(fd)
|
|
if pfd is not None:
|
|
try: os.unlink(stage,dir_fd=pfd)
|
|
except FileNotFoundError: pass
|
|
os.close(pfd)
|
|
`;
|
|
async function atomicCopy(source, output) {
|
|
const parent = dirname(output);
|
|
const entry = await lstat(parent);
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("rendered parent identity is unsafe");
|
|
const bytes = await readFile(source);
|
|
const result = spawnSync("python3", ["-c", ANCHORED_PUBLISH_SOURCE, parent, basename(output), String(entry.dev), String(entry.ino)], { input: bytes, encoding: "utf8", maxBuffer: 1024 * 1024 });
|
|
if (result.error || result.status !== 0) throw new Error("anchored output publication refused; rendered parent identity changed or output is unsafe");
|
|
}
|
|
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(); const 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, 1024 * 1024, "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");
|
|
const renderedRoot = join(root, "rendered");
|
|
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 yamlName = `${match[2]}.yaml`;
|
|
await readSnapshotManifest(root, join(snapshotsRoot, match[1], "snapshot.json"), match[1], yamlName, snapshotSha256);
|
|
const snapshotBytes = await readBounded(snapshot, 1024 * 1024, "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; }
|
|
await mkdir(join(snapshotsRoot, "runtime"), { recursive: true, mode: 0o700 });
|
|
const bindingEnv = Object.fromEntries((await readFile(join(root, "installation", "bindings.env"), "utf8")).trim().split(/\n+/).filter(Boolean).map((line) => line.split(/=(.+)/)));
|
|
const effectiveEnv = { ...bindingEnv, ...env };
|
|
const prior = {};
|
|
for (const [key, value] of Object.entries(effectiveEnv)) { prior[key] = process.env[key]; if (value === undefined) delete process.env[key]; else process.env[key] = value; }
|
|
const runner = new ThtRunner({
|
|
thtBin: join(repo, "harness", ".venv", "bin", "tht"),
|
|
harnessDir: join(repo, "harness"),
|
|
configPath: join(root, "installation", "runtime", "base.yaml"),
|
|
dataRoot: join(root, "installation", "data"),
|
|
runtimeSnapshotRoot: join(snapshotsRoot, "runtime"),
|
|
secretRoots: [join(root, "fixture-secrets")],
|
|
semanticRuntime: { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024 },
|
|
});
|
|
let lease;
|
|
try {
|
|
lease = runner.acquireWorkspaceRuntime(snapshot);
|
|
const verifySnapshot = async () => {
|
|
const current = await readBounded(snapshot, 1024 * 1024, "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 });
|
|
await verifySnapshot();
|
|
await atomicCopy(lease.path, output);
|
|
} finally {
|
|
if (lease) lease.release();
|
|
for (const key of Object.keys(env)) { if (prior[key] === undefined) delete process.env[key]; else process.env[key] = prior[key]; }
|
|
}
|
|
return output;
|
|
}
|
|
function parseArgs(argv) {
|
|
if (argv.length !== 8) throw new Error("usage: p11-render-snapshot.mjs --ownership PATH --snapshot ABSOLUTE_PATH --output PATH --snapshot-sha256 HEX");
|
|
const result = {};
|
|
for (let index = 0; index < argv.length; index += 2) {
|
|
if (!["--ownership", "--snapshot", "--output", "--snapshot-sha256"].includes(argv[index]) || result[argv[index]]) throw new Error("invalid arguments");
|
|
result[argv[index]] = argv[index + 1];
|
|
}
|
|
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"], snapshotSha256: args["--snapshot-sha256"] });
|
|
console.log(`rendered ${resolve(args["--output"])}`);
|
|
} catch (error) {
|
|
console.error(`p11 render refused: ${error.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|