* 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>
214 lines
7.9 KiB
JavaScript
214 lines
7.9 KiB
JavaScript
'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');
|
|
});
|
|
});
|