@@ -730,9 +730,9 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
|
||||
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.symptom=local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.examples=PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint:windows-test-portability (tripwire: flags tests combining chmod exec-bit with sh/bash -c and no platform guard); watch CI windows matrix green before declaring a PR done`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh <path>) rather than relying on exec-bit; annotate // windows-portability-ok: <reason> when a bypass is intentional`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run lint:ci before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh <path>) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it`
|
||||
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom=a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === <that exact octal>; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples=#1634/PR #1638 tests/capability-lifecycle.test.cjs "a .cjs hook command is node-prefixed so it runs without the executable bit" failed windows-latest,24 on "precondition: file staged without +x" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact`
|
||||
@@ -813,6 +813,7 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.detect=Windows CI job at "Run unit tests" exits with code 1 within seconds of starting, no node:test output between "run-tests: suite=… files=N: …" line and "Process completed with exit code 1"; same job on Linux/macOS runs full duration`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward=chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor=tests/run-tests-harness.test.cjs "Windows argv-overflow chunking (issue #3597)" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.prevention=a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)`
|
||||
|
||||
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom=a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with "Cannot find module" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT`
|
||||
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples=#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002`
|
||||
|
||||
@@ -74,7 +74,6 @@ Replace all three with a single coherent mechanism: **AST-based ESLint rules in
|
||||
| `no-hardcoded-tmp` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G4) | tests |
|
||||
| `no-bare-npm-exec` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G5) | tests |
|
||||
| `require-userprofile-with-home` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G6) | tests |
|
||||
| `no-oversized-test-argv` | `DEFECT.WINDOWS-ARGV-OVERFLOW` | tests |
|
||||
| `normalize-path-in-content` | `DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` (`RULESET.CONTENT-PATH-NORMALIZATION`) | `src/**/*.cts` |
|
||||
| `require-fs-op-fallback` | `DEFECT.WINDOWS-FS-OPS` | `src/**/*.cts`, build/install |
|
||||
|
||||
@@ -85,11 +84,13 @@ fence-match shape — covered by `no-crlf-fragile-split`; and (b) feeding a Wind
|
||||
path into a Git Bash glob / `bash -c` — covered jointly by `no-hardcoded-tmp` (steer tmp usage)
|
||||
and `no-unguarded-nonportable-exec` (require a platform guard on `bash -c`). The residual runtime
|
||||
Git-Bash path-translation behavior is not fully statically decidable; the rules catch the source
|
||||
shapes that produce it, not the runtime outcome. `DEFECT.WINDOWS-ARGV-OVERFLOW` (a test assembling an argv that exceeds the
|
||||
Windows command-length limit) is detected heuristically by `no-oversized-test-argv` — it flags
|
||||
very large `.repeat(N)`/concatenated literals passed to a `child_process` exec call; because the
|
||||
true limit is a runtime property, this rule is an over-approximation tripwire, documented as
|
||||
such, landing in Phase 3.
|
||||
shapes that produce it, not the runtime outcome. `DEFECT.WINDOWS-ARGV-OVERFLOW` is deliberately
|
||||
**not** in this catalog: it is a *runtime* argv-length property (the args-array size is not
|
||||
statically knowable — e.g. `execFileSync('node', [...N runtime paths])`), so no AST rule can
|
||||
soundly detect it. Phase 3 evaluated a `no-oversized-test-argv` heuristic and **dropped it as
|
||||
unsound** (it could only catch a contrived literal `.repeat(N)` command string, never the
|
||||
canonical array overflow). The class is addressed at the source: the production `run-tests.cjs`
|
||||
argv chunking under `RUN_TESTS_MAX_CMDLINE_CHARS`, with its anchor `tests/run-tests-harness.test.cjs`.
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -175,9 +176,11 @@ phasing (one rule at a time, each independently reviewed and shipped) and by the
|
||||
they are first needed.
|
||||
- **Phase 4** the G1–G6 rules + fix all grandfathered offenders + delete the ratchet test.
|
||||
- **Phase 5–6** production `normalize-path-in-content`, `require-fs-op-fallback`.
|
||||
- **Phase 7** teardown: delete the regex script + allowlist-ratchet usage + the opt-out convention;
|
||||
rewrite `CONTEXT.md` `DEFECT.WINDOWS-*` predicates to point at the rules; the forward
|
||||
architecture guide ("how to add a portability rule").
|
||||
- **Phase 7** teardown: delete the `windows-test-parity-guard` ratchet + `allowlist-ratchet` usage
|
||||
for these classes + sweep any residual `// windows-portability-ok:` comments; finalize the
|
||||
`CONTEXT.md` `DEFECT.WINDOWS-*` predicate rewrite; the forward architecture guide ("how to add a
|
||||
portability rule"). (The regex script `scripts/lint-windows-test-portability.cjs` was retired
|
||||
earlier — in Phase 3 — as its `no-unguarded-nonportable-exec` replacement landed.)
|
||||
|
||||
Each implementation phase runs the full engineering directive (rubber-duck → laws → architecture
|
||||
→ qa-test-architect → strict TDD via `RuleTester` → codex adversarial → Diátaxis → rebase+PR)
|
||||
|
||||
@@ -18,6 +18,7 @@ running outside ESLint, fails the build if you try). Legitimately platform-speci
|
||||
|---|---|---|
|
||||
| `local/no-path-literal-in-assert` | An `assert.equal`/`strictEqual`/`deepEqual`/`deepStrictEqual` or `expect(...).toBe`/`toEqual`/`toStrictEqual` where one operand is a **path-returning function call** and the other is a **hardcoded `/`-string literal** not normalized to POSIX. | `tests/**/*.test.cjs` |
|
||||
| `local/no-posix-mode-bit-assert` | An equality assertion comparing a file **`.mode`** (e.g. `statSync(p).mode & 0o777`) to an **octal literal** — Windows reports `0o666`/`0o444`, never the requested mode. | `tests/**/*.test.cjs` |
|
||||
| `local/no-unguarded-nonportable-exec` | A file that **both** sets a chmod exec-bit (`chmod`/`chmodSync` with `0oNNN & 0o111 !== 0`) **and** invokes `sh`/`bash` with a `-c` flag (`execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync`) without a Windows platform guard — Windows Git Bash ignores the exec bit for extension-less PATH-executed scripts. | `tests/**/*.test.cjs` |
|
||||
|
||||
(More rules land per the epic — see ADR-1703's catalog and [epic #1702](https://github.com/open-gsd/gsd-core/issues/1702).)
|
||||
|
||||
@@ -71,6 +72,34 @@ assert.match(hookCommand, /^node /); // behavioral assertion — runs everywhere
|
||||
|
||||
Prefer asserting the *behavior* (command shape, runnability) over the raw mode bit where you can.
|
||||
|
||||
## How-to — fix a `no-unguarded-nonportable-exec` violation
|
||||
|
||||
Why it fails on Windows: Windows Git Bash (msys2) does not honour Node's chmod exec bit for
|
||||
extension-less scripts that are invoked by searching PATH. A test that makes a fixture executable
|
||||
with `chmodSync(p, 0o755)` and then runs it with `execFileSync('bash', ['-c', '...'])` passes on
|
||||
macOS/Linux but fails only on the `windows-latest` CI lane (DEFECT.WINDOWS-TEST-PORTABILITY).
|
||||
|
||||
**Fix option A: gate the `sh`/`bash -c` invocation behind a platform check**
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
execFileSync('bash', ['-c', './fixture run']);
|
||||
|
||||
// ✅ platform-guarded
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
if (process.platform !== 'win32') {
|
||||
execFileSync('bash', ['-c', './fixture run']);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix option B: invoke the script with an explicit interpreter (no -c flag)**
|
||||
|
||||
```js
|
||||
// ✅ passes the script path directly — exec bit not needed
|
||||
execFileSync('sh', [fixturePath]);
|
||||
```
|
||||
|
||||
## Platform guards (the only "escape" — by structure, not annotation)
|
||||
|
||||
If an assertion is *genuinely* POSIX-only, gate it behind a Windows platform check the rule
|
||||
|
||||
258
eslint-rules/no-unguarded-nonportable-exec.cjs
Normal file
258
eslint-rules/no-unguarded-nonportable-exec.cjs
Normal file
@@ -0,0 +1,258 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-unguarded-nonportable-exec
|
||||
*
|
||||
* Flag test files that BOTH make a fixture executable via chmod (exec-bit set)
|
||||
* AND invoke it with `sh -c` / `bash -c` — without a Windows platform guard.
|
||||
*
|
||||
* ## Why
|
||||
*
|
||||
* Windows Git Bash (msys2) does not honour Node's chmod exec bit for
|
||||
* PATH-executing extension-less scripts. A test that (a) makes a fixture
|
||||
* executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass
|
||||
* on Mac/Linux but fail only in the CI `test (windows-latest, *)` /
|
||||
* `full test (windows-latest, *)` lanes, producing a hard-to-diagnose
|
||||
* false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY.
|
||||
*
|
||||
* ## What this enforces (Program-level co-occurrence)
|
||||
*
|
||||
* Within a single file, detects the combination:
|
||||
* - makesExecutable: any `chmod`/`chmodSync(path, 0oNNN)` call where the
|
||||
* octal 2nd arg has exec bits set (`0oNNN & 0o111 !== 0`)
|
||||
* - shellDashC: any `execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync`
|
||||
* call whose command arg is `sh`/`bash`/`/bin/sh`/`/bin/bash` with a `-c`
|
||||
* arg in array form — or a string literal arg containing `sh -c`/`bash -c`
|
||||
*
|
||||
* At Program:exit, reports each unguarded shellDashC node when the file also
|
||||
* contains a chmod-exec-bit call. Guarded means the node is inside an
|
||||
* `isWindowsExcludedNode` block (platform guard / early-return / hoisted isWindows).
|
||||
*
|
||||
* ## Remediation
|
||||
*
|
||||
* Gate the bare-command execution behind `if (process.platform !== 'win32')`,
|
||||
* or invoke via an explicit interpreter (`sh <path>` instead of `sh -c <path>`).
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs');
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow unguarded chmod exec-bit + sh/bash -c combinations in tests (fails on Windows Git Bash)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
nonportableExec:
|
||||
'chmod exec-bit + sh/bash -c without a Windows guard ' +
|
||||
'(DEFECT.WINDOWS-TEST-PORTABILITY): Windows Git Bash (msys2) ignores ' +
|
||||
"the exec bit for PATH-executed extension-less scripts. Gate the " +
|
||||
"execution on `if (process.platform !== 'win32')` or invoke via an " +
|
||||
'explicit interpreter `sh <path>` instead of `sh -c`.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
/**
|
||||
* Shell commands whose first argument is the shell name.
|
||||
* Matches execFileSync, spawnSync, spawn, exec, execSync.
|
||||
*/
|
||||
const SHELL_EXEC_FN_NAMES = new Set([
|
||||
'execFileSync',
|
||||
'spawnSync',
|
||||
'spawn',
|
||||
'exec',
|
||||
'execSync',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Bare shell names (possibly with /bin/ or /usr/bin/ prefix).
|
||||
* The path prefix is stripped when comparing.
|
||||
*/
|
||||
const SHELL_NAMES = new Set(['sh', 'bash']);
|
||||
|
||||
/**
|
||||
* Returns the string value of a node if it's a string literal, else null.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function stringValue(node) {
|
||||
if (node && node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `name` (possibly /bin/sh or /usr/bin/bash etc.) is sh/bash.
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isShellName(name) {
|
||||
// Strip /bin/ or /usr/bin/ prefix
|
||||
const bare = name.replace(/^(?:\/usr)?\/bin\//, '');
|
||||
return SHELL_NAMES.has(bare);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when this CallExpression is a `sh`/`bash -c` invocation in
|
||||
* array form:
|
||||
* execFileSync('bash', ['-c', ...])
|
||||
* spawnSync('/bin/sh', ['-c', ...])
|
||||
* etc.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isShellDashCArrayForm(node) {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
|
||||
// Callee must be one of our shell exec functions (possibly member expr)
|
||||
const callee = node.callee;
|
||||
let fnName = null;
|
||||
if (callee.type === 'Identifier') {
|
||||
fnName = callee.name;
|
||||
} else if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
fnName = callee.property.name;
|
||||
}
|
||||
if (!fnName || !SHELL_EXEC_FN_NAMES.has(fnName)) return false;
|
||||
|
||||
const args = node.arguments;
|
||||
if (!args || args.length < 2) return false;
|
||||
|
||||
// First arg: shell name
|
||||
const shellArg = stringValue(args[0]);
|
||||
if (!shellArg || !isShellName(shellArg)) return false;
|
||||
|
||||
// Second arg: must be an ArrayExpression containing '-c'
|
||||
const secondArg = args[1];
|
||||
if (!secondArg || secondArg.type !== 'ArrayExpression') return false;
|
||||
|
||||
// '-c' must be the FIRST element: sh/bash -c <cmd> → args = ['-c', <cmd>]
|
||||
// A script that happens to receive '-c' later (e.g. [fixturePath, '-c'])
|
||||
// is NOT a shell -c invocation.
|
||||
const firstEl = secondArg.elements[0];
|
||||
return stringValue(firstEl) === '-c';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when this CallExpression is a string-literal form containing
|
||||
* `sh -c` or `bash -c`:
|
||||
* exec('sh -c "run.sh"')
|
||||
* execSync('bash -c script')
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isShellDashCStringForm(node) {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
|
||||
const callee = node.callee;
|
||||
let fnName = null;
|
||||
if (callee.type === 'Identifier') {
|
||||
fnName = callee.name;
|
||||
} else if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
fnName = callee.property.name;
|
||||
}
|
||||
if (!fnName || !SHELL_EXEC_FN_NAMES.has(fnName)) return false;
|
||||
|
||||
const args = node.arguments;
|
||||
if (!args || args.length < 1) return false;
|
||||
|
||||
// First arg may be a string literal containing 'sh -c' or 'bash -c'
|
||||
const firstArg = stringValue(args[0]);
|
||||
if (!firstArg) return false;
|
||||
|
||||
// Anchor to the start of the command string (allowing leading whitespace and
|
||||
// an optional absolute-path prefix like /bin/ or /usr/bin/).
|
||||
// This prevents matching 'sh -c' embedded mid-string in data, e.g.
|
||||
// exec('printf "sh -c"') or exec('echo run sh -c later')
|
||||
return /^\s*(?:\/\S+\/)?(?:bash|sh)\s+-c\b/.test(firstArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the CallExpression is a chmod/chmodSync call whose
|
||||
* second arg is an octal literal with at least one exec bit set.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isChmodExecBit(node) {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
|
||||
const callee = node.callee;
|
||||
let fnName = null;
|
||||
if (callee.type === 'Identifier') {
|
||||
fnName = callee.name;
|
||||
} else if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
fnName = callee.property.name;
|
||||
}
|
||||
if (!fnName || (fnName !== 'chmod' && fnName !== 'chmodSync')) return false;
|
||||
|
||||
const args = node.arguments;
|
||||
if (!args || args.length < 2) return false;
|
||||
|
||||
const modeArg = args[1];
|
||||
if (!modeArg || modeArg.type !== 'Literal') return false;
|
||||
if (typeof modeArg.value !== 'number') return false;
|
||||
|
||||
// Check it's an octal literal (raw source starts with 0o or 0O)
|
||||
const raw = sourceCode.getText(modeArg);
|
||||
if (!raw.startsWith('0o') && !raw.startsWith('0O')) return false;
|
||||
|
||||
// Check exec bit is set
|
||||
return (modeArg.value & 0o111) !== 0;
|
||||
}
|
||||
|
||||
// ── Per-file state ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Whether the file contains at least one chmod exec-bit call. */
|
||||
let fileHasChmodExecBit = false;
|
||||
|
||||
/** Collection of sh/bash -c nodes found in this file. */
|
||||
const shellDashCNodes = [];
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (isChmodExecBit(node)) {
|
||||
fileHasChmodExecBit = true;
|
||||
}
|
||||
if (isShellDashCArrayForm(node) || isShellDashCStringForm(node)) {
|
||||
shellDashCNodes.push(node);
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!fileHasChmodExecBit) return;
|
||||
|
||||
for (const shellNode of shellDashCNodes) {
|
||||
if (!isWindowsExcludedNode(shellNode, sourceCode)) {
|
||||
context.report({ node: shellNode, messageId: 'nonportableExec' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
@@ -17,6 +17,7 @@ import noTautologicalAssert from './eslint-rules/no-tautological-assert.cjs';
|
||||
import noAdhocMarkdownParsing from './eslint-rules/no-adhoc-markdown-parsing.cjs';
|
||||
import noPathLiteralInAssert from './eslint-rules/no-path-literal-in-assert.cjs';
|
||||
import noPosixModeBitAssert from './eslint-rules/no-posix-mode-bit-assert.cjs';
|
||||
import noUnguardedNonportableExec from './eslint-rules/no-unguarded-nonportable-exec.cjs';
|
||||
|
||||
const localPlugin = {
|
||||
rules: {
|
||||
@@ -28,6 +29,7 @@ const localPlugin = {
|
||||
'no-adhoc-markdown-parsing': noAdhocMarkdownParsing,
|
||||
'no-path-literal-in-assert': noPathLiteralInAssert,
|
||||
'no-posix-mode-bit-assert': noPosixModeBitAssert,
|
||||
'no-unguarded-nonportable-exec': noUnguardedNonportableExec,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -273,6 +275,8 @@ export default tseslint.config(
|
||||
'local/no-path-literal-in-assert': 'error',
|
||||
// Ban POSIX mode-bit assertions compared to octal literals (fails on Windows)
|
||||
'local/no-posix-mode-bit-assert': 'error',
|
||||
// Ban unguarded chmod exec-bit + sh/bash -c combos (fails on Windows Git Bash)
|
||||
'local/no-unguarded-nonportable-exec': 'error',
|
||||
// Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
|
||||
@@ -919,7 +919,7 @@
|
||||
{
|
||||
"id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention",
|
||||
"klass": "DEFECT",
|
||||
"value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (lint-windows-test-portability) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit",
|
||||
"value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (local/no-unguarded-nonportable-exec now enforces this via ESLint) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit",
|
||||
"line": 735
|
||||
},
|
||||
{
|
||||
@@ -931,7 +931,7 @@
|
||||
{
|
||||
"id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect",
|
||||
"klass": "DEFECT",
|
||||
"value": "npm run lint:windows-test-portability (tripwire: flags tests combining chmod exec-bit with sh/bash -c and no platform guard); watch CI windows matrix green before declaring a PR done",
|
||||
"value": "local/no-unguarded-nonportable-exec ESLint rule (enforced in tests/**/*.test.cjs via npm run lint); flags tests combining chmod exec-bit with sh/bash -c and no platform guard; watch CI windows matrix green before declaring a PR done",
|
||||
"line": 727
|
||||
},
|
||||
{
|
||||
@@ -943,7 +943,7 @@
|
||||
{
|
||||
"id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward",
|
||||
"klass": "DEFECT",
|
||||
"value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh <path>) rather than relying on exec-bit; annotate // windows-portability-ok: <reason> when a bypass is intentional",
|
||||
"value": "gate platform-specific execution with if (process.platform !== 'win32') — there is no opt-out annotation; structure any platform-specific code behind this guard; normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh <path>) rather than relying on exec-bit; see local/no-unguarded-nonportable-exec ESLint rule and docs/how-to/windows-portability.md for details",
|
||||
"line": 728
|
||||
},
|
||||
{
|
||||
|
||||
@@ -94,9 +94,8 @@
|
||||
"pretest:coverage": "npm run build:lib && npm run lint:skill-deps",
|
||||
"lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-windows-test-portability.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs",
|
||||
"lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs",
|
||||
"lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
|
||||
"lint:windows-test-portability": "node scripts/lint-windows-test-portability.cjs",
|
||||
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
|
||||
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
||||
"lint:skill-deps": "node scripts/lint-skill-deps.cjs",
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* lint-windows-test-portability.cjs — flag tests that combine chmod exec-bit
|
||||
* with bare sh/bash -c without a platform guard.
|
||||
*
|
||||
* ## Why
|
||||
*
|
||||
* Windows Git Bash (msys2) does not honour Node's chmod exec bit for
|
||||
* PATH-executing extension-less scripts. A test that (a) makes a fixture
|
||||
* executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass
|
||||
* on Mac/Linux but fail only in the CI `test (windows-latest, *)` /
|
||||
* `full test (windows-latest, *)` lanes, producing a hard-to-diagnose
|
||||
* false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY.
|
||||
*
|
||||
* ## What this enforces
|
||||
*
|
||||
* For every file in tests/**\/*.test.cjs (recursive, excluding node_modules):
|
||||
* - makesExecutable: contains chmodSync?( with an exec-bit octal literal
|
||||
* - shellDashC: contains a sh/bash -c invocation (array form or string literal)
|
||||
* - guarded: contains a process.platform / os.platform() / win32 / isWindows guard
|
||||
* - optOut: contains the literal `windows-portability-ok`
|
||||
* VIOLATION = makesExecutable && shellDashC && !guarded && !optOut
|
||||
*
|
||||
* ## Remediation
|
||||
*
|
||||
* Gate the bare-command execution with `if (process.platform !== 'win32')`,
|
||||
* or invoke via an explicit interpreter (`sh <path>`), or annotate
|
||||
* `// windows-portability-ok: <reason>`.
|
||||
*
|
||||
* ## Export contract (for unit tests)
|
||||
*
|
||||
* When required as a module (`require.main !== module`) this file exports
|
||||
* `scanContent(source)` → { makesExecutable, shellDashC, guarded, optOut, violation }.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ─── Detection regexes ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Match chmod/chmodSync( calls with an octal mode literal whose exec bits are
|
||||
* set, e.g. `fs.chmodSync(p, 0o755)` or `chmod(file, 0o111)`.
|
||||
*/
|
||||
const CHMOD_RE = /chmod(?:Sync)?\s*\([^,;]+,\s*0o([0-7]{3})\b/g;
|
||||
|
||||
/**
|
||||
* Array form: execFileSync/spawnSync/exec* with 'sh' or 'bash' (optionally
|
||||
* prefixed) as the first arg and '-c' as an element of the args array.
|
||||
* e.g. execFileSync('bash', ['-c', ...]) or spawnSync('/bin/sh', ['-c', ...])
|
||||
*/
|
||||
const SHELL_ARRAY_RE =
|
||||
/(?:execFile(?:Sync)?|spawnSync|spawn|exec)\s*\(\s*['"`](?:\/(?:usr\/)?bin\/)?(?:bash|sh)['"`]\s*,\s*\[[^\]]*['"]-c['"]/;
|
||||
|
||||
/**
|
||||
* String-literal form: any string containing `bash -c` or `sh -c`.
|
||||
*/
|
||||
const SHELL_STRING_RE = /['"`][^'"`\n]*(?:bash|sh)\s+-c[^'"`\n]*['"`]/;
|
||||
|
||||
/** Platform guard presence. */
|
||||
const GUARD_RE = /process\.platform|os\.platform\s*\(|\bwin32\b|\bisWindows\b/;
|
||||
|
||||
/** Opt-out annotation. */
|
||||
const OPT_OUT_RE = /windows-portability-ok/;
|
||||
|
||||
// ─── Pure scanning function (exported for unit tests) ────────────────────────
|
||||
|
||||
/**
|
||||
* Scan a single file's source text and return detection flags.
|
||||
*
|
||||
* @param {string} source - The file contents as a string.
|
||||
* @returns {{ makesExecutable: boolean, shellDashC: boolean, guarded: boolean, optOut: boolean, violation: boolean }}
|
||||
*/
|
||||
function scanContent(source) {
|
||||
// Reset stateful regex before use.
|
||||
CHMOD_RE.lastIndex = 0;
|
||||
|
||||
let makesExecutable = false;
|
||||
let match;
|
||||
while ((match = CHMOD_RE.exec(source)) !== null) {
|
||||
const oct = match[1];
|
||||
if ((parseInt(oct, 8) & 0o111) !== 0) {
|
||||
makesExecutable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const shellDashC = SHELL_ARRAY_RE.test(source) || SHELL_STRING_RE.test(source);
|
||||
const guarded = GUARD_RE.test(source);
|
||||
const optOut = OPT_OUT_RE.test(source);
|
||||
const violation = makesExecutable && shellDashC && !guarded && !optOut;
|
||||
|
||||
return { makesExecutable, shellDashC, guarded, optOut, violation };
|
||||
}
|
||||
|
||||
// ─── Filesystem walker ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recursively collect all *.test.cjs files under `dir`, excluding node_modules.
|
||||
*
|
||||
* @param {string} dir
|
||||
* @param {string[]} [acc]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function collectTestFiles(dir, acc) {
|
||||
acc = acc || [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectTestFiles(full, acc);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.test.cjs')) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const TESTS_DIR = path.join(ROOT, 'tests');
|
||||
|
||||
const files = collectTestFiles(TESTS_DIR);
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
let source;
|
||||
try {
|
||||
source = fs.readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const { violation } = scanContent(source);
|
||||
if (violation) {
|
||||
const rel = path.relative(ROOT, file).replace(/\\/g, '/');
|
||||
violations.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
for (const rel of violations) {
|
||||
process.stderr.write(
|
||||
`${rel}: chmod-executable + sh/bash -c with no platform guard\n`,
|
||||
);
|
||||
}
|
||||
process.stderr.write(
|
||||
'\nWindows Git Bash does not honor Node\'s chmod exec bit for ' +
|
||||
'PATH-executing extension-less scripts ' +
|
||||
'(CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY). ' +
|
||||
'Gate the bare-command execution with ' +
|
||||
'`if (process.platform !== \'win32\')`, or invoke via an explicit ' +
|
||||
'interpreter (`sh <path>`), or annotate ' +
|
||||
'`// windows-portability-ok: <reason>`.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`ok lint-windows-test-portability: ${files.length} file(s) scanned, no violations`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Module boundary ─────────────────────────────────────────────────────────
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
} else {
|
||||
module.exports = { scanContent };
|
||||
}
|
||||
@@ -94,6 +94,10 @@ ALLOWLIST=(
|
||||
# real injection payloads to prove the validator rejects them. See
|
||||
# DEFECT.PROMPT-INJECTION-SCAN-COLLISION in CONTEXT.md.
|
||||
'tests/windsurf-conversion.test.cjs'
|
||||
# RuleTester fixtures for the local/no-unguarded-nonportable-exec ESLint rule
|
||||
# contain shell-exec command strings (exec("sh -c …"), execFileSync('bash',['-c',…]))
|
||||
# as test DATA the rule must lint — not attack vectors. ADR-1703 Phase 3 (#1720).
|
||||
'tests/no-unguarded-nonportable-exec.rule.test.cjs'
|
||||
)
|
||||
|
||||
is_allowlisted() {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// windows-portability-ok: fixture strings for the lint's own unit test, not real execution
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for scripts/lint-windows-test-portability.cjs
|
||||
*
|
||||
* Uses the exported `scanContent` pure function to avoid spawning real
|
||||
* subprocesses or touching the filesystem. This keeps the test portable and
|
||||
* prevents the lint from flagging itself (the opt-out comment above covers the
|
||||
* chmod/bash-c fixture strings below).
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { scanContent } = require('../scripts/lint-windows-test-portability.cjs');
|
||||
|
||||
describe('lint-windows-test-portability: scanContent', () => {
|
||||
test('(a) chmod 0o755 + bash -c with no guard => violation', () => {
|
||||
const src = `
|
||||
'use strict';
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
execFileSync('bash', ['-c', 'echo hi']);
|
||||
`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.makesExecutable, true, 'makesExecutable');
|
||||
assert.strictEqual(result.shellDashC, true, 'shellDashC');
|
||||
assert.strictEqual(result.guarded, false, 'guarded');
|
||||
assert.strictEqual(result.optOut, false, 'optOut');
|
||||
assert.strictEqual(result.violation, true, 'violation');
|
||||
});
|
||||
|
||||
test('(b) chmod 0o755 + bash -c + process.platform guard => no violation', () => {
|
||||
const src = `
|
||||
'use strict';
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
execFileSync('bash', ['-c', 'echo hi']);
|
||||
if (process.platform !== 'win32') { runIt(); }
|
||||
`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.makesExecutable, true, 'makesExecutable');
|
||||
assert.strictEqual(result.shellDashC, true, 'shellDashC');
|
||||
assert.strictEqual(result.guarded, true, 'guarded');
|
||||
assert.strictEqual(result.violation, false, 'violation');
|
||||
});
|
||||
|
||||
test('(c) chmod 0o644 (no exec bit) + bash -c => no violation', () => {
|
||||
const src = `
|
||||
'use strict';
|
||||
fs.chmodSync(fixture, 0o644);
|
||||
execFileSync('bash', ['-c', 'cat file']);
|
||||
`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.makesExecutable, false, 'makesExecutable');
|
||||
assert.strictEqual(result.shellDashC, true, 'shellDashC');
|
||||
assert.strictEqual(result.violation, false, 'violation');
|
||||
});
|
||||
|
||||
test('(d) chmod 0o755 + execFileSync(sh, [path]) with no -c => no violation', () => {
|
||||
const src = `
|
||||
'use strict';
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
execFileSync('sh', [fixturePath]);
|
||||
`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.makesExecutable, true, 'makesExecutable');
|
||||
assert.strictEqual(result.shellDashC, false, 'shellDashC');
|
||||
assert.strictEqual(result.violation, false, 'violation');
|
||||
});
|
||||
|
||||
test('(e) violation pattern + windows-portability-ok opt-out => no violation', () => {
|
||||
const src = `
|
||||
// windows-portability-ok: intentional cross-platform test
|
||||
'use strict';
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
execFileSync('bash', ['-c', 'run']);
|
||||
`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.makesExecutable, true, 'makesExecutable');
|
||||
assert.strictEqual(result.shellDashC, true, 'shellDashC');
|
||||
assert.strictEqual(result.optOut, true, 'optOut');
|
||||
assert.strictEqual(result.violation, false, 'violation');
|
||||
});
|
||||
|
||||
test('chmod 0o111 (pure exec bits) is detected as executable', () => {
|
||||
const src = `fs.chmodSync(f, 0o111); spawnSync('sh', ['-c', 'x']);`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.makesExecutable, true, 'makesExecutable');
|
||||
assert.strictEqual(result.shellDashC, true, 'shellDashC');
|
||||
assert.strictEqual(result.violation, true, 'violation');
|
||||
});
|
||||
|
||||
test('chmod 0o444 (read-only) is not executable', () => {
|
||||
const src = `fs.chmodSync(f, 0o444); execFileSync('bash', ['-c', 'x']);`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.makesExecutable, false, 'makesExecutable');
|
||||
assert.strictEqual(result.violation, false, 'violation');
|
||||
});
|
||||
|
||||
test('string-literal sh -c form is detected', () => {
|
||||
const src = `
|
||||
fs.chmodSync(f, 0o755);
|
||||
exec('sh -c "run.sh"');
|
||||
`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.shellDashC, true, 'shellDashC from string literal');
|
||||
assert.strictEqual(result.violation, true, 'violation');
|
||||
});
|
||||
|
||||
test('/bin/bash prefix in array form is detected', () => {
|
||||
const src = `
|
||||
fs.chmodSync(f, 0o755);
|
||||
execFileSync('/bin/bash', ['-c', 'run']);
|
||||
`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.shellDashC, true, 'shellDashC with /bin/bash prefix');
|
||||
assert.strictEqual(result.violation, true, 'violation');
|
||||
});
|
||||
|
||||
test('isWindows guard suppresses violation', () => {
|
||||
const src = `
|
||||
const isWindows = process.platform === 'win32';
|
||||
fs.chmodSync(f, 0o755);
|
||||
execFileSync('bash', ['-c', 'run']);
|
||||
`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.guarded, true, 'guarded via isWindows');
|
||||
assert.strictEqual(result.violation, false, 'violation');
|
||||
});
|
||||
|
||||
test('no chmod at all => no violation regardless of shell -c', () => {
|
||||
const src = `execFileSync('bash', ['-c', 'echo hi']);`;
|
||||
const result = scanContent(src);
|
||||
assert.strictEqual(result.makesExecutable, false, 'makesExecutable');
|
||||
assert.strictEqual(result.violation, false, 'violation');
|
||||
});
|
||||
});
|
||||
300
tests/no-unguarded-nonportable-exec.rule.test.cjs
Normal file
300
tests/no-unguarded-nonportable-exec.rule.test.cjs
Normal file
@@ -0,0 +1,300 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-unguarded-nonportable-exec.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/no-unguarded-nonportable-exec ESLint rule.
|
||||
*
|
||||
* Rule: at Program:exit, if the file contains any chmod call with an exec-bit
|
||||
* octal (0oNNN & 0o111 !== 0), report each sh/bash -c invocation
|
||||
* (execFileSync/spawnSync/spawn/exec/execSync) that is NOT inside a Windows
|
||||
* platform guard.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
|
||||
*
|
||||
* Test cases mirror those in the retired regex-based script
|
||||
* scripts/lint-windows-test-portability.cjs.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const rule = require('../eslint-rules/no-unguarded-nonportable-exec.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── module shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-unguarded-nonportable-exec rule module', () => {
|
||||
test('exports meta and create', () => {
|
||||
assert.strictEqual(typeof rule.meta, 'object');
|
||||
assert.strictEqual(typeof rule.create, 'function');
|
||||
assert.strictEqual(rule.meta.type, 'problem');
|
||||
assert.ok(rule.meta.messages.nonportableExec);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── INVALID cases (violation expected) ───────────────────────────────────────
|
||||
|
||||
describe('no-unguarded-nonportable-exec invalid cases', () => {
|
||||
test('invalid: chmod 0o755 + execFileSync bash -c with no guard', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
execFileSync('bash', ['-c', 'echo hi']);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'nonportableExec' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: chmod 0o111 (pure exec bits) + spawnSync sh -c', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(f, 0o111);
|
||||
spawnSync('sh', ['-c', 'x']);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'nonportableExec' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: string-form exec(sh -c) + chmod 0o755', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(f, 0o755);
|
||||
exec('sh -c "run.sh"');
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'nonportableExec' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: /bin/bash prefix in array form + chmod exec bit', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(f, 0o755);
|
||||
execFileSync('/bin/bash', ['-c', 'run']);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'nonportableExec' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VALID cases (no violation expected) ──────────────────────────────────────
|
||||
|
||||
describe('no-unguarded-nonportable-exec valid cases', () => {
|
||||
test('valid: chmod 0o755 + execFileSync bash -c with process.platform guard', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
if (process.platform !== 'win32') {
|
||||
execFileSync('bash', ['-c', 'echo hi']);
|
||||
}
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: chmod 0o644 (no exec bit) + bash -c does not trigger', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(fixture, 0o644);
|
||||
execFileSync('bash', ['-c', 'cat file']);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: chmod 0o755 + execFileSync(sh, [path]) no -c flag', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
execFileSync('sh', [fixturePath]);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: no chmod at all — bash -c alone is fine', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `execFileSync('bash', ['-c', 'echo hi']);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: chmod 0o444 (read-only, no exec bits) + bash -c', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(f, 0o444);
|
||||
execFileSync('bash', ['-c', 'x']);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: early-return windows guard before sh -c', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(f, 0o755);
|
||||
if (process.platform === 'win32') return;
|
||||
execFileSync('sh', ['-c', 'run']);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: hoisted isWindows guard', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const isWindows = process.platform === 'win32';
|
||||
fs.chmodSync(f, 0o755);
|
||||
if (!isWindows) {
|
||||
execFileSync('bash', ['-c', 'run']);
|
||||
}
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: chmod 0o755 + exec("finish -c something") — "sh" in "finish" is not a shell invocation (W1 word-boundary)', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
exec('finish -c something');
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: chmod 0o755 + exec("publish -c") — "sh" in "publish" is not a shell invocation (W1 word-boundary)', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
exec('publish -c');
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
// C1 fix: '-c' must be FIRST element; a script receiving '-c' as a later arg
|
||||
// is not a shell -c invocation.
|
||||
test('valid (C1): chmod 0o755 + execFileSync(sh, [fixturePath, "-c"]) — script arg, not shell -c', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(fixturePath, 0o755);
|
||||
execFileSync('sh', [fixturePath, '-c']);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
// C2 fix: 'sh -c' embedded mid-string in data (as arg to printf) must NOT flag.
|
||||
test('valid (C2): chmod 0o755 + exec("printf \\"sh -c\\"") — sh -c as data, not a shell invocation', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
exec('printf "sh -c"');
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
// C2 fix: 'sh -c' embedded after other words must NOT flag.
|
||||
test('valid (C2): chmod 0o755 + exec("echo run sh -c later") — sh -c mid-string as data', () => {
|
||||
ruleTester.run('no-unguarded-nonportable-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
exec('echo run sh -c later');
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ const { globSync } = require('glob');
|
||||
const PROTECTED_RULES = [
|
||||
'no-path-literal-in-assert',
|
||||
'no-posix-mode-bit-assert',
|
||||
// Future phases: add new local/ portability rules here.
|
||||
'no-unguarded-nonportable-exec',
|
||||
];
|
||||
|
||||
// ── Detect disable directives via the comment text ───────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user