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>
This commit is contained in:
Tom Boucher
2026-08-07 09:43:36 -04:00
committed by GitHub
parent 4b66bf4560
commit 2afe17bbdb
9 changed files with 1502 additions and 0 deletions

213
tests/git-fixture.test.cjs Normal file
View File

@@ -0,0 +1,213 @@
'use strict';
/**
* git-fixture.test.cjs
*
* Behavioral tests for tests/helpers/git-fixture.cjs's `gitOrThrow`, driving
* the real seam against real `git` in a temp fixture repo. Covers matrix
* section E of .gsd/phase/chore-3143-no-unbounded-spawn-guard/50-test-matrix.md.
*
* E10 needs to observe the exact `timeoutMs` value `gitOrThrow` forwards to
* the seam without any wall-clock measurement (both the documented default
* and the seam's own bare default would let a normal git command succeed,
* so a black-box timing test cannot distinguish them). This file installs a
* pass-through call-recording spy on `process-seam.cjs`'s `runGit` *before*
* `helpers/git-fixture.cjs` is required for the first time in this process,
* so `gitOrThrow`'s own `const { runGit } = require('./process-seam.cjs')`
* destructures the spy. With no custom implementation, `mock.method()`
* calls straight through to the real `runGit` — every test below still
* exercises real git — while additionally recording each call's arguments.
*/
const { describe, test, mock, after, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const processSeam = require('./helpers/process-seam.cjs');
const { OUTCOME } = processSeam;
const runGitSpy = mock.method(processSeam, 'runGit');
after(() => mock.restoreAll());
const { gitOrThrow, DEFAULT_GIT_TIMEOUT_MS } = require('./helpers/git-fixture.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
/**
* Runs `fn`, returning the error it throws. Fails the calling test with a
* clear assertion if `fn` does not throw. A standalone helper (not inline
* in a test body) is the CONTRIBUTING.md-compliant place for a try/catch of
* this shape — "try/finally is only permitted inside standalone utility or
* helper functions".
*/
function captureThrown(fn) {
let caught;
try {
fn();
} catch (e) {
caught = e;
}
assert.ok(caught, 'expected fn to throw');
return caught;
}
/** Initialize a fresh repo with a known branch name and one commit. */
function initRepo(prefix = 'git-fixture-test-') {
const dir = createTempDir(prefix);
gitOrThrow(['init', '--quiet', '-b', 'mainline'], { cwd: dir });
gitOrThrow(['config', 'user.email', 'git-fixture-test@example.com'], { cwd: dir });
gitOrThrow(['config', 'user.name', 'git-fixture-test'], { cwd: dir });
gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], { cwd: dir });
return dir;
}
describe('git-fixture: E — gitOrThrow', () => {
let dir;
beforeEach(() => {
dir = initRepo();
});
afterEach(() => {
cleanup(dir);
});
test('E1: returns stdout as a string on success', () => {
const r = gitOrThrow(['--version'], { cwd: dir });
assert.equal(typeof r, 'string');
});
test('E2: returns real command output', () => {
const r = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
assert.equal(r.trim(), 'mainline');
});
test('E3: throws on non-zero exit', () => {
assert.throws(() => gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir }));
});
test('E4: thrown error exposes .status (legacy execSync idiom)', () => {
const raw = processSeam.runGit(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir });
assert.notEqual(raw.exitCode, 0);
const caught = captureThrown(() =>
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
);
assert.equal(caught.status, raw.exitCode);
});
test('E5: thrown error exposes .exitCode (seam idiom), aliasing .status', () => {
const caught = captureThrown(() =>
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
);
assert.equal(caught.exitCode, caught.status);
});
test('E6: thrown error carries both streams as strings', () => {
const caught = captureThrown(() =>
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
);
assert.equal(typeof caught.stdout, 'string');
assert.equal(typeof caught.stderr, 'string');
assert.ok(caught.stderr.length > 0, 'expected git to write a fatal message to stderr');
});
test('E7: non-zero exit is EXITED, not a failure outcome', () => {
const caught = captureThrown(() =>
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
);
assert.equal(caught.outcome, OUTCOME.EXITED);
assert.equal(caught.timedOut, false);
});
test('E8: spawn failure throws with SPAWN_FAILED', () => {
const caught = captureThrown(() =>
gitOrThrow(['--version'], { cwd: path.join(dir, 'no-such-subdirectory') })
);
assert.equal(caught.outcome, OUTCOME.SPAWN_FAILED);
});
test('E9: timeout throws and reports timedOut', () => {
const caught = captureThrown(() => gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir, timeoutMs: 1 }));
assert.equal(caught.timedOut, true);
assert.equal(caught.outcome, OUTCOME.TIMED_OUT);
});
test('E10: omitted timeout uses the documented default, not silence', () => {
runGitSpy.mock.resetCalls();
gitOrThrow(['--version'], { cwd: dir });
assert.equal(runGitSpy.mock.calls.length, 1);
assert.equal(runGitSpy.mock.calls[0].arguments[1].timeoutMs, DEFAULT_GIT_TIMEOUT_MS);
assert.equal(DEFAULT_GIT_TIMEOUT_MS, 15000);
});
test('E11: explicit timeoutMs overrides the default', () => {
runGitSpy.mock.resetCalls();
gitOrThrow(['--version'], { cwd: dir, timeoutMs: 12345 });
assert.equal(runGitSpy.mock.calls.length, 1);
assert.equal(runGitSpy.mock.calls[0].arguments[1].timeoutMs, 12345);
assert.notEqual(12345, DEFAULT_GIT_TIMEOUT_MS);
});
test('E12: shell-string args are rejected', () => {
assert.throws(() => gitOrThrow('status', { cwd: dir }), TypeError);
});
test('E13: argv is never shell-interpreted', () => {
const marker = path.join(dir, 'PWNED_MARKER');
// A ref name containing shell metacharacters. spawnSync never invokes a
// shell, so this whole string reaches git as ONE literal argv element
// (the candidate ref name) — never tokenized or command-substituted.
const hostileRef = ';touch ' + marker + ';`id`;$(id)';
let threw = false;
try {
gitOrThrow(['rev-parse', '--verify', hostileRef], { cwd: dir });
} catch (_e) {
threw = true;
}
assert.ok(threw, 'expected the hostile string to fail resolution as a literal (bad) ref');
assert.equal(fs.existsSync(marker), false, 'a shell-interpreted argv would have created this file');
});
test('E14: string return is toString-compatible', () => {
const r = gitOrThrow(['--version'], { cwd: dir });
assert.equal(r.toString(), r);
});
test('E15: string return is trim-compatible', () => {
const r = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
assert.equal(typeof r.trim(), 'string');
assert.equal(r.trim(), 'mainline');
});
test('E16: cwd is forwarded to the seam', () => {
const otherDir = initRepo('git-fixture-test-other-');
gitOrThrow(['checkout', '-b', 'other-branch'], { cwd: otherDir });
const branchInDir = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).trim();
const branchInOtherDir = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: otherDir }).trim();
assert.equal(branchInDir, 'mainline');
assert.equal(branchInOtherDir, 'other-branch');
cleanup(otherDir);
});
test('E17: env is forwarded to the seam', () => {
gitOrThrow(
['commit', '--allow-empty', '-m', 'env-authored commit'],
{
cwd: dir,
env: {
...process.env,
GIT_AUTHOR_NAME: 'Env Author',
GIT_AUTHOR_EMAIL: 'env-author@example.com',
GIT_COMMITTER_NAME: 'Env Author',
GIT_COMMITTER_EMAIL: 'env-author@example.com',
},
}
);
const author = gitOrThrow(['log', '-1', '--format=%an'], { cwd: dir }).trim();
assert.equal(author, 'Env Author');
});
});

View File

@@ -0,0 +1,87 @@
'use strict';
/**
* git-fixture — a throw-preserving wrapper over the process seam's `runGit`.
*
* Why this exists: `execSync`/`execFileSync` throw on any non-zero exit,
* and 237 sites in this repo's test suite are written against that throw —
* they read `err.status`, `err.stdout`, `err.stderr`. `tests/helpers/
* process-seam.cjs` deliberately never throws (see its own header): every
* outcome, including a non-zero exit, a timeout, or a spawn failure, comes
* back as data on a discriminated-union result. Migrating a throwing
* `execSync`/`execFileSync` call site straight onto the seam without this
* wrapper would silently turn a loud test failure (an uncaught throw) into
* a quiet one (a result object nobody checked) — exactly the kind of
* regression a migration must not introduce.
*
* `gitOrThrow` is that bridge: it calls the seam's `runGit` and re-throws in
* the shape the old idiom produced, with the seam's typed fields attached
* alongside it. `tests/helpers/process-seam.cjs` itself is NOT modified by
* this module — its never-throws contract is intact; this is a layer on
* top, not a change underneath.
*/
const { runGit, OUTCOME } = require('./process-seam.cjs');
/**
* Default timeout for `gitOrThrow` calls, in milliseconds.
*
* 15000ms: these are git plumbing operations (rev-parse, branch, log, ...)
* against a small mkdtemp fixture repo — well over any observed local/CI
* duration for that class of call, and far under the seam's own 60000ms
* default so a hung git surfaces fast instead of riding out the seam's full
* budget.
*/
const DEFAULT_GIT_TIMEOUT_MS = 15000;
/**
* Run `git` via the process seam and throw on anything other than a clean
* exit, preserving the legacy `execSync`/`execFileSync` throw-on-failure
* idiom that existing test code is written against.
*
* @param {string[]} args - argv passed to git (never shell-interpreted).
* @param {object} [options] - forwarded to `runGit`; see process-seam.cjs.
* `options.timeoutMs`, if provided, overrides `DEFAULT_GIT_TIMEOUT_MS`.
* @returns {string} `stdout` on a clean (exit 0) run.
* @throws {Error} On any non-zero exit, timeout, kill, or spawn failure.
* The thrown error carries, as own properties:
* - `status` — the exit code (the legacy `execSync`/`execFileSync` name;
* this repo's migrated catch blocks read `err.status`, e.g.
* tests/worktree-safety.test.cjs:1361, tests/read-guard.test.cjs:160,
* tests/security-scan.security.test.cjs:201).
* - `exitCode` — the same value as `status` (the seam's own name; both
* are aliases on purpose, not a rename).
* - `stdout`, `stderr` — strings.
* - `signal` — the seam's `signal` field.
* - `timedOut` — the seam's `timedOut` field.
* - `outcome` — the seam's `OUTCOME` discriminant.
*/
function gitOrThrow(args, options = {}) {
// Destructure (not spread-after) so an explicit `timeoutMs: undefined` in
// `options` still resolves to the default: a destructure default applies
// on `undefined`, whereas `{ timeoutMs: DEFAULT, ...options }` would let
// an own `undefined` key silently overwrite it and fall through to the
// seam's much larger default timeout.
const { timeoutMs = DEFAULT_GIT_TIMEOUT_MS, ...rest } = options;
const r = runGit(args, { ...rest, timeoutMs });
if (r.outcome === OUTCOME.EXITED && r.exitCode === 0) {
return r.stdout;
}
const argvDisplay = ['git', ...args].join(' ');
const err = new Error(
`gitOrThrow: \`${argvDisplay}\` failed — outcome=${r.outcome} exitCode=${r.exitCode} ` +
`stderr=${r.stderr.trim()}`
);
err.status = r.exitCode;
err.exitCode = r.exitCode;
err.stdout = r.stdout;
err.stderr = r.stderr;
err.signal = r.signal;
err.timedOut = r.timedOut;
err.outcome = r.outcome;
throw err;
}
module.exports = { gitOrThrow, DEFAULT_GIT_TIMEOUT_MS };

View File

@@ -0,0 +1,166 @@
// allow-test-rule: structural-regression-guard [#3143]
/**
* no-unbounded-spawn-allowlist.test.cjs
*
* Structural guards on `eslint-rules/no-unbounded-spawn.allowlist.json` —
* matrix D4-D8 of
* .gsd/phase/chore-3143-no-unbounded-spawn-guard/50-test-matrix.md.
*
* D7 needs to inspect test-file *contents* for an inline directive that
* disables this rule by name — the absence of that pattern is the contract
* this guard protects (a contributor cannot silence the check by disabling
* it inline instead of fixing the timeout). That is a `readFileSync` +
* text-search on `.cjs` files, which is exactly what `local/no-source-grep`
* exists to catch — hence the
* `// allow-test-rule: structural-regression-guard [#3143]` annotation on
* its own line above, per CONTRIBUTING.md's documented exemption.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ALLOWLIST_PATH = path.join(__dirname, '..', 'eslint-rules', 'no-unbounded-spawn.allowlist.json');
const TESTS_DIR = path.join(__dirname);
const REPO_ROOT = path.join(__dirname, '..');
// The allowlist only ratchets DOWN. This baseline is the length observed at
// the time this guard was written (139 entries) — each future migration
// wave lowers it as files are moved off the allowlist by adding real
// timeouts; it must never grow back up.
const BASELINE = 139;
function readAllowlist() {
const raw = fs.readFileSync(ALLOWLIST_PATH, 'utf8');
return JSON.parse(raw);
}
function listTestFiles() {
const out = [];
for (const entry of fs.readdirSync(TESTS_DIR, { withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith('.cjs')) {
out.push(path.join(TESTS_DIR, entry.name));
}
}
return out;
}
// Pure detection helpers, extracted so each is unit-testable against a
// synthetic fixture — proving the check itself can fail, not only that
// today's real data happens to pass it (a check that never runs against an
// injected violation is a vacuous-truth risk).
function findDeadEntries(list, root) {
return list.filter((entry) => !fs.existsSync(path.join(root, entry)));
}
function findBackslashEntries(list) {
return list.filter((entry) => entry.includes('\\'));
}
function isSorted(list) {
return JSON.stringify(list) === JSON.stringify([...list].sort());
}
function findDuplicates(list) {
const seen = new Set();
const dupes = new Set();
for (const entry of list) {
if (seen.has(entry)) dupes.add(entry);
seen.add(entry);
}
return [...dupes];
}
// Built via concatenation, not a string literal, so this file does not
// itself contain the literal directive text (`local/no-unbounded-spawn`)
// that D7 below scans every test file for — a literal here would make this
// guard flag itself.
const GUARDED_RULE = 'local' + '/' + 'no-unbounded-spawn';
function containsDisableDirective(contents, ruleName) {
return new RegExp(`eslint-disable[^\\n]*${ruleName}`).test(contents);
}
describe('no-unbounded-spawn allowlist: D4 — no dead entries', () => {
test('every allowlist entry resolves to a file that exists on disk', () => {
const list = readAllowlist();
const dead = findDeadEntries(list, REPO_ROOT);
assert.deepEqual(dead, [], `dead allowlist entries (file does not exist): ${JSON.stringify(dead)}`);
});
test('detection logic actually flags a synthetic dead entry', () => {
const synthetic = ['tests/does-not-exist-xyz.test.cjs', 'tests/no-unbounded-spawn.test.cjs'];
const dead = findDeadEntries(synthetic, REPO_ROOT);
assert.deepEqual(dead, ['tests/does-not-exist-xyz.test.cjs']);
});
});
describe('no-unbounded-spawn allowlist: D5 — never grows', () => {
test('allowlist length is at or under the recorded baseline', () => {
const list = readAllowlist();
assert.ok(
list.length <= BASELINE,
`allowlist grew to ${list.length} entries, exceeding the BASELINE of ${BASELINE}. ` +
`The allowlist only ratchets down — if this is a legitimate new violation, ` +
`lower BASELINE only after confirming it, never raise it to paper over growth.`
);
});
});
describe('no-unbounded-spawn allowlist: D6 — separator normalization', () => {
test('every entry uses / separators, never \\', () => {
const list = readAllowlist();
const withBackslash = findBackslashEntries(list);
assert.deepEqual(withBackslash, [], `entries with a backslash separator: ${JSON.stringify(withBackslash)}`);
});
test('detection logic actually flags a synthetic backslash entry', () => {
const synthetic = ['tests\\foo.test.cjs', 'tests/bar.test.cjs'];
assert.deepEqual(findBackslashEntries(synthetic), ['tests\\foo.test.cjs']);
});
});
describe('no-unbounded-spawn allowlist: D7 — no inline disable of this rule', () => {
test('no test file inline-disables the unbounded-spawn guard', () => {
const offenders = [];
for (const filePath of listTestFiles()) {
const contents = fs.readFileSync(filePath, 'utf8');
if (containsDisableDirective(contents, GUARDED_RULE)) {
offenders.push(path.relative(REPO_ROOT, filePath));
}
}
assert.deepEqual(
offenders,
[],
`test files inline-disabling the unbounded-spawn guard (forbidden — fix the timeout instead): ${JSON.stringify(offenders)}`
);
});
test('detection logic actually flags a synthetic inline-disable directive', () => {
const syntheticContents = [
"'use strict';",
'// eslint-disable-next-line ' + GUARDED_RULE,
"spawnSync('git', ['status'], {});",
].join('\n');
assert.equal(containsDisableDirective(syntheticContents, GUARDED_RULE), true);
assert.equal(containsDisableDirective("'use strict';\nspawnSync('git', ['status'], {});", GUARDED_RULE), false);
});
});
describe('no-unbounded-spawn allowlist: D8 — canonical form', () => {
test('allowlist is valid JSON, sorted, with no duplicates', () => {
const list = readAllowlist();
assert.ok(Array.isArray(list), 'allowlist.json must parse to an array');
assert.ok(isSorted(list), 'allowlist entries must be sorted');
assert.deepEqual(findDuplicates(list), [], 'allowlist entries must be unique');
});
test('detection logic actually flags a synthetic unsorted/duplicate list', () => {
assert.equal(isSorted(['b.test.cjs', 'a.test.cjs']), false);
assert.deepEqual(findDuplicates(['a.test.cjs', 'b.test.cjs', 'a.test.cjs']), ['a.test.cjs']);
});
});

View File

@@ -0,0 +1,457 @@
'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' }],
},
],
});
});
});