mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-20 23:11:46 +02:00
refactor(hooks): flatten block-no-verify decision tree
checkGitWords, executableWords, shellRole, updateShellState and checkCommand were each well over the complexity budget and checkCommand nested seven blocks deep. Split them into named helpers: git global option scanning and bypass flag search, per-wrapper handlers for command/exec/env/sudo, a shell option cluster classifier, declaration flag parsing, and a per-task/per-state/per-environment pipeline inside checkCommand. Every budget.spend call keeps its amount and order so the work quota tests still land in the same window. The module still only requires ./lib/shell-scan and exports run unchanged. Wrapper option sets moved to module constants instead of being rebuilt on every call.
This commit is contained in:
committed by
Affaan Mustafa
parent
5af4395e5c
commit
603e1dee1e
+403
-252
@@ -216,7 +216,23 @@ function gitEnvironmentOverride(environment, budget) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkGitWords(words, budget, start = 0, environmentOverride = false) {
|
||||
// True when a git global option at words[index] redirects hooks via config.
|
||||
// The separate-operand forms always charge the operand so budget accounting
|
||||
// does not depend on whether an earlier option already matched.
|
||||
function gitGlobalOptionRedirectsHooks(words, index, value, budget) {
|
||||
if (value === '-c' || value === '--config-env') {
|
||||
const setting = words[index + 1]?.value || '';
|
||||
budget.spend(setting.length + 1);
|
||||
return isHookRedirectSetting(setting);
|
||||
}
|
||||
if (value.startsWith('-c')) return isHookRedirectSetting(value.slice(2));
|
||||
if (value.startsWith('--config-env=')) return isHookRedirectSetting(value.slice('--config-env='.length));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk git's global options after the executable. Returns the index of the
|
||||
// subcommand word and whether any option redirected the hooks path.
|
||||
function scanGitGlobalOptions(words, budget, start, environmentOverride) {
|
||||
let index = start + 1;
|
||||
let override = environmentOverride;
|
||||
for (; index < words.length; index++) {
|
||||
@@ -224,20 +240,16 @@ function checkGitWords(words, budget, start = 0, environmentOverride = false) {
|
||||
budget.spend(value.length + 1);
|
||||
if (!value.startsWith('-')) break;
|
||||
if (value === '--') { index++; break; }
|
||||
if (value === '-c' || value === '--config-env') {
|
||||
const setting = words[index + 1]?.value || '';
|
||||
budget.spend(setting.length + 1);
|
||||
override ||= isHookRedirectSetting(setting);
|
||||
} else if (value.startsWith('-c')) override ||= isHookRedirectSetting(value.slice(2));
|
||||
else if (value.startsWith('--config-env=')) override ||= isHookRedirectSetting(value.slice('--config-env='.length));
|
||||
if (gitGlobalOptionRedirectsHooks(words, index, value, budget)) override = true;
|
||||
if (GIT_GLOBAL_VALUES.has(value)) index++;
|
||||
}
|
||||
const command = words[index]?.value;
|
||||
budget.spend((command?.length || 0) + 1);
|
||||
if (!PROTECTED_GIT_COMMANDS.has(command)) return null;
|
||||
if (override) return `BLOCKED: Overriding core.hooksPath is not allowed with git ${command}. Git hooks must not be bypassed.`;
|
||||
return { index, override };
|
||||
}
|
||||
|
||||
// Scan a protected subcommand's own arguments for a hook-bypass flag.
|
||||
function findGitBypassFlag(words, budget, start, command) {
|
||||
let skipNext = false;
|
||||
for (index++; index < words.length; index++) {
|
||||
for (let index = start; index < words.length; index++) {
|
||||
const value = words[index].value;
|
||||
budget.spend(value.length + 1);
|
||||
if (skipNext) { skipNext = false; continue; }
|
||||
@@ -246,9 +258,19 @@ function checkGitWords(words, budget, start = 0, environmentOverride = false) {
|
||||
if (commitOptionConsumesNextValue(value)) { skipNext = true; continue; }
|
||||
if (commitOptionContainsInlineValue(value)) continue;
|
||||
}
|
||||
if (isNoVerifyLongFlag(value) || (command === 'commit' && isCommitNoVerifyShortFlag(value))) {
|
||||
return `BLOCKED: --no-verify flag is not allowed with git ${command}. Git hooks must not be bypassed.`;
|
||||
}
|
||||
if (isNoVerifyLongFlag(value) || (command === 'commit' && isCommitNoVerifyShortFlag(value))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkGitWords(words, budget, start = 0, environmentOverride = false) {
|
||||
const { index, override } = scanGitGlobalOptions(words, budget, start, environmentOverride);
|
||||
const command = words[index]?.value;
|
||||
budget.spend((command?.length || 0) + 1);
|
||||
if (!PROTECTED_GIT_COMMANDS.has(command)) return null;
|
||||
if (override) return `BLOCKED: Overriding core.hooksPath is not allowed with git ${command}. Git hooks must not be bypassed.`;
|
||||
if (findGitBypassFlag(words, budget, index + 1, command)) {
|
||||
return `BLOCKED: --no-verify flag is not allowed with git ${command}. Git hooks must not be bypassed.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -284,15 +306,45 @@ function validateEnvSplitEscapes(payload, budget) {
|
||||
}
|
||||
}
|
||||
|
||||
const ENV_VALUE_OPTIONS = new Set(['-u', '--unset', '-C', '--chdir']);
|
||||
const ENV_FLAG_OPTIONS = new Set(['-i', '--ignore-environment', '-0', '--null']);
|
||||
const SUDO_VALUE_OPTIONS = new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '-T', '-R', '-D']);
|
||||
const SUDO_FLAG_OPTIONS = new Set(['-n', '-E', '-H', '-S', '-k', '-K', '-b']);
|
||||
const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/;
|
||||
const GIT_ENV_NAME = /^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/;
|
||||
|
||||
function isEnvSplitFlag(flag) {
|
||||
return flag === '-S' || flag === '--split-string'
|
||||
|| flag.startsWith('--split-string=') || flag.startsWith('-S');
|
||||
}
|
||||
|
||||
// A value-taking wrapper option with its operand attached (`-uNAME`, `--unset=NAME`).
|
||||
function hasAttachedOperand(flag, values) {
|
||||
return [...values].some(value => value.startsWith('--')
|
||||
? flag.startsWith(`${value}=`)
|
||||
: flag.startsWith(value) && flag.length > value.length);
|
||||
}
|
||||
|
||||
// Only literal word splitting is understood for env -S. Keep complex env
|
||||
// syntax opaque rather than pretending to execute it as a shell.
|
||||
function isLiteralWordList(scan) {
|
||||
const command = scan.commands[0];
|
||||
return scan.commands.length === 1 && !command.nested.length
|
||||
&& !command.redirects.length && !command.words.some(word => word.dynamic);
|
||||
}
|
||||
|
||||
// Only explicit option grammars remove wrapper operands. Unknown launchers are
|
||||
// opaque/conservative, never guessed from a name found among data arguments.
|
||||
function executableWords(words, budget, inherited = new Map(), callerValues = inherited) {
|
||||
budget.spend(inherited.size + 1);
|
||||
const environments = [new Map(inherited)];
|
||||
const prefixAssignments = new Map();
|
||||
const dynamicAssignments = new Set();
|
||||
let local = true;
|
||||
let assignmentOnly = true;
|
||||
const dynamicAssignments = new Set();
|
||||
let i = 0;
|
||||
let assignments = true;
|
||||
let environmentAssignments = false;
|
||||
function result(values) { return { words: values, environments, prefixAssignments, local, assignmentOnly, dynamicAssignments }; }
|
||||
function suffix(start) {
|
||||
budget.spend(words.length - start);
|
||||
@@ -304,7 +356,7 @@ function executableWords(words, budget, inherited = new Map(), callerValues = in
|
||||
const equals = value.indexOf('=');
|
||||
const append = !environmentAssignments && value[equals - 1] === '+';
|
||||
const key = value.slice(0, append ? equals - 1 : equals);
|
||||
if (/^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/.test(key)) {
|
||||
if (GIT_ENV_NAME.test(key)) {
|
||||
const operand = value.slice(equals + 1);
|
||||
const count = environments.length;
|
||||
budget.spend(count + 1);
|
||||
@@ -336,87 +388,139 @@ function executableWords(words, budget, inherited = new Map(), callerValues = in
|
||||
else environment.delete(name);
|
||||
}
|
||||
}
|
||||
let i = 0;
|
||||
let assignments = true;
|
||||
let environmentAssignments = false;
|
||||
// Wrapper handlers below return a final result to stop scanning, or
|
||||
// undefined once the wrapper's own options are consumed and the scan
|
||||
// should continue with the wrapped command.
|
||||
function commandBuiltin(token) {
|
||||
assignmentOnly = false;
|
||||
local &&= token.value === 'command';
|
||||
i++;
|
||||
while (words[i]?.value.startsWith('-')) {
|
||||
const flag = words[i++].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') break;
|
||||
if (/^-[pvV]+$/.test(flag) && /[vV]/.test(flag)) return result([]);
|
||||
if (!/^-p+$/.test(flag)) return suffix(i - 1);
|
||||
}
|
||||
assignments = false;
|
||||
return undefined;
|
||||
}
|
||||
function execBuiltin() {
|
||||
assignmentOnly = false;
|
||||
local = false;
|
||||
i++;
|
||||
while (words[i]?.value.startsWith('-')) {
|
||||
const flag = words[i++].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') break;
|
||||
if (/^-[cl]*a$/.test(flag)) i++;
|
||||
else if (!/^-([cl]*a.+|[cl]+)$/.test(flag)) return suffix(i - 1);
|
||||
if (flag.slice(1).split('a', 1)[0].includes('c')) resetEnvironment();
|
||||
}
|
||||
assignments = false;
|
||||
return undefined;
|
||||
}
|
||||
// env -S splits a literal payload into argv words in place; words[i] is
|
||||
// then re-examined as the first expanded word.
|
||||
function envSplitString(flag) {
|
||||
const separated = flag === '-S' || flag === '--split-string';
|
||||
const payload = separated ? words[i + 1]?.value || ''
|
||||
: flag.slice(flag.startsWith('--split-string=') ? '--split-string='.length : 2);
|
||||
budget.spend(payload.length + 1);
|
||||
validateEnvSplitEscapes(payload, budget);
|
||||
const scan = scanShell(payload, budget);
|
||||
if (!isLiteralWordList(scan)) return suffix(i - 1);
|
||||
// Pay for both slices and the expanded array before allocating them.
|
||||
budget.spend(2 * words.length + scan.commands[0].words.length);
|
||||
words = [...words.slice(0, i), ...scan.commands[0].words, ...words.slice(i + (separated ? 2 : 1))];
|
||||
return undefined;
|
||||
}
|
||||
function envResetFlag(flag) {
|
||||
if (flag === '-i' || flag === '--ignore-environment') resetEnvironment();
|
||||
if (flag === '-u' || flag === '--unset') resetEnvironment(words[i + 1]?.value || '');
|
||||
else if (flag.startsWith('--unset=')) resetEnvironment(flag.slice('--unset='.length));
|
||||
else if (flag.startsWith('-u')) resetEnvironment(flag.slice(2));
|
||||
}
|
||||
function envWrapper(name) {
|
||||
assignmentOnly = false;
|
||||
local = false;
|
||||
const env = name === 'env';
|
||||
const values = env ? ENV_VALUE_OPTIONS : SUDO_VALUE_OPTIONS;
|
||||
const flags = env ? ENV_FLAG_OPTIONS : SUDO_FLAG_OPTIONS;
|
||||
i++;
|
||||
while (words[i]?.value.startsWith('-')) {
|
||||
const flag = words[i].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') { i++; break; }
|
||||
if (env && isEnvSplitFlag(flag)) {
|
||||
const stop = envSplitString(flag);
|
||||
if (stop) return stop;
|
||||
continue;
|
||||
}
|
||||
if (env) envResetFlag(flag);
|
||||
if (values.has(flag)) i += 2;
|
||||
else if (flags.has(flag) || hasAttachedOperand(flag, values)) i++;
|
||||
else return suffix(i - 1); // Includes opaque env -S / sudo shell modes.
|
||||
}
|
||||
assignments = true;
|
||||
environmentAssignments = true;
|
||||
return undefined;
|
||||
}
|
||||
while (i < words.length) {
|
||||
const token = words[i];
|
||||
budget.spend(token.value.length + token.raw.length + 1);
|
||||
if (assignments && /^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(environmentAssignments ? token.value : token.raw)) { assignment(token); i++; continue; }
|
||||
if (assignments && SHELL_ASSIGNMENT.test(environmentAssignments ? token.value : token.raw)) { assignment(token); i++; continue; }
|
||||
if (!token.quoted && CONTROL_WORDS.has(token.value)) { i++; continue; }
|
||||
const name = basename(token.value);
|
||||
if (name === 'command') {
|
||||
assignmentOnly = false;
|
||||
local &&= token.value === 'command';
|
||||
i++;
|
||||
while (words[i]?.value.startsWith('-')) {
|
||||
const flag = words[i++].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') break;
|
||||
if (/^-[pvV]+$/.test(flag) && /[vV]/.test(flag)) return result([]);
|
||||
if (!/^-p+$/.test(flag)) return suffix(i - 1);
|
||||
}
|
||||
assignments = false; continue;
|
||||
}
|
||||
if (name === 'exec') {
|
||||
assignmentOnly = false;
|
||||
local = false;
|
||||
i++;
|
||||
while (words[i]?.value.startsWith('-')) {
|
||||
const flag = words[i++].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') break;
|
||||
if (/^-[cl]*a$/.test(flag)) i++;
|
||||
else if (!/^-([cl]*a.+|[cl]+)$/.test(flag)) return suffix(i - 1);
|
||||
if (flag.slice(1).split('a', 1)[0].includes('c')) resetEnvironment();
|
||||
}
|
||||
assignments = false; continue;
|
||||
}
|
||||
if (name === 'env' || name === 'sudo' || name === 'doas') {
|
||||
assignmentOnly = false;
|
||||
local = false;
|
||||
const env = name === 'env';
|
||||
const values = env
|
||||
? new Set(['-u', '--unset', '-C', '--chdir'])
|
||||
: new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '-T', '-R', '-D']);
|
||||
const flags = env ? new Set(['-i', '--ignore-environment', '-0', '--null']) : new Set(['-n', '-E', '-H', '-S', '-k', '-K', '-b']);
|
||||
i++;
|
||||
while (words[i]?.value.startsWith('-')) {
|
||||
const flag = words[i].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') { i++; break; }
|
||||
if (env && (flag === '-S' || flag === '--split-string'
|
||||
|| flag.startsWith('--split-string=') || flag.startsWith('-S'))) {
|
||||
const separated = flag === '-S' || flag === '--split-string';
|
||||
const payload = separated ? words[i + 1]?.value || ''
|
||||
: flag.slice(flag.startsWith('--split-string=') ? '--split-string='.length : 2);
|
||||
budget.spend(payload.length + 1);
|
||||
validateEnvSplitEscapes(payload, budget);
|
||||
const scan = scanShell(payload, budget);
|
||||
// Only literal word splitting is understood here. Keep complex env
|
||||
// syntax opaque rather than pretending to execute it as a shell.
|
||||
if (scan.commands.length !== 1 || scan.commands[0].nested.length
|
||||
|| scan.commands[0].redirects.length || scan.commands[0].words.some(word => word.dynamic)) return suffix(i - 1);
|
||||
// Pay for both slices and the expanded array before allocating them.
|
||||
budget.spend(2 * words.length + scan.commands[0].words.length);
|
||||
words = [...words.slice(0, i), ...scan.commands[0].words, ...words.slice(i + (separated ? 2 : 1))];
|
||||
continue;
|
||||
}
|
||||
if (env && (flag === '-i' || flag === '--ignore-environment')) resetEnvironment();
|
||||
if (env && (flag === '-u' || flag === '--unset')) resetEnvironment(words[i + 1]?.value || '');
|
||||
else if (env && flag.startsWith('--unset=')) resetEnvironment(flag.slice('--unset='.length));
|
||||
else if (env && flag.startsWith('-u')) resetEnvironment(flag.slice(2));
|
||||
if (values.has(flag)) i += 2;
|
||||
else if (flags.has(flag) || [...values].some(value => value.startsWith('--') ? flag.startsWith(`${value}=`) : flag.startsWith(value) && flag.length > value.length)) i++;
|
||||
else return suffix(i - 1); // Includes opaque env -S / sudo shell modes.
|
||||
}
|
||||
assignments = true; environmentAssignments = true; continue;
|
||||
}
|
||||
return suffix(i);
|
||||
let stop;
|
||||
if (name === 'command') stop = commandBuiltin(token);
|
||||
else if (name === 'exec') stop = execBuiltin();
|
||||
else if (name === 'env' || name === 'sudo' || name === 'doas') stop = envWrapper(name);
|
||||
else return suffix(i);
|
||||
if (stop) return stop;
|
||||
}
|
||||
return result([]);
|
||||
}
|
||||
|
||||
const SHELL_LONG_FLAGS = ['--noprofile', '--norc', '--posix', '--restricted', '--verbose', '--login'];
|
||||
const OPAQUE_SHELL = { kind: 'opaque', stdin: true };
|
||||
|
||||
// Classify one short shell flag inside an option cluster.
|
||||
function classifyShellFlag(flag, shell) {
|
||||
// Named-option arity is unproved for sh/dash/ksh: keep the invocation
|
||||
// opaque instead of consuming a code flag as a guessed option operand.
|
||||
if ((flag === 'o' || flag === 'O') && shell !== 'bash' && shell !== 'zsh') return 'opaque';
|
||||
if (flag === 'c') return 'code';
|
||||
if (flag === 's') return 'stdin';
|
||||
// zsh consumes the rest of this argv word as the option name, or one
|
||||
// separate word if no suffix exists, then ends this option cluster.
|
||||
if (shell === 'zsh' && flag === 'o') return 'zshOption';
|
||||
// These are not Bash's operand grammar; unmodeled zsh modes stay opaque.
|
||||
if (shell === 'zsh' && (flag === 'O' || flag === 'b')) return 'opaque';
|
||||
if (flag === 'o' || flag === 'O') return 'operand';
|
||||
return 'abefhiklmnprtuvxBCEHPTD'.includes(flag) ? 'plain' : 'opaque';
|
||||
}
|
||||
|
||||
// Bash accepts either sign and consumes a separate operand for each o/O
|
||||
// even inside a cluster. The command string follows ALL option processing,
|
||||
// not necessarily the argv word immediately after the first c flag.
|
||||
// Returns null when the cluster makes the invocation opaque.
|
||||
function scanShellOptionCluster(option, words, start, shell, budget) {
|
||||
const cluster = { next: start + 1, code: false, stdin: false };
|
||||
for (let j = 1; j < option.length; j++) {
|
||||
budget.spend();
|
||||
const kind = classifyShellFlag(option[j], shell);
|
||||
if (kind === 'opaque') return null;
|
||||
if (kind === 'code') cluster.code = true;
|
||||
else if (kind === 'stdin') cluster.stdin = true;
|
||||
else if (kind === 'zshOption') {
|
||||
if (j + 1 === option.length && cluster.next < words.length) cluster.next++;
|
||||
return cluster;
|
||||
} else if (kind === 'operand' && cluster.next < words.length) cluster.next++;
|
||||
}
|
||||
return cluster;
|
||||
}
|
||||
|
||||
function shellRole(words, budget, shell) {
|
||||
let i = 1;
|
||||
let stdin = false;
|
||||
@@ -428,33 +532,14 @@ function shellRole(words, budget, shell) {
|
||||
if (!/^[+-]/.test(option)) break;
|
||||
if (option === '--rcfile' || option === '--init-file') { i += 2; continue; }
|
||||
if (option.startsWith('--')) {
|
||||
if (!['--noprofile', '--norc', '--posix', '--restricted', '--verbose', '--login'].includes(option)) return { kind: 'opaque', stdin: true };
|
||||
if (!SHELL_LONG_FLAGS.includes(option)) return { ...OPAQUE_SHELL };
|
||||
i++; continue;
|
||||
}
|
||||
// Bash accepts either sign and consumes a separate operand for each o/O
|
||||
// even inside a cluster. The command string follows ALL option processing,
|
||||
// not necessarily the argv word immediately after the first c flag.
|
||||
let next = i + 1;
|
||||
for (let j = 1; j < option.length; j++) {
|
||||
budget.spend();
|
||||
const flag = option[j];
|
||||
// Named-option arity is unproved for sh/dash/ksh: keep the invocation
|
||||
// opaque instead of consuming a code flag as a guessed option operand.
|
||||
if ((flag === 'o' || flag === 'O') && shell !== 'bash' && shell !== 'zsh') return { kind: 'opaque', stdin: true };
|
||||
if (flag === 'c') code = true;
|
||||
else if (flag === 's') stdin = true;
|
||||
else if (shell === 'zsh' && flag === 'o') {
|
||||
// zsh consumes the rest of this argv word as the option name, or one
|
||||
// separate word if no suffix exists, then ends this option cluster.
|
||||
if (j + 1 === option.length && next < words.length) next++;
|
||||
break;
|
||||
} else if (shell === 'zsh' && (flag === 'O' || flag === 'b')) {
|
||||
// These are not Bash's operand grammar; unmodeled zsh modes stay opaque.
|
||||
return { kind: 'opaque', stdin: true };
|
||||
} else if (flag === 'o' || flag === 'O') { if (next < words.length) next++; }
|
||||
else if (!'abefhiklmnprtuvxBCEHPTD'.includes(flag)) return { kind: 'opaque', stdin: true };
|
||||
}
|
||||
i = next;
|
||||
const cluster = scanShellOptionCluster(option, words, i, shell, budget);
|
||||
if (!cluster) return { ...OPAQUE_SHELL };
|
||||
code ||= cluster.code;
|
||||
stdin ||= cluster.stdin;
|
||||
i = cluster.next;
|
||||
}
|
||||
if (code) return { kind: 'shell', code: words[i]?.value, stdin: false };
|
||||
// A script filename and its positional arguments are not shell source text.
|
||||
@@ -761,7 +846,6 @@ function pipelineSources(command, budget) {
|
||||
return sources;
|
||||
}
|
||||
|
||||
const GIT_ENV_NAME = /^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/;
|
||||
const DECLARATIONS = new Set(['export', 'declare', 'typeset', 'readonly', 'unset']);
|
||||
|
||||
function shellState(environment, budget) {
|
||||
@@ -825,31 +909,11 @@ function updateShellState(state, normalized, budget) {
|
||||
// Exact builtin names only: /some/path/export is an external executable.
|
||||
const name = words[0]?.value;
|
||||
if (!DECLARATIONS.has(name)) return result(false, false);
|
||||
let exported = name === 'export' ? true : null;
|
||||
let readonly = name === 'readonly';
|
||||
let passive = false;
|
||||
let uncertain = dynamicAssignments.size > 0;
|
||||
let i = 1;
|
||||
for (; i < words.length; i++) {
|
||||
const flag = words[i].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') { i++; break; }
|
||||
if (!/^[+-]/.test(flag)) break;
|
||||
if (name === 'export' && /^-[npf]+$/.test(flag)) {
|
||||
if (flag.includes('n')) exported = false;
|
||||
passive ||= flag.includes('f');
|
||||
} else if ((name === 'declare' || name === 'typeset') && /^[+-][xrgpf]+$/.test(flag)) {
|
||||
if (flag.includes('x')) exported = flag[0] === '-';
|
||||
if (flag[0] === '-' && flag.includes('r')) readonly = true;
|
||||
passive ||= /[pf]/.test(flag);
|
||||
} else if (name === 'readonly' && /^-[pf]+$/.test(flag)) passive ||= flag.includes('f');
|
||||
else if (name === 'unset' && /^-[vf]+$/.test(flag)) passive ||= flag.includes('f');
|
||||
else uncertain = true;
|
||||
}
|
||||
if (passive && !uncertain) return result(true, false);
|
||||
const declaration = parseDeclarationFlags(name, words, budget, dynamicAssignments.size > 0);
|
||||
if (declaration.passive && !declaration.uncertain) return result(true, false);
|
||||
let changed = prefixAssignments.size > 0;
|
||||
assignPrefixes();
|
||||
for (; i < words.length; i++) {
|
||||
for (let i = declaration.index; i < words.length; i++) {
|
||||
const value = words[i].value;
|
||||
budget.spend(2 * value.length + 1);
|
||||
const equals = value.indexOf('=');
|
||||
@@ -859,21 +923,65 @@ function updateShellState(state, normalized, budget) {
|
||||
changed = true;
|
||||
if (name !== 'unset' && equals >= 0) assign(key, value.slice(equals + 1), words[i].dynamic, append);
|
||||
budget.spend(states.length + 1);
|
||||
for (const current of states) {
|
||||
if (name === 'unset') {
|
||||
if (equals < 0 && !current.readonly.has(key)) {
|
||||
current.variables.delete(key); current.exported.delete(key);
|
||||
}
|
||||
} else {
|
||||
if (exported === true || uncertain) current.exported.add(key);
|
||||
else if (exported === false) current.exported.delete(key);
|
||||
if (readonly || uncertain) current.readonly.add(key);
|
||||
}
|
||||
}
|
||||
for (const current of states) applyDeclaration(current, key, equals, declaration);
|
||||
}
|
||||
// Unsupported attributes may transform values or reject the declaration.
|
||||
// Retain old and conservative literal states; never use them to prove reset.
|
||||
return result(true, changed, uncertain);
|
||||
return result(true, changed, declaration.uncertain);
|
||||
}
|
||||
|
||||
// Fold one declaration builtin flag into the parsed attribute set.
|
||||
function applyDeclarationFlag(declaration, flag) {
|
||||
const { name } = declaration;
|
||||
if (name === 'export' && /^-[npf]+$/.test(flag)) {
|
||||
if (flag.includes('n')) declaration.exported = false;
|
||||
declaration.passive ||= flag.includes('f');
|
||||
return;
|
||||
}
|
||||
if ((name === 'declare' || name === 'typeset') && /^[+-][xrgpf]+$/.test(flag)) {
|
||||
if (flag.includes('x')) declaration.exported = flag[0] === '-';
|
||||
if (flag[0] === '-' && flag.includes('r')) declaration.readonly = true;
|
||||
declaration.passive ||= /[pf]/.test(flag);
|
||||
return;
|
||||
}
|
||||
if (name === 'readonly' && /^-[pf]+$/.test(flag)) { declaration.passive ||= flag.includes('f'); return; }
|
||||
if (name === 'unset' && /^-[vf]+$/.test(flag)) { declaration.passive ||= flag.includes('f'); return; }
|
||||
declaration.uncertain = true;
|
||||
}
|
||||
|
||||
// Parse the option words of export/declare/typeset/readonly/unset. Returns the
|
||||
// attributes they set and the index of the first operand.
|
||||
function parseDeclarationFlags(name, words, budget, uncertain) {
|
||||
const declaration = {
|
||||
name,
|
||||
exported: name === 'export' ? true : null,
|
||||
readonly: name === 'readonly',
|
||||
passive: false,
|
||||
uncertain,
|
||||
index: 1
|
||||
};
|
||||
for (; declaration.index < words.length; declaration.index++) {
|
||||
const flag = words[declaration.index].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') { declaration.index++; break; }
|
||||
if (!/^[+-]/.test(flag)) break;
|
||||
applyDeclarationFlag(declaration, flag);
|
||||
}
|
||||
return declaration;
|
||||
}
|
||||
|
||||
// Apply a parsed declaration of `key` to one literal shell state.
|
||||
function applyDeclaration(current, key, equals, declaration) {
|
||||
const { name, exported, readonly, uncertain } = declaration;
|
||||
if (name === 'unset') {
|
||||
if (equals < 0 && !current.readonly.has(key)) {
|
||||
current.variables.delete(key); current.exported.delete(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (exported === true || uncertain) current.exported.add(key);
|
||||
else if (exported === false) current.exported.delete(key);
|
||||
if (readonly || uncertain) current.readonly.add(key);
|
||||
}
|
||||
|
||||
function checkCommand(input) {
|
||||
@@ -896,6 +1004,148 @@ function checkCommand(input) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Split a text task into one pending task per scanned command.
|
||||
function expandText(task) {
|
||||
const scan = scanShell(task.text, budget);
|
||||
const contexts = new Map([[scan.rootScope, task.context]]);
|
||||
budget.spend(scan.commands.length + 1);
|
||||
for (let i = scan.commands.length - 1; i >= 0; i--) pending.push({ ...task, command: scan.commands[i], contexts });
|
||||
}
|
||||
// A closing scope folds its states back into the enclosing context when the
|
||||
// scope ended a pipeline.
|
||||
function exitScope(command, contexts) {
|
||||
const closing = command.scopeExit;
|
||||
const exited = contexts.get(closing);
|
||||
const enclosing = contexts.get(closing.parent);
|
||||
if (closing.pipelineLast && exited && enclosing) {
|
||||
budget.spend(exited.states.length + 1);
|
||||
enclosing.states.push(...exited.states);
|
||||
}
|
||||
}
|
||||
// Materialize contexts for any scopes between the command and the nearest
|
||||
// known ancestor, then return the command's own scope context.
|
||||
function resolveScope(command, contexts) {
|
||||
const missing = [];
|
||||
for (let scope = command.scope; !contexts.has(scope); scope = scope.parent) { budget.spend(); missing.push(scope); }
|
||||
while (missing.length) {
|
||||
const scope = missing.pop();
|
||||
const parent = contexts.get(scope.parent);
|
||||
contexts.set(scope, scope.isolated ? copyShellContext(parent, budget) : parent);
|
||||
}
|
||||
return contexts.get(command.scope);
|
||||
}
|
||||
function isConditional(scope) {
|
||||
let conditional = false;
|
||||
for (let current = scope; current; current = current.parent) { budget.spend(); conditional ||= current.conditional; }
|
||||
return conditional;
|
||||
}
|
||||
// Stdin fed to a shell-like receiver (here-strings, heredocs, literal pipe
|
||||
// producers) is scanned as source. Opaque receivers keep their text opaque.
|
||||
function collectStdinSources(frame, role, environment) {
|
||||
const opaque = role.kind === 'opaque';
|
||||
for (const redirect of frame.command.redirects) {
|
||||
if (redirect.operator === '<<<') frame.childEnvironments.push({ code: redirect.word.value, opaque, environment });
|
||||
else if (redirect.operator === '<<' || redirect.operator === '<<-') frame.childEnvironments.push({ code: redirect.body, opaque, environment });
|
||||
}
|
||||
if (frame.command.pipeFrom) {
|
||||
for (const source of pipelineSources(frame.command.pipeFrom, budget)) frame.childEnvironments.push({ code: source.text, opaque: source.opaque || opaque, environment });
|
||||
}
|
||||
}
|
||||
// Inspect one command under one literal environment. Returns a block reason
|
||||
// or null, and records any child source text the command would execute.
|
||||
function inspectEnvironment(frame, role, normalized, environment) {
|
||||
const { task, command } = frame;
|
||||
const { words } = normalized;
|
||||
const reason = task.opaque || role.kind === 'opaque'
|
||||
? inspectOpaque(command.words, task.text, environment)
|
||||
: role.kind === 'git' ? checkGitWords(words, budget, 0, gitEnvironmentOverride(environment, budget)) : null;
|
||||
if (reason) return reason;
|
||||
if (role.code) {
|
||||
if (normalized.local && words[0]?.value === 'eval') frame.sameShellCode = role.code;
|
||||
else frame.childEnvironments.push({ code: role.code, opaque: false, environment });
|
||||
}
|
||||
if (role.kind === 'sedExec') {
|
||||
// Fragments sed will hand to the shell are scanned as real shell
|
||||
// source, not as opaque text — this closes the GNU sed `e` gap.
|
||||
for (const part of role.parts) {
|
||||
budget.spend(part.length + 1);
|
||||
frame.childEnvironments.push({ code: part, opaque: false, environment });
|
||||
}
|
||||
}
|
||||
if (role.stdin) collectStdinSources(frame, role, environment);
|
||||
return null;
|
||||
}
|
||||
// Advance one literal shell state through the command: apply declarations
|
||||
// and prefix assignments, then inspect the executable under each resulting
|
||||
// environment.
|
||||
function inspectState(frame, state) {
|
||||
const { command, conditional } = frame;
|
||||
const normalized = executableWords(command.words, budget, exportedEnvironment(state, budget), state.variables);
|
||||
const { words, environments } = normalized;
|
||||
const next = copyShellState(state, budget);
|
||||
const evalPrefix = normalized.local && words[0]?.value === 'eval' && normalized.prefixAssignments.size > 0;
|
||||
const mutation = updateShellState(next, evalPrefix ? { ...normalized, assignmentOnly: true } : normalized, budget);
|
||||
if (evalPrefix) {
|
||||
frame.alternatives.push(state);
|
||||
budget.spend(mutation.states.length * (normalized.prefixAssignments.size + 1));
|
||||
for (const variant of mutation.states) for (const name of normalized.prefixAssignments.keys()) variant.exported.add(name);
|
||||
}
|
||||
budget.spend(mutation.states.length + 1);
|
||||
frame.alternatives.push(...mutation.states);
|
||||
if (mutation.changed && (conditional || mutation.uncertain)) frame.alternatives.push(state);
|
||||
frame.changed ||= mutation.changed;
|
||||
if (mutation.handled && !evalPrefix) return null;
|
||||
const role = commandRole(words, budget);
|
||||
budget.spend(environments.length + 1);
|
||||
for (const environment of environments) {
|
||||
const reason = inspectEnvironment(frame, role, normalized, environment);
|
||||
if (reason) return reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Commit the command's resulting states and queue whatever it would run.
|
||||
function finishCommand(frame) {
|
||||
const { command, context, parent, conditional, sameShellCode, changed } = frame;
|
||||
context.states = frame.alternatives;
|
||||
// Bash lastpipe and zsh can execute a final pipeline builtin in the
|
||||
// parent shell. Preserve that possible state as well as isolation; this
|
||||
// is deliberately conservative when the host shell/options are unknown.
|
||||
if (command.pipeFrom && !command.pipeTo && !command.background && (changed || sameShellCode)) pending.push({ mergeInto: parent, context });
|
||||
for (const child of frame.childEnvironments) enqueue(child.code, child.opaque, { states: [shellState(child.environment, budget)] });
|
||||
if (sameShellCode) {
|
||||
// A conditional eval may not run. Its nested scans have fresh lexical
|
||||
// roots, so preserve the skipped branch across all delayed updates.
|
||||
const evaluated = conditional ? copyShellContext(context, budget) : context;
|
||||
if (conditional) pending.push({ mergeInto: context, context: evaluated });
|
||||
enqueue(sameShellCode, false, evaluated);
|
||||
}
|
||||
}
|
||||
// Process one scanned command task. Returns a block reason or null.
|
||||
function processCommand(task) {
|
||||
const { command, contexts } = task;
|
||||
if (command.scopeExit) { exitScope(command, contexts); return null; }
|
||||
const parent = resolveScope(command, contexts);
|
||||
const isolated = command.pipeFrom || command.pipeTo || command.background;
|
||||
const context = task.commandContext || (isolated ? copyShellContext(parent, budget) : parent);
|
||||
if (!task.nestedDone && command.nested.length) {
|
||||
pending.push({ ...task, nestedDone: true, commandContext: context });
|
||||
budget.spend(command.nested.length + 1);
|
||||
for (let i = command.nested.length - 1; i >= 0; i--) enqueue(command.nested[i], false, copyShellContext(context, budget));
|
||||
return null;
|
||||
}
|
||||
const frame = {
|
||||
task, command, context, parent,
|
||||
conditional: isConditional(command.scope),
|
||||
alternatives: [], childEnvironments: [], sameShellCode: null, changed: false
|
||||
};
|
||||
budget.spend(context.states.length + 1);
|
||||
for (const state of context.states) {
|
||||
const reason = inspectState(frame, state);
|
||||
if (reason) return reason;
|
||||
}
|
||||
finishCommand(frame);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
while (pending.length) {
|
||||
const task = pending.pop();
|
||||
@@ -904,108 +1154,9 @@ function checkCommand(input) {
|
||||
task.mergeInto.states.push(...task.context.states);
|
||||
continue;
|
||||
}
|
||||
if (!task.command) {
|
||||
const scan = scanShell(task.text, budget);
|
||||
const contexts = new Map([[scan.rootScope, task.context]]);
|
||||
budget.spend(scan.commands.length + 1);
|
||||
for (let i = scan.commands.length - 1; i >= 0; i--) pending.push({ ...task, command: scan.commands[i], contexts });
|
||||
continue;
|
||||
}
|
||||
const { command, contexts } = task;
|
||||
if (command.scopeExit) {
|
||||
const closing = command.scopeExit;
|
||||
const exited = contexts.get(closing);
|
||||
const enclosing = contexts.get(closing.parent);
|
||||
if (closing.pipelineLast && exited && enclosing) {
|
||||
budget.spend(exited.states.length + 1);
|
||||
enclosing.states.push(...exited.states);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const missing = [];
|
||||
for (let scope = command.scope; !contexts.has(scope); scope = scope.parent) { budget.spend(); missing.push(scope); }
|
||||
while (missing.length) {
|
||||
const scope = missing.pop();
|
||||
const parent = contexts.get(scope.parent);
|
||||
contexts.set(scope, scope.isolated ? copyShellContext(parent, budget) : parent);
|
||||
}
|
||||
const parent = contexts.get(command.scope);
|
||||
const isolated = command.pipeFrom || command.pipeTo || command.background;
|
||||
const context = task.commandContext || (isolated ? copyShellContext(parent, budget) : parent);
|
||||
if (!task.nestedDone && command.nested.length) {
|
||||
pending.push({ ...task, nestedDone: true, commandContext: context });
|
||||
budget.spend(command.nested.length + 1);
|
||||
for (let i = command.nested.length - 1; i >= 0; i--) enqueue(command.nested[i], false, copyShellContext(context, budget));
|
||||
continue;
|
||||
}
|
||||
let conditional = false;
|
||||
for (let scope = command.scope; scope; scope = scope.parent) { budget.spend(); conditional ||= scope.conditional; }
|
||||
const alternatives = [];
|
||||
const childEnvironments = [];
|
||||
let sameShellCode = null;
|
||||
let changed = false;
|
||||
budget.spend(context.states.length + 1);
|
||||
for (const state of context.states) {
|
||||
const normalized = executableWords(command.words, budget, exportedEnvironment(state, budget), state.variables);
|
||||
const { words, environments } = normalized;
|
||||
const next = copyShellState(state, budget);
|
||||
const evalPrefix = normalized.local && words[0]?.value === 'eval' && normalized.prefixAssignments.size > 0;
|
||||
const mutation = updateShellState(next, evalPrefix ? { ...normalized, assignmentOnly: true } : normalized, budget);
|
||||
if (evalPrefix) {
|
||||
alternatives.push(state);
|
||||
budget.spend(mutation.states.length * (normalized.prefixAssignments.size + 1));
|
||||
for (const variant of mutation.states) for (const name of normalized.prefixAssignments.keys()) variant.exported.add(name);
|
||||
}
|
||||
budget.spend(mutation.states.length + 1);
|
||||
alternatives.push(...mutation.states);
|
||||
if (mutation.changed && (conditional || mutation.uncertain)) alternatives.push(state);
|
||||
changed ||= mutation.changed;
|
||||
if (mutation.handled && !evalPrefix) continue;
|
||||
const role = commandRole(words, budget);
|
||||
budget.spend(environments.length + 1);
|
||||
for (const environment of environments) {
|
||||
const reason = task.opaque || role.kind === 'opaque'
|
||||
? inspectOpaque(command.words, task.text, environment)
|
||||
: role.kind === 'git' ? checkGitWords(words, budget, 0, gitEnvironmentOverride(environment, budget)) : null;
|
||||
if (reason) return { blocked: true, reason };
|
||||
if (role.code) {
|
||||
if (normalized.local && words[0]?.value === 'eval') {
|
||||
sameShellCode = role.code;
|
||||
}
|
||||
else childEnvironments.push({ code: role.code, opaque: false, environment });
|
||||
}
|
||||
if (role.kind === 'sedExec') {
|
||||
// Fragments sed will hand to the shell are scanned as real shell
|
||||
// source, not as opaque text — this closes the GNU sed `e` gap.
|
||||
for (const part of role.parts) {
|
||||
budget.spend(part.length + 1);
|
||||
childEnvironments.push({ code: part, opaque: false, environment });
|
||||
}
|
||||
}
|
||||
if (role.stdin) {
|
||||
for (const redirect of command.redirects) {
|
||||
if (redirect.operator === '<<<') childEnvironments.push({ code: redirect.word.value, opaque: role.kind === 'opaque', environment });
|
||||
else if (redirect.operator === '<<' || redirect.operator === '<<-') childEnvironments.push({ code: redirect.body, opaque: role.kind === 'opaque', environment });
|
||||
}
|
||||
if (command.pipeFrom) {
|
||||
for (const source of pipelineSources(command.pipeFrom, budget)) childEnvironments.push({ code: source.text, opaque: source.opaque || role.kind === 'opaque', environment });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
context.states = alternatives;
|
||||
// Bash lastpipe and zsh can execute a final pipeline builtin in the
|
||||
// parent shell. Preserve that possible state as well as isolation; this
|
||||
// is deliberately conservative when the host shell/options are unknown.
|
||||
if (command.pipeFrom && !command.pipeTo && !command.background && (changed || sameShellCode)) pending.push({ mergeInto: parent, context });
|
||||
for (const child of childEnvironments) enqueue(child.code, child.opaque, { states: [shellState(child.environment, budget)] });
|
||||
if (sameShellCode) {
|
||||
// A conditional eval may not run. Its nested scans have fresh lexical
|
||||
// roots, so preserve the skipped branch across all delayed updates.
|
||||
const evaluated = conditional ? copyShellContext(context, budget) : context;
|
||||
if (conditional) pending.push({ mergeInto: context, context: evaluated });
|
||||
enqueue(sameShellCode, false, evaluated);
|
||||
}
|
||||
if (!task.command) { expandText(task); continue; }
|
||||
const reason = processCommand(task);
|
||||
if (reason) return { blocked: true, reason };
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedEnvSplitEscape) {
|
||||
|
||||
Reference in New Issue
Block a user