mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
fix: verify dependency read continuity after ctime hydration
This commit is contained in:
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); }",
|
||||
"});",
|
||||
|
||||
Reference in new issue
Block a user