* 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>
167 lines
7.2 KiB
JavaScript
167 lines
7.2 KiB
JavaScript
'use strict';
|
|
|
|
const path = require('node:path');
|
|
const { PATHEXT_VAR_NAME, countWindowsExecutableExtensions } = require('./lib/portability-vocab.cjs');
|
|
|
|
/**
|
|
* no-private-binary-resolution
|
|
*
|
|
* Epic #3411's actual thesis: four private Windows-binary-resolution
|
|
* implementations existed (execNpm's shell:true, execTool's absence of any
|
|
* handling, gsd-tools.cjs's private scan, and fallow-runner.cts's own
|
|
* candidate array). All four are gone; this rule stops a fifth from
|
|
* accreting by flagging the two unambiguous "I am re-implementing Windows
|
|
* binary resolution" shapes outside the platform seam
|
|
* (src/shell-command-projection.cts):
|
|
*
|
|
* 1. Reading PATHEXT from any object, any casing — Windows environment
|
|
* variable names are case-insensitive, and nothing reads PATHEXT for a
|
|
* reason other than locating an executable. This covers both member
|
|
* access (`env.PATHEXT`, `env['Pathext']`) and destructuring
|
|
* (`const { PATHEXT } = env`, `const { PATHEXT: exts } = env`), from
|
|
* any source object.
|
|
* 2. A hardcoded Windows executable-extension list (an ArrayExpression or
|
|
* a single string literal) carrying two or more of .exe/.cmd/.bat/.com
|
|
* — the exact shape both deleted implementations had. A SINGLE
|
|
* extension is deliberately not flagged: that is a classification
|
|
* (`p.endsWith('.cmd')`) or a shim-path derivation
|
|
* (`scriptPath.replace(/\.js$/, '.cmd')`), not a candidate list, and
|
|
* the tree has legitimate instances of both.
|
|
*
|
|
* The seam exemption is PATH-SUFFIX ANCHORED, not substring-matched: a file
|
|
* is exempt only when its repo-relative path IS `src/shell-command-projection.cts`
|
|
* or ENDS WITH `/src/shell-command-projection.cts` — so a file nested under
|
|
* any parent directory at that suffix (e.g. `foo/src/shell-command-projection.cts`)
|
|
* is exempt too, but a file that merely contains that string as a substring
|
|
* elsewhere in its own path, or as a `.bak` / `.test.cts` variant of the
|
|
* filename itself, is NOT exempt (case I9 pins this distinction) — a
|
|
* substring exemption would be the obvious wrong implementation here.
|
|
*
|
|
* See .gsd/phase/chore-3619-no-bare-binary-spawn/40-design.md for the full
|
|
* behavior table and rejected alternatives.
|
|
*/
|
|
|
|
const SEAM_RELATIVE_PATH = 'src/shell-command-projection.cts';
|
|
|
|
/**
|
|
* True when `filename` IS the seam file, matched by path SUFFIX after
|
|
* normalizing separators to `/` — never by substring containment anywhere
|
|
* else in the path.
|
|
*
|
|
* @param {string} filename
|
|
* @returns {boolean}
|
|
*/
|
|
function isSeamFile(filename) {
|
|
if (typeof filename !== 'string' || filename.length === 0) return false;
|
|
const normalized = filename.split(path.sep).join('/');
|
|
return normalized === SEAM_RELATIVE_PATH || normalized.endsWith(`/${SEAM_RELATIVE_PATH}`);
|
|
}
|
|
|
|
/** True when `node` is a string Literal whose value case-insensitively equals PATHEXT_VAR_NAME. */
|
|
function isPathextStringLiteral(node) {
|
|
return !!node && node.type === 'Literal' && typeof node.value === 'string'
|
|
&& node.value.toLowerCase() === PATHEXT_VAR_NAME.toLowerCase();
|
|
}
|
|
|
|
/** True when `node` is an Identifier whose name case-insensitively equals PATHEXT_VAR_NAME. */
|
|
function isPathextIdentifier(node) {
|
|
return !!node && node.type === 'Identifier'
|
|
&& node.name.toLowerCase() === PATHEXT_VAR_NAME.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* True when a MemberExpression's property resolves to PATHEXT, any casing:
|
|
* - non-computed (dot access): property is always an Identifier — `env.PATHEXT`, `env.pathext`
|
|
* - computed (bracket access): property may be a string Literal — `env['PATHEXT']` —
|
|
* or an Identifier referencing a same-named local variable — `env[PATHEXT]`
|
|
* (row 10 in the design doc: a variable *named* PATHEXT, accepted false-positive risk)
|
|
*/
|
|
function memberExpressionReadsPathext(node) {
|
|
const property = node.property;
|
|
if (!node.computed) return isPathextIdentifier(property);
|
|
return isPathextStringLiteral(property) || isPathextIdentifier(property);
|
|
}
|
|
|
|
/**
|
|
* True when an ObjectPattern `Property`'s key resolves to PATHEXT, any casing —
|
|
* regardless of what is being destructured (`process.env`, `env`, `opts.env`,
|
|
* anything) and regardless of renaming (`const { PATHEXT: exts } = ...`):
|
|
* - non-computed key: an Identifier (`{ PATHEXT }`, `{ Pathext: v }`) or a
|
|
* string Literal (`{ 'PATHEXT': v }`)
|
|
* - computed key (`{ [expr]: v }`): only a string Literal is statically
|
|
* decidable (`{ ['PATHEXT']: v }`); a variable expression like `{ [key]: v }`
|
|
* is NOT decidable and must not be reported (an Identifier that happens to
|
|
* be *named* PATHEXT is still accepted, mirroring the MemberExpression
|
|
* computed case above and its accepted false-positive risk).
|
|
*/
|
|
function objectPatternPropertyReadsPathext(property) {
|
|
if (!property || property.type !== 'Property') return false;
|
|
const key = property.key;
|
|
return isPathextIdentifier(key) || isPathextStringLiteral(key);
|
|
}
|
|
|
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
const rule = {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description: 'Disallow re-implementing Windows binary resolution outside the platform seam',
|
|
category: 'Portability',
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
pathextRead:
|
|
'Reading PATHEXT is Windows binary resolution — route through resolveExecutableBinary ' +
|
|
'in src/shell-command-projection.cts instead. Four private resolvers is what epic #3411 removed.',
|
|
extensionList:
|
|
'A hardcoded Windows executable-extension list is a private resolver candidate set — ' +
|
|
'use the seam\'s PATHEXT handling in src/shell-command-projection.cts instead.',
|
|
},
|
|
},
|
|
|
|
create(context) {
|
|
const filename = typeof context.filename === 'string' ? context.filename : context.getFilename();
|
|
if (isSeamFile(filename)) return {};
|
|
|
|
return {
|
|
MemberExpression(node) {
|
|
if (memberExpressionReadsPathext(node)) {
|
|
context.report({ node, messageId: 'pathextRead' });
|
|
}
|
|
},
|
|
|
|
ObjectPattern(node) {
|
|
for (const property of node.properties) {
|
|
if (objectPatternPropertyReadsPathext(property)) {
|
|
context.report({ node: property, messageId: 'pathextRead' });
|
|
}
|
|
}
|
|
},
|
|
|
|
ArrayExpression(node) {
|
|
const stringValues = node.elements
|
|
.filter((el) => el && el.type === 'Literal' && typeof el.value === 'string')
|
|
.map((el) => el.value);
|
|
if (stringValues.length === 0) return;
|
|
if (countWindowsExecutableExtensions(stringValues.join(' ')) >= 2) {
|
|
context.report({ node, messageId: 'extensionList' });
|
|
}
|
|
},
|
|
|
|
Literal(node) {
|
|
if (typeof node.value !== 'string') return;
|
|
// Elements of an ArrayExpression are handled by the ArrayExpression
|
|
// visitor above (which combines the whole array's contents) — do not
|
|
// double-report the same evidence from this node's own value alone.
|
|
const parent = node.parent;
|
|
if (parent && parent.type === 'ArrayExpression' && parent.elements.includes(node)) return;
|
|
if (countWindowsExecutableExtensions(node.value) >= 2) {
|
|
context.report({ node, messageId: 'extensionList' });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
module.exports = rule;
|