* 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:
213
tests/git-fixture.test.cjs
Normal file
213
tests/git-fixture.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
87
tests/helpers/git-fixture.cjs
Normal file
87
tests/helpers/git-fixture.cjs
Normal 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 };
|
||||
166
tests/no-unbounded-spawn-allowlist.test.cjs
Normal file
166
tests/no-unbounded-spawn-allowlist.test.cjs
Normal 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']);
|
||||
});
|
||||
});
|
||||
457
tests/no-unbounded-spawn.test.cjs
Normal file
457
tests/no-unbounded-spawn.test.cjs
Normal 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' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user