feat(#1720): no-unguarded-nonportable-exec AST rule; retire the regex script (Phase 3) (#1723)

Phase 3 of epic #1702. Closes #1720.
This commit is contained in:
Tom Boucher
2026-06-25 16:25:48 -04:00
committed by GitHub
parent cf2e66b39e
commit c57e0d56c2
12 changed files with 616 additions and 332 deletions

View File

@@ -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');
});
});

View 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: [],
});
});
});

View File

@@ -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 ───────────────────────────