fix: verify dependency read continuity after ctime hydration

This commit is contained in:
Dotta committed 2026-10-11 04:32:31 -05:00
1 parent d5bce13cca
commit 683696352d
2 files changed
+161 -9

No files matched your search

@@ -37,6 +37,7 @@ import {
awaitVerifiedAcpxProviderOwnership,
createAcpxPackageJsonResolver,
guardSnapshotModuleLookup,
readStableSnapshotModuleSource,
guardSnapshotModuleResolution,
reapCurrentProviderProcessGroup,
sanitizedNodeEnvironment,
@@ -1709,6 +1710,110 @@ describe("ACPX installation integrity", () => {
await expectFailure(child, "requires Linux descriptor-pinned paths");
}
});
describe("descriptor-pinned dependency read continuity", () => {
it.each(["stable", "once", "recurring", "changed_bytes", "dev", "ino", "mode", "uid", "gid", "nlink", "size", "mtimeNs", "not_regular", "oversized", "snapshot_first", "snapshot_second"] as const)(
"requires bounded complete read continuity (%s)", (transition) => {
const original = Buffer.from("qualified fixture bytes");
const changed = Buffer.from("different fixture bytes");
expect(changed.length).toBe(original.length);
const initial = { dev: 1n, ino: 2n, mode: 0o100444n, uid: 1000n, gid: 1000n, nlink: 1n, size: BigInt(original.length),
mtimeNs: 10n, ctimeNs: 20n, isFile: () => true };
let pass = 0;
const verify = vi.fn((bytes: Buffer) => {
if (transition.startsWith("snapshot_")) {
const expected = createHash("sha256").update(original).digest("hex");
if (createHash("sha256").update(bytes).digest("hex") !== expected) throw new Error("snapshot digest mismatch");
}
});
const read = () => readStableSnapshotModuleSource({
before: { ...initial, ...(transition === "oversized" ? { size: 17n } : {}) },
maxBytes: transition === "oversized" ? 16 : 64,
stat: () => {
const metadata = { ...initial, ctimeNs: transition === "stable" ? 20n : 21n };
if (transition === "recurring" && pass === 2) metadata.ctimeNs = 22n;
if (["dev", "ino", "mode", "uid", "gid", "nlink", "size", "mtimeNs"].includes(transition)) {
const field = transition as "dev" | "ino" | "mode" | "uid" | "gid" | "nlink" | "size" | "mtimeNs";
metadata[field] += 1n;
}
if (transition === "not_regular") metadata.isFile = () => false;
return metadata;
},
read: (buffer, offset, length, position) => {
if (position === 0) pass += 1;
const source = (transition === "snapshot_first"
|| ((transition === "changed_bytes" || transition === "snapshot_second") && pass === 2)) ? changed : original;
return source.copy(buffer, offset, position, position + length);
},
verify,
});
if (transition === "stable" || transition === "once") {
expect(read()).toEqual(original);
expect(pass).toBe(transition === "stable" ? 1 : 2);
expect(verify).toHaveBeenCalledTimes(pass);
} else {
expect(read).toThrow();
expect(pass).toBeLessThanOrEqual(2);
if (transition === "snapshot_second") expect(verify).toHaveBeenCalledTimes(2);
}
},
);
});
it.runIf(process.platform !== "win32").each(["once", "recurring", "bytes", "mode", "rename", "replacement", "symlink"] as const)("loads dependency bytes only after stable ctime-only continuity (%s)", async (transition) => {
const fixture = await installationFixture();
await writeFile(join(fixture.commandDirectory, "dependency.mjs"), 'export const value = "dependency-success";');
const command = `import fs from 'node:fs';
const read = fs.readSync.bind(fs), stat = fs.fstatSync.bind(fs);
let moduleFd, passes = 0;
fs.readSync = (fd, buffer, offset, length, position) => {
const count = read(fd, buffer, offset, length, position);
if (buffer.subarray(offset, offset + count).includes(Buffer.from('dependency-success'))) {
moduleFd = fd; passes += 1;
if (${JSON.stringify(transition)} === 'bytes' && passes === 2) buffer[offset] = 0;
if (passes === 1 && ['rename','replacement','symlink'].includes(${JSON.stringify(transition)})) {
const named = new URL('./dependency.mjs', import.meta.url);
fs.renameSync(named, new URL('./moved.mjs', import.meta.url));
if (${JSON.stringify(transition)} === 'replacement') fs.writeFileSync(named, buffer.subarray(offset, offset + count));
if (${JSON.stringify(transition)} === 'symlink') fs.symlinkSync('./moved.mjs', named);
}
}
return count;
};
fs.fstatSync = (fd, options) => {
const metadata = stat(fd, options);
if (fd === moduleFd && options?.bigint) {
metadata.ctimeNs += ${JSON.stringify(transition)} === 'recurring' ? BigInt(passes) : 1n;
if (${JSON.stringify(transition)} === 'mode') metadata.mode ^= 0o100n;
}
return metadata;
};
const dependency = await import('./dependency.mjs');
process.stdout.write(dependency.value);`;
await writeFile(fixture.commandPath, command);
await writeFile(fixture.serverPackageJsonPath, JSON.stringify({ version: "0.0.33", bin: "bin/server.js", type: "module" }));
const installation = await verifyQualifiedAcpxInstallation({ ...fixture.profile,
commandDigest: `sha256:${createHash("sha256").update(command).digest("hex")}`,
}, fixture.resolve);
const lease = await installation.openCommand();
const child = lease.spawn();
const closed = once(child, "close");
let stdout = "";
child.stdout?.on("data", bytes => { stdout += String(bytes); });
// Drain private stderr while keeping it out of test/report output.
child.stderr?.resume();
const timeout = setTimeout(() => child.kill("SIGKILL"), 4_000);
try {
const [code] = await closed;
expect(code).toBe(transition === "once" ? 0 : 1);
expect(stdout).toBe(transition === "once" ? "dependency-success" : "");
} finally {
clearTimeout(timeout);
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
await closed;
await lease.close();
}
});
it.runIf(process.platform !== "win32").each(["OTHER", "UNVERIFIED_MODULE"] as const)(
"settles a guarded ESM import failure without guardian retirement (%s)",
async (category) => {
@@ -7,7 +7,7 @@ import {
type ChildProcess,
type SpawnOptionsWithoutStdio,
} from "node:child_process";
import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, realpathSync } from "node:fs";
import { type BigIntStats, closeSync, constants, existsSync, fstatSync, openSync, readFileSync, realpathSync } from "node:fs";
import {
lstat,
open,
@@ -1848,6 +1848,55 @@ export function sanitizedNodeEnvironment(
return sanitized;
}
/**
* Read continuity for descriptor-pinned dependency modules. Linux has no separate
* per-module expected digest here; equal reads do not create new provenance.
* Darwin's existing private-snapshot digest verifier still runs on every pass.
* This function is serialized into the child, so it must remain self-contained.
*/
export function readStableSnapshotModuleSource(input: {
before: Pick<BigIntStats, "dev" | "ino" | "mode" | "uid" | "gid" | "nlink" | "size" | "mtimeNs" | "ctimeNs" | "isFile">;
stat: () => Pick<BigIntStats, "dev" | "ino" | "mode" | "uid" | "gid" | "nlink" | "size" | "mtimeNs" | "ctimeNs" | "isFile">;
read: (buffer: Buffer, offset: number, length: number, position: number) => number;
verify: (bytes: Buffer) => void;
maxBytes: number;
}): Buffer {
const reject = (): never => {
throw Object.assign(new Error("ACPX provider module changed while it was read"), {
code: "ERR_ACPX_UNVERIFIED_MODULE",
});
};
let before = input.before;
let first: Buffer | null = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
if (!before.isFile() || before.size < 0n || before.size > BigInt(input.maxBytes)) reject();
const size = Number(before.size);
const buffer = Buffer.alloc(size + 1);
let offset = 0;
while (offset < buffer.length) {
const count = input.read(buffer, offset, buffer.length - offset, offset);
if (!Number.isSafeInteger(count) || count < 0 || count > buffer.length - offset) reject();
if (count === 0) break;
offset += count;
}
const bytes = buffer.subarray(0, offset);
const after = input.stat();
input.verify(bytes);
if (!after.isFile() || bytes.length !== size || BigInt(bytes.length) !== after.size
|| before.dev !== after.dev || before.ino !== after.ino || before.mode !== after.mode
|| before.uid !== after.uid || before.gid !== after.gid || before.nlink !== after.nlink
|| before.size !== after.size || before.mtimeNs !== after.mtimeNs) reject();
if (first !== null && !first.equals(bytes)) reject();
if (before.ctimeNs === after.ctimeNs) return bytes;
if (attempt !== 0) reject();
first = bytes;
// Continue from the full final metadata, including ctime. A further drift
// between passes is therefore rejected, not granted another transition.
before = after;
}
return reject();
}
function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string {
return [
'const fs = require("node:fs");',
@@ -1916,6 +1965,7 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string {
`const source = fs.readFileSync(${COMMAND_SOURCE_FD});`,
"let resolvingDescriptorBare = false;",
"const resolveBareFromDescriptor = (specifier, dependencyDirectoryUrl) => { resolvingDescriptorBare = true; try { return require.resolve(specifier, { paths: [fileURLToPath(dependencyDirectoryUrl)] }); } finally { resolvingDescriptorBare = false; } };",
`const readStableSnapshotModuleSource = ${readStableSnapshotModuleSource.toString()};`,
"registerHooks({ resolve(specifier, context, nextResolve) {",
"if (resolvingDescriptorBare) return nextResolve(specifier, context);",
"if (specifier === target) return { url: target, shortCircuit: true };",
@@ -1978,14 +2028,11 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string {
"const extension = extname(fileURLToPath(url));",
'const moduleFormat = extension === ".mjs" ? "module" : extension === ".cjs" ? "commonjs" : extension === ".json" ? "json" : extension === ".node" ? "addon" : extension === ".js" ? (hintedFormat === "module" || hintedFormat === "commonjs" ? hintedFormat : packageFormat) : hintedFormat;',
'if (moduleFormat !== "module" && moduleFormat !== "commonjs" && moduleFormat !== "json") { const error = new Error("ACPX provider module format is not supported by descriptor-pinned loading"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }',
"const admittedModuleBytes = Number(metadataBefore.size);",
"const moduleBuffer = Buffer.alloc(admittedModuleBytes + 1);",
"let moduleBytesRead = 0;",
"while (moduleBytesRead < moduleBuffer.length) { const bytesRead = fs.readSync(moduleFd, moduleBuffer, moduleBytesRead, moduleBuffer.length - moduleBytesRead, moduleBytesRead); if (bytesRead === 0) break; moduleBytesRead += bytesRead; }",
"const moduleSource = moduleBuffer.subarray(0, moduleBytesRead);",
"const metadataAfter = fs.fstatSync(moduleFd, { bigint: true });",
"verifySnapshotBytes(fileURLToPath(url), moduleSource);",
`if (moduleSource.length > ${MAX_AGENT_COMMAND_BYTES} || moduleSource.length !== admittedModuleBytes || BigInt(moduleSource.length) !== metadataAfter.size || metadataBefore.dev !== metadataAfter.dev || metadataBefore.ino !== metadataAfter.ino || metadataBefore.size !== metadataAfter.size || metadataBefore.mtimeNs !== metadataAfter.mtimeNs || metadataBefore.ctimeNs !== metadataAfter.ctimeNs) { const error = new Error("ACPX provider module changed while it was read"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }`,
// Re-check both the retained descriptor's canonical path and its named
// inode after each pass; a rename is not harmless hydration just because
// its already-open descriptor still returns identical bytes.
'const statModule = () => { const currentUrl = pathToFileURL(fs.realpathSync(privateSnapshot ? fileURLToPath(url) : "/proc/self/fd/" + moduleFd)).href; const named = fs.lstatSync(fileURLToPath(url), { bigint: true }); const metadata = fs.fstatSync(moduleFd, { bigint: true }); if (currentUrl !== openedUrl || !named.isFile() || named.dev !== metadata.dev || named.ino !== metadata.ino) { const error = new Error("ACPX provider module path changed while it was read"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; } return metadata; };',
`const moduleSource = readStableSnapshotModuleSource({ before: metadataBefore, stat: statModule, read: (buffer, offset, length, position) => fs.readSync(moduleFd, buffer, offset, length, position), verify: (bytes) => verifySnapshotBytes(fileURLToPath(url), bytes), maxBytes: ${MAX_AGENT_COMMAND_BYTES} });`,
"return { format: moduleFormat, source: moduleSource, shortCircuit: true };",
"} finally { fs.closeSync(moduleFd); }",
"});",