Files
msd-core/tests/fallow-runner.test.cjs
Tom Boucher 2972da4c9d enhance(#3619): ratchet the platform seam with local/no-private-binary-resolution (epic #3411 Phase 3) (#3636)
* chore(#3619): ratchet the platform seam with local/no-private-binary-resolution

Epic #3411 Phase 3, the ratchet. Scope revised with maintainer approval and
recorded on the issue: the epic's literal ask was a rule rejecting a bare-name
spawn outside the seam. Surveyed at ac1b6d679, ~30 such sites exist and none is
a defect — git, gh and npm ship native .exe that CreateProcess resolves unaided,
and the rest are POSIX-only tools. ADR-1703 rules 2 and 3 forbid grandfathering
and escape hatches, so a literal rule would be unsuppressable and would force
rewriting 30 correct calls.

The epic's actual thesis was four private RESOLVERS, not four bare spawns. So
the rule flags re-implementing resolution: reading PATHEXT in any casing from
any object, and a hardcoded list carrying two or more of .exe/.cmd/.bat/.com —
precisely the shapes fallow-runner's candidateNames and gsd-tools' PATHEXT
string had before Phases 1 and 2 deleted them.

Three boundaries were arrived at rather than assumed:

  two-or-more   a single .endsWith('.cmd') is a classification, not a candidate
                set; runtime-hooks-surface derives .cmd shim paths that way
  boundary-aware  a naive substring test flags .execute and .compacting, caught
                on src/host-integration.cts before it could become a false
                positive nobody could suppress
  suffix-anchored  the seam exemption matches src/shell-command-projection.cts
                exactly; a substring match would also exempt the dispatch test
                file. Case I9 pins it.

PATH scans are deliberately NOT flagged — membership checks (bin/install.js)
are indistinguishable from resolution scans, and an unsound rule in a
zero-escape-hatch architecture is worse than no rule.

To make the ratchet strict with no carve-out, resolveExecutableBinary gained
pathOverride: search THIS PATH, read everything else including PATHEXT from the
ambient environment. resolveFallowBinary now supplies its own search path
without hand-threading PATHEXT, which would itself have been a private read.
The three alternatives were all worse: exempting the file is grandfathering,
exempting the AST shape is a carve-out every future caller must replicate, and
dropping the pass-through would silently ignore a user's real PATHEXT — buying
a lint rule with a correctness regression.

eslint-rules/** is outside the rule's globs rather than exempted, because
portability-vocab.cjs owns the extension set. scripts/**/*.cjs got its own block
so that exclusion does not leave a hole in the ratchet.

Started green with nothing suppressed. Proven able to fail: a fixture with both
signals reports two errors.

Refs #3411

* fix(#3619): close the PATHEXT destructuring evasion and correct two overclaims

Adversarial review found a trivial evasion of the rule's primary signal: the
visitor only handled MemberExpression, so

  const { PATHEXT } = process.env
  const { PATHEXT: exts } = process.env
  const { Pathext } = opts.env

were all unflagged. That is a common idiom, not an exotic bypass. An ObjectPattern
visitor now catches it in every form — renamed, any casing, any receiver, string
keys — while leaving a computed key alone, since it is not statically decidable.
I10-I13 pin the invalid forms and V9/V10 pin PATH and the computed key.

Two overclaims corrected, both mine:

Standards review proved the docs were factually wrong. Both the ADR amendment and
the CONTEXT.md entry asserted that tests/shell-command-projection-dispatch.test.cjs
is still linted by this rule. It is not — the rule's surface is src, gsd-core/bin,
scripts and hooks, and tests/** is deliberately outside it because test setup
legitimately assigns process.env.PATHEXT (fallow-runner's P3 does exactly that).
The suffix-vs-substring distinction is therefore proven by RuleTester case I9
feeding a synthetic filename, NOT by real coverage of that file. Both documents now
say so.

The rule's own docstring claimed the seam exemption matches the seam path
'exactly'. It is a suffix match, so a nested foo/src/shell-command-projection.cts
would also be exempt. Suffix matching is kept — it is how sibling rules resolve
paths and the nested case does not exist — but the docstring now states the
boundary rather than overstating the precision.

The evasion fix was verified by executing eslint against both destructuring forms
in scripts/, not by inspection. Probe: 31/31.

Refs #3411

* chore(#3619): backfill changeset pr number 3636

---------

Co-authored-by: sim <sim@local>
2026-08-18 16:40:17 -04:00

369 lines
15 KiB
JavaScript

'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const {
resolveFallowBinary,
requireFallowBinary,
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'fallow-runner.cjs'));
const {
resolveExecutableBinary,
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
const { createTempDir, cleanup } = require('./helpers.cjs');
// `resolveFallowBinary` resolves through the seam using the REAL process.platform —
// it has no platform injection. So the fixture, not the assertion, is what varies:
// on win32 the seam only accepts a PATHEXT-listed extension (never a bare name, by
// design — the #3275 npm sh-shim trap), so the staged file must carry one there.
// This keeps every row genuinely executing on all three CI lanes instead of
// silently no-opping on one.
//
// The extension's CASE must come from the same source the seam itself falls
// back to (fallow-runner.cjs passes `process.env.PATHEXT ?? ''`, and the seam
// falls back to its own DEFAULT_PATHEXT `.EXE;.CMD;.BAT;.COM` when that's
// empty) — resolveExecutableBinary returns `name + <PATHEXT entry AS-IS>`,
// never the on-disk file's own casing, so a hardcoded 'fallow.cmd' would
// silently mismatch a real Windows PATHEXT of '.CMD' with a strict
// assert.equal (case-insensitive filesystem, case-SENSITIVE string compare).
const WIN32_CMD_EXT = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
.split(';')
.find((ext) => ext.toLowerCase() === '.cmd') ?? '.CMD';
const FALLOW_FIXTURE = process.platform === 'win32' ? `fallow${WIN32_CMD_EXT}` : 'fallow';
// ─── fallow-runner (#3618 Phase 2) ──────────────────────────────────────────
// Fold of the prior private resolver onto the seam's resolveExecutableBinary.
// See .gsd/phase/chore-3618-fallow-binary-resolution/50-test-matrix.md (F1-F15).
//
// Executability (X_OK) cannot be forced via chmod — root Docker bypasses mode
// bits, which would silently zero out coverage. Every case that needs a POSIX
// "is executable" or "is not executable" outcome monkeypatches fs.accessSync
// instead, restored in a finally.
//
// F7-F10 (the win32 rows) cannot inject a platform through
// resolveFallowBinary's public signature, so they are asserted directly
// against resolveExecutableBinary with platform:'win32' plus the same
// prependPaths/requireExecutable shape resolveFallowBinary passes — a
// projection of fallow's call, not fallow's own entry point (approach (a)
// from the phase brief).
function resolveFallowBinaryWin32({ cwd, envPath = '' }) {
return resolveExecutableBinary('fallow', {
platform: 'win32',
prependPaths: [path.join(cwd, 'node_modules', '.bin')],
env: { PATH: envPath, PATHEXT: process.env['PATHEXT'] ?? '' },
requireExecutable: true,
});
}
describe('resolveFallowBinary (#3618)', () => {
test('F1: POSIX, executable fallow in <cwd>/node_modules/.bin only — the .bin path', () => {
const cwd = createTempDir();
const originalAccessSync = fs.accessSync;
try {
const binDir = path.join(cwd, 'node_modules', '.bin');
fs.mkdirSync(binDir, { recursive: true });
const fallowPath = path.join(binDir, FALLOW_FIXTURE);
fs.writeFileSync(fallowPath, '');
fs.accessSync = () => {};
const resolved = resolveFallowBinary({ cwd, envPath: '' });
assert.equal(resolved, fallowPath);
} finally {
fs.accessSync = originalAccessSync;
cleanup(cwd);
}
});
test('F2: POSIX, fallow on envPath only — the PATH copy', () => {
const cwd = createTempDir();
const pathDir = createTempDir();
const originalAccessSync = fs.accessSync;
try {
const fallowPath = path.join(pathDir, FALLOW_FIXTURE);
fs.writeFileSync(fallowPath, '');
fs.accessSync = () => {};
const resolved = resolveFallowBinary({ cwd, envPath: pathDir });
assert.equal(resolved, fallowPath);
} finally {
fs.accessSync = originalAccessSync;
cleanup(cwd);
cleanup(pathDir);
}
});
test('F3: POSIX, executable fallow in BOTH .bin and PATH — .bin wins (documented-backwards precedence)', () => {
const cwd = createTempDir();
const pathDir = createTempDir();
const originalAccessSync = fs.accessSync;
try {
const binDir = path.join(cwd, 'node_modules', '.bin');
fs.mkdirSync(binDir, { recursive: true });
const binFallow = path.join(binDir, FALLOW_FIXTURE);
const pathFallow = path.join(pathDir, FALLOW_FIXTURE);
fs.writeFileSync(binFallow, '');
fs.writeFileSync(pathFallow, '');
fs.accessSync = () => {};
const resolved = resolveFallowBinary({ cwd, envPath: pathDir });
assert.equal(resolved, binFallow);
assert.notEqual(resolved, pathFallow);
} finally {
fs.accessSync = originalAccessSync;
cleanup(cwd);
cleanup(pathDir);
}
});
test('F4: accessSync failure yields null on POSIX; on win32 requireExecutable is a documented no-op', () => {
const cwd = createTempDir();
const originalAccessSync = fs.accessSync;
try {
const binDir = path.join(cwd, 'node_modules', '.bin');
fs.mkdirSync(binDir, { recursive: true });
const fallowPath = path.join(binDir, FALLOW_FIXTURE);
fs.writeFileSync(fallowPath, '');
fs.accessSync = () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); };
const resolved = resolveFallowBinary({ cwd, envPath: '' });
if (process.platform === 'win32') {
// requireExecutable is a documented no-op on win32 (mode bits don't
// mean execute there), so accessSync throwing must NOT block resolution.
assert.equal(resolved, fallowPath);
} else {
// POSIX: the X_OK check is consulted, so a throwing accessSync must null out.
assert.equal(resolved, null);
}
} finally {
fs.accessSync = originalAccessSync;
cleanup(cwd);
}
});
test('F5: absent everywhere — null', () => {
const cwd = createTempDir();
try {
const resolved = resolveFallowBinary({ cwd, envPath: '' });
assert.equal(resolved, null);
} finally {
cleanup(cwd);
}
});
test('F6: envPath omitted (default from process.env.PATH), binary in .bin — resolves from .bin', () => {
const cwd = createTempDir();
const originalAccessSync = fs.accessSync;
const originalPath = process.env.PATH;
try {
const binDir = path.join(cwd, 'node_modules', '.bin');
fs.mkdirSync(binDir, { recursive: true });
const fallowPath = path.join(binDir, FALLOW_FIXTURE);
fs.writeFileSync(fallowPath, '');
fs.accessSync = () => {};
process.env.PATH = '';
// Call shape production uses (structural-pre-pass.md): no envPath.
const resolved = resolveFallowBinary({ cwd });
assert.equal(resolved, fallowPath);
} finally {
fs.accessSync = originalAccessSync;
process.env.PATH = originalPath;
cleanup(cwd);
}
});
test('F7: win32, fallow.CMD in .bin — resolves .bin/fallow.CMD', () => {
const cwd = createTempDir();
try {
const binDir = path.join(cwd, 'node_modules', '.bin');
fs.mkdirSync(binDir, { recursive: true });
const fallowCmd = path.join(binDir, 'fallow.CMD');
fs.writeFileSync(fallowCmd, '');
const resolved = resolveFallowBinaryWin32({ cwd, envPath: '' });
assert.equal(resolved, fallowCmd);
} finally {
cleanup(cwd);
}
});
test('F8: win32, fallow.EXE and fallow.CMD both on PATH — .EXE wins (PATHEXT default order)', () => {
const cwd = createTempDir();
const pathDir = createTempDir();
try {
const exePath = path.join(pathDir, 'fallow.EXE');
const cmdPath = path.join(pathDir, 'fallow.CMD');
fs.writeFileSync(exePath, '');
fs.writeFileSync(cmdPath, '');
const resolved = resolveFallowBinaryWin32({ cwd, envPath: pathDir });
assert.equal(resolved, exePath);
assert.notEqual(resolved, cmdPath);
} finally {
cleanup(cwd);
cleanup(pathDir);
}
});
test('F9: win32, extensionless fallow beside fallow.CMD — resolves fallow.CMD, never the shim (#3275 trap)', () => {
const cwd = createTempDir();
const pathDir = createTempDir();
try {
const bareFallow = path.join(pathDir, 'fallow');
const cmdFallow = path.join(pathDir, 'fallow.CMD');
fs.writeFileSync(bareFallow, '');
fs.writeFileSync(cmdFallow, '');
const resolved = resolveFallowBinaryWin32({ cwd, envPath: pathDir });
assert.equal(resolved, cmdFallow);
assert.notEqual(resolved, bareFallow);
} finally {
cleanup(cwd);
cleanup(pathDir);
}
});
test('F10: win32, only an extensionless fallow — null (intentional change)', () => {
const cwd = createTempDir();
const pathDir = createTempDir();
try {
fs.writeFileSync(path.join(pathDir, 'fallow'), '');
const resolved = resolveFallowBinaryWin32({ cwd, envPath: pathDir });
assert.equal(resolved, null);
} finally {
cleanup(cwd);
cleanup(pathDir);
}
});
test('F11: requireFallowBinary with a binary present — returns the path, does not throw', () => {
const cwd = createTempDir();
const originalAccessSync = fs.accessSync;
try {
const binDir = path.join(cwd, 'node_modules', '.bin');
fs.mkdirSync(binDir, { recursive: true });
const fallowPath = path.join(binDir, FALLOW_FIXTURE);
fs.writeFileSync(fallowPath, '');
fs.accessSync = () => {};
const resolved = requireFallowBinary({ cwd, envPath: '' });
assert.equal(resolved, fallowPath);
} finally {
fs.accessSync = originalAccessSync;
cleanup(cwd);
}
});
test('F12: requireFallowBinary with nothing found — throws the byte-identical install-instructions message', () => {
const cwd = createTempDir();
try {
let caught = null;
try {
requireFallowBinary({ cwd, envPath: '' });
} catch (err) {
caught = err;
}
assert.ok(caught instanceof Error, 'requireFallowBinary must throw when nothing is found');
assert.equal(
caught.message,
'Fallow is enabled but no binary was found. Please install fallow via `npm install -D fallow` or `cargo install fallow`.',
);
} finally {
cleanup(cwd);
}
});
test('F13: <cwd> has no node_modules at all — no throw; falls through to PATH', () => {
const cwd = createTempDir();
const pathDir = createTempDir();
const originalAccessSync = fs.accessSync;
const originalPath = process.env.PATH;
try {
const fallowPath = path.join(pathDir, FALLOW_FIXTURE);
fs.writeFileSync(fallowPath, '');
fs.accessSync = () => {};
process.env.PATH = pathDir;
// Call shape production uses (structural-pre-pass.md): no envPath.
assert.doesNotThrow(() => {
const resolved = resolveFallowBinary({ cwd });
assert.equal(resolved, fallowPath);
});
} finally {
fs.accessSync = originalAccessSync;
process.env.PATH = originalPath;
cleanup(cwd);
cleanup(pathDir);
}
});
test('F14: <cwd>/node_modules/.bin exists but is a file, not a directory — no throw; falls through', () => {
const cwd = createTempDir();
const pathDir = createTempDir();
const originalAccessSync = fs.accessSync;
try {
const nodeModulesDir = path.join(cwd, 'node_modules');
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(path.join(nodeModulesDir, '.bin'), ''); // .bin is a FILE, not a dir
const fallowPath = path.join(pathDir, FALLOW_FIXTURE);
fs.writeFileSync(fallowPath, '');
fs.accessSync = () => {};
assert.doesNotThrow(() => {
const resolved = resolveFallowBinary({ cwd, envPath: pathDir });
assert.equal(resolved, fallowPath);
});
} finally {
fs.accessSync = originalAccessSync;
cleanup(cwd);
cleanup(pathDir);
}
});
test('F15: a directory named fallow in .bin — not resolved (isFile() is the predicate)', () => {
const cwd = createTempDir();
const originalAccessSync = fs.accessSync;
try {
const binDir = path.join(cwd, 'node_modules', '.bin');
fs.mkdirSync(path.join(binDir, FALLOW_FIXTURE), { recursive: true }); // fallow is a DIRECTORY
fs.accessSync = () => {};
const resolved = resolveFallowBinary({ cwd, envPath: '' });
assert.equal(resolved, null);
} finally {
fs.accessSync = originalAccessSync;
cleanup(cwd);
}
});
// P3 (#3619, epic #3411 Phase 3): resolveFallowBinary switched from
// `env: { PATH: envPath, PATHEXT: process.env['PATHEXT'] ?? '' }` to
// `pathOverride: envPath` so this module never reads PATHEXT itself (the
// shape local/no-private-binary-resolution forbids outside the seam). The
// seam's `env` param is left unset, so it defaults to `process.env` inside
// resolveExecutableBinary — a REAL ambient process.env.PATHEXT must still
// govern win32 resolution exactly as it did before the switch. This is the
// row the whole switch could have silently broken.
//
// PATHEXT is meaningless on POSIX (R21), so per F4's pattern above, BOTH
// platform contracts are asserted explicitly rather than skipping either:
// a `t.skip()`/bare `return` would make this a vacuous pass on that lane.
test('P3: a real ambient process.env.PATHEXT still governs win32 resolution after the env -> pathOverride switch', () => {
const cwd = createTempDir();
const pathDir = createTempDir();
const originalPathext = process.env.PATHEXT;
try {
process.env.PATHEXT = '.XYZ';
const distinctiveFixture = path.join(pathDir, 'fallow.XYZ');
fs.writeFileSync(distinctiveFixture, '');
const resolved = resolveFallowBinary({ cwd, envPath: pathDir });
if (process.platform === 'win32') {
// win32: ambient process.env.PATHEXT ('.XYZ') is consulted, so the
// distinctively-extensioned fixture resolves — proving the pass-through
// was preserved by pathOverride, not silently dropped.
assert.equal(resolved, distinctiveFixture);
} else {
// POSIX: PATHEXT plays no role in resolution at all (resolveExecutableBinary
// matches the bare name 'fallow' exactly); a file named 'fallow.XYZ' is not
// that name, so nothing is found.
assert.equal(resolved, null);
}
} finally {
if (originalPathext === undefined) delete process.env.PATHEXT; else process.env.PATHEXT = originalPathext;
cleanup(cwd);
cleanup(pathDir);
}
});
});