Files
rea/tests/boundary/process/spawnArgumentLimit.test.ts
T
c38f96fa69 test(cli): keep IPC trace input under the Linux spawn limit (#5673)
* test(cli): keep IPC trace input under the Linux spawn limit

The listener-callsite CLI builds one trace argument from the full
application Evidence document. On 4 KiB-page Linux that string exceeds
MAX_ARG_STRLEN, so spawn throws E2BIG before the command starts. Keep the
document in a file, require the spawned argv to fit the single-argument
budget, and lock that budget through the test process registry.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(process): size the spawn ceiling from ARG_MAX and MAX_ARG_STRLEN

A payload at the single-string limit still fails execve with E2BIG when
the stack shrinks ARG_MAX below the rest of the image. Accept only the
bytes that fit both budgets, and skip that success spawn when the fixed
argv and environment already fill ARG_MAX.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* style(test): format files rejected by oxfmt in static checks

check:ci:fast failed format:check on the CLI fixture import and, after
merging main, on the admission test's new command-abort case.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 04:48:33 +08:00

182 lines
5.6 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { processTest } from "../../support/process/processFixture.js";
import {
acceptedSpawnPayloadBytes,
assertSpawnArgumentsWithinLimit,
LINUX_4K_SINGLE_ARGUMENT_BYTES,
} from "../../support/process/spawnArgumentLimit.js";
const minimalSpawnEnvironment = (): NodeJS.ProcessEnv => ({
PATH: process.env.PATH ?? "",
...(process.platform === "win32"
? {
SystemRoot: process.env.SystemRoot,
TEMP: tmpdir(),
TMP: tmpdir(),
}
: {}),
});
const linuxGetconf = (name: "PAGE_SIZE" | "ARG_MAX"): number => {
const raw = execFileSync("getconf", [name], { encoding: "utf8" }).trim();
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < 1)
throw new Error(`unexpected ${name} ${raw}`);
return value;
};
/** Linux `execve` stores one native pointer per argv and environment string. */
const linuxPointerBytes = (): number =>
process.arch === "arm" ||
process.arch === "ia32" ||
process.arch === "mips" ||
process.arch === "mipsel"
? 4
: 8;
const execStringBytes = (value: string): number => Buffer.byteLength(value) + 1;
const captureSpawnFailure = async (
run: () => Promise<unknown>,
): Promise<unknown> => {
try {
await run();
} catch (error) {
return error;
}
return undefined;
};
processTest(
"spawns a file path when the payload exceeds the single-argument budget",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-spawn-argument-");
const payload = "x".repeat(LINUX_4K_SINGLE_ARGUMENT_BYTES + 1);
const file = join(root, "payload.txt");
await writeFile(file, payload);
expect(() => assertSpawnArgumentsWithinLimit([file])).not.toThrow();
expect(() => assertSpawnArgumentsWithinLimit([payload])).toThrow(/E2BIG/u);
const result = await processes.run(
process.execPath,
["-e", "process.exit(0)", file],
{ env: minimalSpawnEnvironment(), timeoutMs: 15_000 },
);
expect(result.exitCode).toBe(0);
},
);
it("fits the accepted payload to both the single-string and aggregate budgets", () => {
const fixedStringBytes = 500;
const pointerCount = 5;
const pointerBytes = 8;
expect(
acceptedSpawnPayloadBytes({
pageSize: 4096,
argMax: 2 * 1024 * 1024,
pointerBytes,
pointerCount,
fixedStringBytes,
}),
).toBe(LINUX_4K_SINGLE_ARGUMENT_BYTES);
const tight = acceptedSpawnPayloadBytes({
pageSize: 4096,
argMax: 32 * 4096,
pointerBytes,
pointerCount,
fixedStringBytes,
});
expect(tight).toBe(
32 * 4096 - pointerCount * pointerBytes - fixedStringBytes - 1,
);
expect(tight).toBeLessThan(LINUX_4K_SINGLE_ARGUMENT_BYTES);
expect(
acceptedSpawnPayloadBytes({
pageSize: 4096,
argMax: 32 * 4096,
pointerBytes,
pointerCount,
fixedStringBytes: 32 * 4096,
}),
).toBeUndefined();
});
it("counts UTF-8 bytes when applying the single-argument budget", () => {
const argument = "é".repeat(
Math.ceil((LINUX_4K_SINGLE_ARGUMENT_BYTES + 1) / 2),
);
expect(argument.length).toBeLessThanOrEqual(LINUX_4K_SINGLE_ARGUMENT_BYTES);
expect(Buffer.byteLength(argument)).toBeGreaterThan(
LINUX_4K_SINGLE_ARGUMENT_BYTES,
);
expect(() => assertSpawnArgumentsWithinLimit([argument])).toThrow(/E2BIG/u);
expect(() =>
assertSpawnArgumentsWithinLimit([
"x".repeat(LINUX_4K_SINGLE_ARGUMENT_BYTES),
]),
).not.toThrow();
});
processTest.skipIf(process.platform !== "linux")(
"matches the kernel single-argument ceiling through the test process registry",
async ({ processes }) => {
const script = "process.exit(0)";
const environment = minimalSpawnEnvironment();
const environmentEntries = Object.entries(environment).flatMap(
([key, value]) =>
typeof value === "string" ? [[key, value] as const] : [],
);
const pageSize = linuxGetconf("PAGE_SIZE");
const singleStringBytes = 32 * pageSize - 1;
expect(LINUX_4K_SINGLE_ARGUMENT_BYTES).toBeLessThanOrEqual(
singleStringBytes,
);
if (pageSize === 4096)
expect(LINUX_4K_SINGLE_ARGUMENT_BYTES).toBe(singleStringBytes);
// The filename is copied before argv, so the executable path is counted twice.
const fixedStringBytes =
execStringBytes(process.execPath) +
execStringBytes(process.execPath) +
execStringBytes("-e") +
execStringBytes(script) +
environmentEntries.reduce(
(sum, [key, value]) => sum + execStringBytes(`${key}=${value}`),
0,
);
const accepted = acceptedSpawnPayloadBytes({
pageSize,
argMax: linuxGetconf("ARG_MAX"),
pointerBytes: linuxPointerBytes(),
pointerCount: Math.max(4, 1) + environmentEntries.length,
fixedStringBytes,
});
const run = (payloadBytes: number) =>
processes.run(
process.execPath,
["-e", script, "x".repeat(payloadBytes)],
{ env: environment, timeoutMs: 15_000 },
);
if (accepted !== undefined) {
const result = await run(accepted);
expect(result.exitCode).toBe(0);
}
expect(
await captureSpawnFailure(() => run(singleStringBytes + 1)),
).toMatchObject({
code: "E2BIG",
});
if (accepted !== undefined && accepted < singleStringBytes) {
expect(await captureSpawnFailure(() => run(accepted + 1))).toMatchObject({
code: "E2BIG",
});
}
},
);