259 lines
8.7 KiB
JavaScript
259 lines
8.7 KiB
JavaScript
'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;
|