Files
msd-core/tests/no-unbounded-spawn.test.cjs
Tom Boucher 2afe17bbdb test(#3143): add the no-unbounded-spawn guard and throw-preserving git fixture (#3150)
* test(#3143): add no-unbounded-spawn guard and throw-preserving git fixture

Adds the ESLint rule local/no-unbounded-spawn, wired into the tests/**/*.cjs
block, plus an allowlist that only ratchets down: a listed file with zero
violations reports its own entry as stale.

The rule resolves renamed destructures and chained requires rather than
matching literal callee names -- both forms exist in the suite today and a
name-only matcher leaves them permanently invisible. It resolves an options
object held in a single-write const, which is what keeps process-seam.cjs,
the bounded reference implementation, from flagging itself.

timeout: 0 and anything above the 600000ms ceiling are rejected as only
nominally bounded.

Adds tests/helpers/git-fixture.cjs so a migrated execSync call site keeps
its throw-on-non-zero contract; process-seam.cjs is unchanged.

* test(#3143): prove the allowlist guards can actually fail

Extracts the D4/D6/D7/D8 checks into pure helpers and drives each against a
synthetic fixture carrying an injected violation. Without this the suite only
proved that today's clean data passes, which a deleted check would also
satisfy.

* fix(#3143): close two ceiling and alias escapes found in review

Nested arithmetic bypassed the ceiling entirely: the numeric evaluator only
resolved a flat literal, so `timeout: 60 * 60 * 1000` (3600000ms, six times
the ceiling) fell through to trusted and reported nothing. The evaluator now
recurses through arithmetic and unary signs with a depth cap.

Alias resolution was traversal-order dependent, not scope dependent: a call
textually above its own require destructure saw an empty alias map and
reported clean. The map is now built in a Program pre-pass.

Also: an explicit timeoutMs:undefined no longer overwrites the git fixture
default via spread, adds the missing seam-routed rule test, and de-duplicates
the repeated try/catch in the fixture tests.

---------

Co-authored-by: sim <sim@local>
2026-08-07 09:43:36 -04:00

458 lines
14 KiB
JavaScript

'use strict';
/**
* no-unbounded-spawn.test.cjs
*
* RuleTester unit tests for the `local/no-unbounded-spawn` ESLint rule
* (eslint-rules/no-unbounded-spawn.cjs). Covers matrix sections A, B, C, D of
* .gsd/phase/chore-3143-no-unbounded-spawn-guard/50-test-matrix.md.
*
* All spawn/exec call shapes below are TEST DATA (fixture code strings
* handed to RuleTester) — none of them are real invocations, and they never
* execute as real CallExpressions in THIS file's own AST (they live inside
* string literals), so this file is not itself flaggable by the rule under
* test.
*
* Discrepancy note (A12): 40-design.md / the test matrix state that a
* locally-declared `function execSync(){}` followed by a bare `execSync(x)`
* call is clean, because it isn't a `child_process` binding. The rule's
* actual implementation does not check origin for a bare Identifier call —
* `getFnName()` returns the raw callee name, and that name is looked up
* directly against `TARGET_FNS` regardless of whether it was ever resolved
* through the alias map. Verified empirically against the shipped rule
* (RuleTester run, see PR discussion): a local `execSync` is FLAGGED, not
* clean. This file asserts the actual (flagged) behavior rather than the
* matrix's stated expectation — see A12 below.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { RuleTester, Linter } = require('eslint');
const path = require('path');
const fs = require('fs');
const rule = require('../eslint-rules/no-unbounded-spawn.cjs');
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
},
});
const FILE = 'tests/foo.test.cjs';
// ─── A. detection ──────────────────────────────────────────────────────────
describe('no-unbounded-spawn: A — detection', () => {
test('A1/A2/A3/A4: bounded literal timeout is clean; missing/empty options and single-arg are unbounded', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{ code: `spawnSync(c, a, { timeout: 5000 });`, filename: FILE },
],
invalid: [
{
code: `spawnSync(c, a, {});`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
{
code: `spawnSync(c, a);`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
{
code: `spawnSync(c);`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('A5: execSync without timeout is unbounded', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `execSync('git status', { cwd });`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('A6: execFileSync without timeout is unbounded', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `execFileSync('git', ['status'], { cwd, encoding: 'utf8' });`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('A7: member call on a namespace object is matched', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `cp.spawnSync(c, a, {});`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('A8: member call on a require() CallExpression is matched (object-blind)', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `require('node:child_process').execFileSync('git',['--version'],{stdio:'ignore'});`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('A9: destructured-and-renamed binding is matched', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code:
`const { execSync: exec } = require('node:child_process');\n` +
`exec('git branch main',{cwd,stdio:'pipe'});`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('A10: renamed binding with a timeout is clean', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{
code:
`const { execSync: exec } = require('node:child_process');\n` +
`exec('x',{timeout:1000});`,
filename: FILE,
},
],
invalid: [],
});
});
test('A11: plain destructure, bounded, is clean', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{
code:
`const { spawnSync } = require('child_process');\n` +
`spawnSync('git', ['status'], { timeout: 5000 });`,
filename: FILE,
},
],
invalid: [],
});
});
test('A12: unrelated local named execSync IS matched (name-only matching) — diverges from matrix', () => {
// See file header: the matrix states "clean"; the shipped rule
// name-matches bare identifiers regardless of child_process origin, so
// this actually reports 1 error. Asserting the real behavior here.
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `function execSync(x) { return x; }\nexecSync('foo');`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('seam-routed calls are never flagged (runGit/runNode/runHook/gitOrThrow)', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{ code: `runGit(args, { timeoutMs: 5000 });`, filename: FILE },
{ code: `runNode([script], { timeoutMs: 5000 });`, filename: FILE },
{ code: `runHook(HOOK, [], { input: payload, timeoutMs: 5000 });`, filename: FILE },
{ code: `gitOrThrow(['status'], { cwd });`, filename: FILE },
],
invalid: [],
});
});
test('A13: property-name match is deliberately object-blind (documented false positive)', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `db.execSync(q);`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
});
// ─── B. the timeout value (Goodhart defenses) ──────────────────────────────
describe('no-unbounded-spawn: B — timeout value boundaries', () => {
test('B1/B2/B3/B4/B5: zero/negative/null/undefined/NaN timeout are all unbounded', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{ code: `spawnSync(c, a, { timeout: 0 });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
{ code: `spawnSync(c, a, { timeout: -1 });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
{ code: `spawnSync(c, a, { timeout: null });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
{ code: `spawnSync(c, a, { timeout: undefined });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
{ code: `spawnSync(c, a, { timeout: NaN });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
],
});
});
test('B6/B7/B8: 1ms, ceiling-1, and exactly the ceiling are all clean', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{ code: `spawnSync(c, a, { timeout: 1 });`, filename: FILE },
{ code: `spawnSync(c, a, { timeout: 599999 });`, filename: FILE },
{ code: `spawnSync(c, a, { timeout: 600000 });`, filename: FILE },
],
invalid: [],
});
});
test('B9/B10: over the ceiling and an absurd timeout are both unbounded', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `spawnSync(c, a, { timeout: 600001 });`,
filename: FILE,
errors: [{ messageId: 'timeoutTooLarge' }],
},
{
code: `spawnSync(c, a, { timeout: 999999999 });`,
filename: FILE,
errors: [{ messageId: 'timeoutTooLarge' }],
},
],
});
});
test('B11/B12/B13: non-literal timeout values are trusted as bounded', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{ code: `spawnSync(c, a, { timeout: someVar });`, filename: FILE },
{ code: `spawnSync(c, a, { timeout: opts.t });`, filename: FILE },
{ code: `spawnSync(c, a, { timeout: 5 * 1000 });`, filename: FILE },
],
invalid: [],
});
});
test('B14: computed key is not a resolvable timeout', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `spawnSync(c, a, { ['time'+'out']: 5000 });`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('B15: string-literal key is a timeout', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{ code: `spawnSync(c, a, { 'timeout': 5000 });`, filename: FILE },
],
invalid: [],
});
});
test('B16: duplicate timeout keys resolve to the last', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `spawnSync(c, a, { timeout: 5000, timeout: 0 });`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
});
// ─── C. options passed by reference (the seam's own shape) ────────────────
describe('no-unbounded-spawn: C — options by reference', () => {
test('C1: options held in a const are resolved (the real process-seam.cjs shape)', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{
code: `const o = { timeout: t }; spawnSync(c, a, o);`,
filename: FILE,
},
],
invalid: [],
});
});
test('C2: post-hoc property assignment is not resolved', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `const o = {}; o.timeout = 5; spawnSync(c,a,o);`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('C3: resolved options without a timeout are unbounded', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `const o = { cwd }; spawnSync(c, a, o);`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('C4: unresolvable options binding (function parameter) is unbounded', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `function f(o) { spawnSync(c, a, o); }`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('C5: reassigned binding is not trusted', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `let o = {timeout:1}; o = {}; spawnSync(c,a,o);`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('C6: spread-only options cannot prove a bound', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `spawnSync(c, a, { ...opts });`,
filename: FILE,
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
test('C7: spread plus a literal timeout is bounded', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{ code: `spawnSync(c, a, { ...opts, timeout: 5000 });`, filename: FILE },
],
invalid: [],
});
});
test('C8: the process seam does not flag itself', () => {
// Uses the real ESLint Linter (not RuleTester) against the actual file
// contents, since this is checking the real implementation, not a
// synthetic fixture.
const linter = new Linter({ configType: 'flat' });
const seamPath = path.join(__dirname, 'helpers', 'process-seam.cjs');
const code = fs.readFileSync(seamPath, 'utf8');
const messages = linter.verify(
code,
{
languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs' },
plugins: { local: { rules: { 'no-unbounded-spawn': rule } } },
rules: { 'local/no-unbounded-spawn': 'error' },
},
{ filename: 'tests/helpers/process-seam.cjs' }
);
assert.deepEqual(messages, []);
});
});
// ─── D. allowlist + ratchet (D1-D3 here; D4-D8 in no-unbounded-spawn-allowlist.test.cjs) ─
describe('no-unbounded-spawn: D — allowlist behavior', () => {
const ALLOWLISTED_ABS = path.join(process.cwd(), 'tests/allowlisted-fixture.test.cjs');
const ALLOWLISTED_REL = 'tests/allowlisted-fixture.test.cjs';
test('D1: allowlisted file suppresses its violations', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [
{
code: `spawnSync('git', ['status'], {});`,
filename: ALLOWLISTED_ABS,
options: [{ allowlist: [ALLOWLISTED_REL] }],
},
],
invalid: [],
});
});
test('D2: allowlisted file with zero violations reports exactly one stale-entry error', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `spawnSync('git', ['status'], { timeout: 5000 });`,
filename: ALLOWLISTED_ABS,
options: [{ allowlist: [ALLOWLISTED_REL] }],
errors: [{ messageId: 'staleAllowlistEntry' }],
},
],
});
});
test('D3: non-allowlisted file reports normally', () => {
ruleTester.run('local/no-unbounded-spawn', rule, {
valid: [],
invalid: [
{
code: `spawnSync('git', ['status'], {});`,
filename: FILE,
options: [{ allowlist: [ALLOWLISTED_REL] }],
errors: [{ messageId: 'unboundedSpawn' }],
},
],
});
});
});