2026-09-13 22:07:18 -07:00
|
|
|
import fs from "node:fs/promises";
|
|
|
|
|
import path from "node:path";
|
2026-09-15 15:23:24 -07:00
|
|
|
import { execFile as callback, spawn } from "node:child_process";
|
2026-09-14 10:26:11 -07:00
|
|
|
import { createWriteStream } from "node:fs";
|
2026-09-15 15:23:24 -07:00
|
|
|
import { promisify } from "node:util";
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
2026-09-13 22:07:18 -07:00
|
|
|
const execFile = promisify(callback);
|
2026-09-15 15:23:24 -07:00
|
|
|
const environment = () => ({ ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0" });
|
|
|
|
|
export const checkerIdentity = () =>
|
|
|
|
|
process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
2026-09-13 22:07:18 -07:00
|
|
|
|
|
|
|
|
/** Checking snapshots jj, but never publishes or activates the working copy. */
|
|
|
|
|
export async function snapshotCommit(root: string): Promise<string> {
|
2026-09-15 15:23:24 -07:00
|
|
|
const run = async (...args: string[]) =>
|
|
|
|
|
(await execFile("jj", args, { cwd: root, env: environment() })).stdout.trim();
|
2026-10-01 01:37:46 -07:00
|
|
|
// A single normal log snapshots the working copy and observes both values
|
|
|
|
|
// from that exact revision. Separate status/log processes add no evidence.
|
|
|
|
|
const [conflict, commit] = (await run("log", "--no-graph", "-r", "@", "-T", 'conflict ++ "\\n" ++ commit_id')).split(
|
|
|
|
|
"\n",
|
|
|
|
|
);
|
|
|
|
|
if (conflict !== "false") throw new Error("Resolve source conflicts before verification");
|
|
|
|
|
if (!commit || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit))
|
|
|
|
|
throw new Error("Verification requires an exact jj commit");
|
2026-09-15 15:23:24 -07:00
|
|
|
await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {
|
|
|
|
|
cwd: root,
|
|
|
|
|
env: environment(),
|
|
|
|
|
});
|
|
|
|
|
const { stdout } = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root });
|
2026-09-13 22:07:18 -07:00
|
|
|
if (stdout) throw new Error("Source contains files not captured by jj; inspect jj tracking before verification");
|
|
|
|
|
return commit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Use Git's actual committed tree, never dirty overlays labelled as old pins. */
|
|
|
|
|
export async function checkoutCommit(root: string, commit: string, destination: string) {
|
2026-09-15 15:23:24 -07:00
|
|
|
await fs.mkdir(destination, { recursive: true });
|
|
|
|
|
const archive = await execFile("git", ["archive", "--format=tar", commit], {
|
|
|
|
|
cwd: root,
|
|
|
|
|
encoding: "buffer",
|
|
|
|
|
maxBuffer: 128 * 1024 * 1024,
|
|
|
|
|
});
|
2026-09-13 22:07:18 -07:00
|
|
|
await new Promise<void>((resolve, reject) => {
|
2026-09-15 15:23:24 -07:00
|
|
|
const child = spawn("tar", ["-xf", "-", "-C", destination], { stdio: ["pipe", "ignore", "pipe"] });
|
2026-09-13 22:07:18 -07:00
|
|
|
let error = "";
|
2026-09-15 15:23:24 -07:00
|
|
|
child.stderr.on("data", (chunk) => {
|
|
|
|
|
error += chunk;
|
|
|
|
|
});
|
2026-09-13 22:07:18 -07:00
|
|
|
child.on("error", reject);
|
2026-09-15 15:23:24 -07:00
|
|
|
child.on("close", (code) =>
|
|
|
|
|
code === 0 ? resolve() : reject(new Error(`Cannot extract committed source: ${error}`)),
|
|
|
|
|
);
|
2026-09-13 22:07:18 -07:00
|
|
|
child.stdin.on("error", reject);
|
|
|
|
|
child.stdin.end(archive.stdout);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Shared by provisional checks, template publication and host activation.
|
|
|
|
|
* The Nix derivation is the cached check; its metadata is internal provenance,
|
|
|
|
|
* not a certificate authored or approved by the workspace agent.
|
|
|
|
|
*/
|
|
|
|
|
export async function buildCheckedPackage(source: string, schema: string, packageRevisionId: string): Promise<string> {
|
2026-09-15 15:23:24 -07:00
|
|
|
const generator =
|
|
|
|
|
process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
|
|
|
if (!/^\/nix\/store\/[^/]+$/.test(generator))
|
|
|
|
|
throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)");
|
2026-09-13 22:07:18 -07:00
|
|
|
const builder = path.join(generator, "share/checked-package.nix");
|
|
|
|
|
await fs.access(builder);
|
|
|
|
|
return await new Promise((resolve, reject) => {
|
2026-09-15 15:23:24 -07:00
|
|
|
const child = spawn(
|
|
|
|
|
"nix",
|
|
|
|
|
[
|
|
|
|
|
"build",
|
|
|
|
|
"--impure",
|
|
|
|
|
"--file",
|
|
|
|
|
builder,
|
|
|
|
|
"--argstr",
|
|
|
|
|
"source",
|
|
|
|
|
source,
|
|
|
|
|
"--argstr",
|
|
|
|
|
"schema",
|
|
|
|
|
schema,
|
|
|
|
|
"--argstr",
|
|
|
|
|
"generator",
|
|
|
|
|
generator,
|
|
|
|
|
"--argstr",
|
|
|
|
|
"packageRevisionId",
|
|
|
|
|
packageRevisionId,
|
|
|
|
|
"--no-link",
|
|
|
|
|
"--print-out-paths",
|
|
|
|
|
"-L",
|
|
|
|
|
],
|
|
|
|
|
{ env: environment(), stdio: ["ignore", "pipe", "inherit"] },
|
|
|
|
|
);
|
2026-09-13 22:07:18 -07:00
|
|
|
let output = "";
|
2026-09-15 15:23:24 -07:00
|
|
|
child.stdout.on("data", (chunk) => {
|
|
|
|
|
output += chunk;
|
|
|
|
|
});
|
2026-09-13 22:07:18 -07:00
|
|
|
child.on("error", reject);
|
2026-09-15 15:23:24 -07:00
|
|
|
child.on("close", (code) => {
|
2026-09-13 22:07:18 -07:00
|
|
|
const artifact = output.trim();
|
2026-09-15 15:23:24 -07:00
|
|
|
if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact))
|
|
|
|
|
reject(new Error(`Checked Nix build failed (${code}); see build diagnostics above`));
|
2026-09-13 22:07:18 -07:00
|
|
|
else resolve(artifact);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-09-14 10:26:11 -07:00
|
|
|
|
|
|
|
|
/** Exact remote source DAG; the Nix checker owns schema construction and all
|
|
|
|
|
* nested fetches. Keep build noise in a named log, with a bounded failure tail. */
|
2026-09-15 15:23:24 -07:00
|
|
|
export async function buildImmutableCandidate(
|
|
|
|
|
source: { repository: string; commit: string },
|
2026-09-22 13:51:32 -07:00
|
|
|
kind: "workspace" | "interface" | "package" | "build",
|
2026-09-15 15:23:24 -07:00
|
|
|
logFile: string,
|
|
|
|
|
contractOnly = false,
|
2026-10-01 01:37:46 -07:00
|
|
|
observe?: (phase: "nixEvaluationMs" | "nixBuildMs", ms: number) => void,
|
2026-10-10 17:09:03 -07:00
|
|
|
workspaceId?: string,
|
2026-10-11 15:54:44 -07:00
|
|
|
platformSource?: { repository: string; commit: string; directory: string },
|
2026-09-15 15:23:24 -07:00
|
|
|
): Promise<string> {
|
2026-10-10 17:09:03 -07:00
|
|
|
if (workspaceId !== undefined && (kind !== "workspace" || !/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,255}$/.test(workspaceId)))
|
|
|
|
|
throw new Error("Instance binding requires a valid Workspace identity and a Workspace root");
|
2026-09-15 15:23:24 -07:00
|
|
|
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit))
|
|
|
|
|
throw new Error("An immutable candidate requires an exact commit");
|
2026-09-14 10:26:11 -07:00
|
|
|
const url = new URL(source.repository);
|
2026-09-15 15:23:24 -07:00
|
|
|
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash)
|
|
|
|
|
throw new Error("Candidate origin must be credential-free HTTPS");
|
2026-10-11 15:54:44 -07:00
|
|
|
if (
|
|
|
|
|
platformSource &&
|
|
|
|
|
(Object.keys(platformSource).sort().join(",") !== "commit,directory,repository" ||
|
|
|
|
|
!/^[a-f0-9]{40}$/.test(platformSource.commit) ||
|
|
|
|
|
!/^\/nix\/store\/[a-z0-9]{32}-[^/\s]+$/.test(platformSource.directory))
|
|
|
|
|
)
|
|
|
|
|
throw Error("Invalid immutable platform source input");
|
2026-09-15 15:23:24 -07:00
|
|
|
const generator =
|
|
|
|
|
process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
2026-09-14 10:26:11 -07:00
|
|
|
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker");
|
|
|
|
|
const builder = path.join(generator, "share/checked-candidate.nix");
|
|
|
|
|
await fs.access(builder);
|
|
|
|
|
const log = createWriteStream(logFile, { flags: "wx", mode: 0o600 });
|
2026-09-15 15:23:24 -07:00
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
log.once("open", () => resolve());
|
|
|
|
|
log.once("error", reject);
|
|
|
|
|
});
|
2026-10-06 20:56:37 -07:00
|
|
|
let logBytes = 0;
|
2026-10-01 01:37:46 -07:00
|
|
|
const run = async (executable: string, args: string[]) =>
|
|
|
|
|
await new Promise<string>((resolve, reject) => {
|
|
|
|
|
let output = "",
|
|
|
|
|
tail = "",
|
|
|
|
|
failure: Error | undefined;
|
|
|
|
|
const child = spawn(executable, args, { env: environment(), stdio: ["ignore", "pipe", "pipe"] });
|
|
|
|
|
const failedLog = (error: Error) => {
|
|
|
|
|
failure = error;
|
|
|
|
|
child.kill();
|
|
|
|
|
};
|
|
|
|
|
log.on("error", failedLog);
|
|
|
|
|
child.stdout.on("data", (chunk) => {
|
2026-10-06 20:56:37 -07:00
|
|
|
if (failure) return;
|
|
|
|
|
if (Buffer.byteLength(output) + chunk.length > 65536)
|
|
|
|
|
failedLog(new Error("Candidate checker output exceeds 64 KiB"));
|
|
|
|
|
else output += chunk;
|
2026-10-01 01:37:46 -07:00
|
|
|
});
|
|
|
|
|
child.stderr.on("data", (chunk) => {
|
2026-10-06 20:56:37 -07:00
|
|
|
if (failure) return;
|
|
|
|
|
logBytes += chunk.length;
|
|
|
|
|
if (logBytes > 16 * 1024 * 1024) {
|
|
|
|
|
failedLog(new Error("Candidate checker log exceeds 16 MiB"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-01 01:37:46 -07:00
|
|
|
log.write(chunk);
|
|
|
|
|
tail = (tail + String(chunk)).slice(-6000);
|
|
|
|
|
});
|
|
|
|
|
child.on("error", (error) => {
|
|
|
|
|
failure = error;
|
|
|
|
|
});
|
|
|
|
|
child.on("close", (code) => {
|
|
|
|
|
log.off("error", failedLog);
|
|
|
|
|
if (failure) reject(failure);
|
|
|
|
|
else if (code !== 0) reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`));
|
|
|
|
|
else resolve(output.trim());
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
const timed = async (phase: "nixEvaluationMs" | "nixBuildMs", action: () => Promise<string>) => {
|
|
|
|
|
const started = performance.now();
|
|
|
|
|
try {
|
|
|
|
|
return await action();
|
|
|
|
|
} finally {
|
|
|
|
|
observe?.(phase, Math.round(performance.now() - started));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let gcDirectory: string | undefined;
|
|
|
|
|
try {
|
|
|
|
|
// Evaluation includes import-from-derivation builds (locks and contracts).
|
|
|
|
|
// Build the resulting derivation directly, without evaluating the DAG twice.
|
|
|
|
|
// Keep the derivation alive across the two processes, including concurrent
|
|
|
|
|
// GC. This temporary root is removed on success and on verification failure.
|
|
|
|
|
gcDirectory = await fs.mkdtemp(path.join(path.dirname(logFile), ".gc-root-"));
|
|
|
|
|
const gcRoot = path.join(gcDirectory, "candidate");
|
|
|
|
|
const derivation = await timed("nixEvaluationMs", async () => {
|
|
|
|
|
await run("nix-instantiate", [
|
2026-09-15 15:23:24 -07:00
|
|
|
builder,
|
|
|
|
|
"--argstr",
|
|
|
|
|
"repository",
|
|
|
|
|
source.repository,
|
|
|
|
|
"--argstr",
|
|
|
|
|
"commit",
|
|
|
|
|
source.commit,
|
|
|
|
|
"--argstr",
|
|
|
|
|
"kind",
|
|
|
|
|
kind,
|
|
|
|
|
"--argstr",
|
|
|
|
|
"generator",
|
|
|
|
|
generator,
|
|
|
|
|
"--arg",
|
|
|
|
|
"contractOnly",
|
|
|
|
|
contractOnly ? "true" : "false",
|
2026-10-10 17:09:03 -07:00
|
|
|
...(workspaceId === undefined ? [] : ["--argstr", "workspaceId", workspaceId]),
|
2026-10-11 15:54:44 -07:00
|
|
|
...(platformSource ? ["--argstr", "platformSourceJSON", JSON.stringify(platformSource)] : []),
|
2026-10-01 01:37:46 -07:00
|
|
|
"--add-root",
|
|
|
|
|
gcRoot,
|
|
|
|
|
"--indirect",
|
|
|
|
|
]);
|
|
|
|
|
return await fs.readlink(gcRoot);
|
|
|
|
|
});
|
|
|
|
|
if (!/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+\.drv$/.test(derivation))
|
|
|
|
|
throw new Error("Nix returned an invalid candidate derivation");
|
|
|
|
|
const artifact = await timed("nixBuildMs", () =>
|
|
|
|
|
run("nix", ["build", derivation + "^*", "--no-link", "--print-out-paths", "-L"]),
|
2026-09-15 15:23:24 -07:00
|
|
|
);
|
2026-10-01 01:37:46 -07:00
|
|
|
if (!/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact))
|
|
|
|
|
throw new Error("Nix returned an invalid candidate artifact");
|
|
|
|
|
return artifact;
|
|
|
|
|
} finally {
|
|
|
|
|
await new Promise<void>((resolve) => log.end(resolve));
|
|
|
|
|
if (gcDirectory) await fs.rm(gcDirectory, { recursive: true, force: true });
|
|
|
|
|
}
|
2026-09-14 10:26:11 -07:00
|
|
|
}
|