mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
Merge reviewed Git environment scope correction for PR2965
Source-PR: https://github.com/affaan-m/ECC/pull/2965
Source-Head: dee884656e
This commit is contained in:
@@ -173,8 +173,8 @@ function isGitExecutable(value) {
|
||||
return name === 'git' || name === 'git.exe';
|
||||
}
|
||||
|
||||
// Only explicit command-scoped assignments are tracked. No host environment,
|
||||
// exported shell state, arbitrary expansion or external configuration is read.
|
||||
// Only literal values from this supplied shell task are tracked. No host
|
||||
// environment, arbitrary expansion or external configuration is read.
|
||||
function gitEnvironmentOverride(environment, budget) {
|
||||
const count = environment.get('GIT_CONFIG_COUNT') || '';
|
||||
budget.spend(count.length + environment.size + 1);
|
||||
@@ -246,17 +246,50 @@ function checkGitWords(words, budget, start = 0, environmentOverride = false) {
|
||||
|
||||
// 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()) {
|
||||
function executableWords(words, budget, inherited = new Map(), callerValues = inherited) {
|
||||
budget.spend(inherited.size + 1);
|
||||
const environment = new Map(inherited);
|
||||
const environments = [new Map(inherited)];
|
||||
const prefixAssignments = new Map();
|
||||
let local = true;
|
||||
let assignmentOnly = true;
|
||||
const dynamicAssignments = new Set();
|
||||
function result(values) { return { words: values, environments, prefixAssignments, local, assignmentOnly, dynamicAssignments }; }
|
||||
function suffix(start) {
|
||||
budget.spend(words.length - start);
|
||||
return { words: words.slice(start), environment };
|
||||
assignmentOnly = false;
|
||||
return result(words.slice(start));
|
||||
}
|
||||
function assignment(value) {
|
||||
function assignment(token) {
|
||||
const { value, dynamic } = token;
|
||||
const equals = value.indexOf('=');
|
||||
const key = value.slice(0, equals);
|
||||
if (/^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/.test(key)) environment.set(key, value.slice(equals + 1));
|
||||
if (/^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/.test(key)) {
|
||||
const assigned = value.slice(equals + 1);
|
||||
const count = environments.length;
|
||||
budget.spend(count + 1);
|
||||
for (let n = 0; n < count; n++) {
|
||||
const environment = environments[n];
|
||||
// Expansion precedes env's reset. Caller values remain separate from
|
||||
// the child environment; keep both that possible value and the new
|
||||
// literal spelling without interpreting expansion syntax.
|
||||
if (dynamic && callerValues.has(key)) {
|
||||
budget.spend(environment.size + 1);
|
||||
const prior = new Map(environment);
|
||||
prior.set(key, callerValues.get(key));
|
||||
environments.push(prior);
|
||||
}
|
||||
environment.set(key, assigned);
|
||||
}
|
||||
prefixAssignments.set(key, assigned);
|
||||
if (dynamic) dynamicAssignments.add(key);
|
||||
}
|
||||
}
|
||||
function resetEnvironment(name) {
|
||||
budget.spend(environments.length + 1);
|
||||
for (const environment of environments) {
|
||||
if (name === undefined) environment.clear();
|
||||
else environment.delete(name);
|
||||
}
|
||||
}
|
||||
let i = 0;
|
||||
let assignments = true;
|
||||
@@ -264,21 +297,25 @@ function executableWords(words, budget, inherited = new Map()) {
|
||||
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.value); i++; continue; }
|
||||
if (assignments && /^[A-Za-z_][A-Za-z0-9_]*=/.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 { words: [], environment };
|
||||
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;
|
||||
@@ -286,11 +323,13 @@ function executableWords(words, budget, inherited = new Map()) {
|
||||
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')) environment.clear();
|
||||
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'])
|
||||
@@ -301,10 +340,10 @@ function executableWords(words, budget, inherited = new Map()) {
|
||||
const flag = words[i].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') { i++; break; }
|
||||
if (env && (flag === '-i' || flag === '--ignore-environment')) environment.clear();
|
||||
if (env && (flag === '-u' || flag === '--unset')) environment.delete(words[i + 1]?.value);
|
||||
else if (env && flag.startsWith('--unset=')) environment.delete(flag.slice('--unset='.length));
|
||||
else if (env && flag.startsWith('-u')) environment.delete(flag.slice(2));
|
||||
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.
|
||||
@@ -313,7 +352,7 @@ function executableWords(words, budget, inherited = new Map()) {
|
||||
}
|
||||
return suffix(i);
|
||||
}
|
||||
return { words: [], environment };
|
||||
return result([]);
|
||||
}
|
||||
|
||||
function shellRole(words, budget, shell) {
|
||||
@@ -405,13 +444,119 @@ 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) {
|
||||
budget.spend(2 * environment.size + 1);
|
||||
return { variables: new Map(environment), exported: new Set(environment.keys()), readonly: new Set() };
|
||||
}
|
||||
|
||||
function copyShellState(state, budget) {
|
||||
budget.spend(state.variables.size + state.exported.size + state.readonly.size + 1);
|
||||
return { variables: new Map(state.variables), exported: new Set(state.exported), readonly: new Set(state.readonly) };
|
||||
}
|
||||
|
||||
function copyShellContext(context, budget) {
|
||||
budget.spend(context.states.length + 1);
|
||||
return { states: context.states.map(state => copyShellState(state, budget)) };
|
||||
}
|
||||
|
||||
function exportedEnvironment(state, budget) {
|
||||
const environment = new Map();
|
||||
budget.spend(state.exported.size + 1);
|
||||
for (const name of state.exported) {
|
||||
if (state.variables.has(name)) environment.set(name, state.variables.get(name));
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
// Literal declaration operands are data, not executable source. A value and
|
||||
// its export attribute are separate: an assignment-only command does not start
|
||||
// exporting a previously local variable. No host shell state is consulted.
|
||||
function updateShellState(state, normalized, budget) {
|
||||
const { words, prefixAssignments, local, assignmentOnly, dynamicAssignments } = normalized;
|
||||
const states = [state];
|
||||
const result = (handled, changed, uncertain = false) => ({ handled, changed, uncertain, states });
|
||||
if (!local) return result(false, false);
|
||||
function assign(name, value, dynamic = false) {
|
||||
const count = states.length;
|
||||
budget.spend(count + 1);
|
||||
for (let n = 0; n < count; n++) {
|
||||
const current = states[n];
|
||||
if (current.readonly.has(name)) continue;
|
||||
// Preserve the known possible value AND the new literal spelling.
|
||||
// Both alternatives subsequently receive the declaration attributes.
|
||||
if (dynamic && current.variables.has(name)) states.push(copyShellState(current, budget));
|
||||
current.variables.set(name, value);
|
||||
}
|
||||
}
|
||||
if (assignmentOnly) {
|
||||
budget.spend(prefixAssignments.size + 1);
|
||||
for (const [name, value] of prefixAssignments) assign(name, value, dynamicAssignments.has(name));
|
||||
return result(true, prefixAssignments.size > 0, dynamicAssignments.size > 0);
|
||||
}
|
||||
// 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);
|
||||
let changed = false;
|
||||
budget.spend(prefixAssignments.size + 1);
|
||||
for (const [key, value] of prefixAssignments) { assign(key, value, dynamicAssignments.has(key)); changed = true; }
|
||||
for (; i < words.length; i++) {
|
||||
const value = words[i].value;
|
||||
budget.spend(2 * value.length + 1);
|
||||
const equals = value.indexOf('=');
|
||||
const key = equals < 0 ? value : value.slice(0, equals);
|
||||
if (!GIT_ENV_NAME.test(key)) continue;
|
||||
changed = true;
|
||||
if (name !== 'unset' && equals >= 0) assign(key, value.slice(equals + 1), words[i].dynamic);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
function checkCommand(input) {
|
||||
const budget = createBudget(input.length);
|
||||
const pending = [{ text: input, opaque: false, environment: new Map() }];
|
||||
function enqueue(text, opaque = false, environment = new Map()) {
|
||||
const pending = [{ text: input, opaque: false, context: { states: [shellState(new Map(), budget)] } }];
|
||||
function enqueue(text, opaque = false, context = { states: [shellState(new Map(), budget)] }) {
|
||||
if (!text) return;
|
||||
budget.spend(text.length + 1);
|
||||
pending.push({ text, opaque, environment });
|
||||
pending.push({ text, opaque, context });
|
||||
}
|
||||
function inspectOpaque(words, text, environment) {
|
||||
for (let index = 0; index < words.length; index++) {
|
||||
@@ -421,33 +566,112 @@ function checkCommand(input) {
|
||||
const reason = checkGitWords(words, budget, index, gitEnvironmentOverride(environment, budget));
|
||||
if (reason) return reason;
|
||||
}
|
||||
if (word.value !== text && /git/i.test(word.value) && /[\s'"()]/.test(word.value)) enqueue(word.value, true, environment);
|
||||
if (word.value !== text && /git/i.test(word.value) && /[\s'"()]/.test(word.value)) enqueue(word.value, true, { states: [shellState(environment, budget)] });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
while (pending.length) {
|
||||
const task = pending.pop();
|
||||
const scan = scanShell(task.text, budget);
|
||||
for (const text of scan.nested) enqueue(text, false, task.environment);
|
||||
for (const command of scan.commands) {
|
||||
const { words, environment } = executableWords(command.words, budget, task.environment);
|
||||
if (task.mergeInto) {
|
||||
budget.spend(task.context.states.length + 1);
|
||||
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);
|
||||
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) enqueue(role.code, false, environment);
|
||||
if (role.stdin) {
|
||||
for (const redirect of command.redirects) {
|
||||
if (redirect.operator === '<<<') enqueue(redirect.word.value, role.kind === 'opaque', environment);
|
||||
else if (redirect.operator === '<<' || redirect.operator === '<<-') enqueue(redirect.body, role.kind === 'opaque', environment);
|
||||
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 (command.pipeFrom) {
|
||||
for (const source of pipelineSources(command.pipeFrom, budget)) enqueue(source.text, source.opaque || role.kind === 'opaque', 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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof RangeError)) throw error;
|
||||
|
||||
@@ -247,13 +247,16 @@ function scanShell(input, budget) {
|
||||
const commands = [];
|
||||
const nested = [];
|
||||
const pendingHeredocs = [];
|
||||
let current = { words: [], redirects: [], pipeFrom: null };
|
||||
const rootScope = { parent: null, isolated: false, conditional: false };
|
||||
let scope = rootScope;
|
||||
const command = pipeFrom => ({ words: [], redirects: [], pipeFrom, nested: [], scope });
|
||||
let current = command(null);
|
||||
let word = null;
|
||||
let quote = null;
|
||||
let pendingRedirect = null;
|
||||
let i = 0;
|
||||
function begin() {
|
||||
if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true };
|
||||
if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true, dynamic: false };
|
||||
}
|
||||
function flushWord() {
|
||||
if (!word) return;
|
||||
@@ -262,24 +265,35 @@ function scanShell(input, budget) {
|
||||
if (pendingRedirect) {
|
||||
const redirect = { operator: pendingRedirect, word, body: '' };
|
||||
current.redirects.push(redirect);
|
||||
if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push(redirect);
|
||||
if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push({ redirect, owner: current });
|
||||
pendingRedirect = null;
|
||||
} else current.words.push(word);
|
||||
word = null;
|
||||
}
|
||||
function flushCommand(pipe = false) {
|
||||
function flushCommand(pipe = false, background = false) {
|
||||
flushWord();
|
||||
const previous = current;
|
||||
previous.pipeTo = pipe;
|
||||
previous.background = background;
|
||||
if (previous.closedScope && (pipe || background)) {
|
||||
previous.closedScope.isolated = true;
|
||||
previous.closedScope.pipelineLast = false;
|
||||
}
|
||||
const first = previous.words[0];
|
||||
if (first && !first.quoted && ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'].includes(first.value)) scope.conditional = true;
|
||||
if (previous.words.length || previous.redirects.length) commands.push(previous);
|
||||
current = { words: [], redirects: [], pipeFrom: pipe ? previous : null };
|
||||
current = command(pipe ? previous : null);
|
||||
pendingRedirect = null;
|
||||
}
|
||||
function consumeHeredocs() {
|
||||
for (const redirect of pendingHeredocs) {
|
||||
for (const { redirect, owner } of pendingHeredocs) {
|
||||
const region = heredocBody(input, i, redirect, budget);
|
||||
redirect.body = region.body;
|
||||
i = region.end;
|
||||
if (!redirect.word.quoted) nested.push(...scanExpansions(redirect.body, budget));
|
||||
if (!redirect.word.quoted) {
|
||||
const regions = scanExpansions(redirect.body, budget);
|
||||
for (const text of regions) { budget.spend(); nested.push(text); owner.nested.push(text); }
|
||||
}
|
||||
}
|
||||
pendingHeredocs.length = 0;
|
||||
}
|
||||
@@ -307,9 +321,14 @@ function scanShell(input, budget) {
|
||||
word.value += next; i += 2; continue;
|
||||
}
|
||||
if (hasExpansion(input, i, quote === null)) {
|
||||
begin(); word.literal = false;
|
||||
begin(); word.literal = false; word.dynamic = true;
|
||||
const region = executionRegion(input, i, budget);
|
||||
nested.push(region.text); word.value += '\u0000'; i = region.end; continue;
|
||||
nested.push(region.text); current.nested.push(region.text); word.value += '\u0000'; i = region.end; continue;
|
||||
}
|
||||
// Parameter expansions remain opaque values. Escaped/single/ANSI-C
|
||||
// quoted dollars have already been consumed as data above.
|
||||
if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(input[i + 1] || '')) {
|
||||
begin(); word.dynamic = true;
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (c === '"') quote = null;
|
||||
@@ -329,7 +348,29 @@ function scanShell(input, budget) {
|
||||
const braceKeyword = (c === '{' || c === '}') && !word && current.words.length === 0 && /[\s;&|]/.test(input[i + 1] || ' ');
|
||||
if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || braceKeyword) {
|
||||
const pipe = c === '|' && input[i + 1] !== '|';
|
||||
flushCommand(pipe);
|
||||
const background = c === '&' && input[i + 1] !== '&';
|
||||
if ((c === '&' || c === '|') && input[i + 1] === c) scope.conditional = true;
|
||||
const incomingPipe = Boolean(current.pipeFrom);
|
||||
if (c === '(') {
|
||||
flushWord();
|
||||
// A function definition does not execute its body. Function grammar
|
||||
// is unsupported: keep pre-definition alternatives instead of using
|
||||
// flattened body mutations to certify a later command as safe.
|
||||
if (current.words.length === 1 && !current.words[0].quoted &&
|
||||
/^[A-Za-z_][A-Za-z0-9_]*$/.test(current.words[0].value)) scope.conditional = true;
|
||||
}
|
||||
flushCommand(pipe, background);
|
||||
if (c === '(' || (braceKeyword && c === '{')) {
|
||||
scope = { parent: scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe };
|
||||
current.scope = scope;
|
||||
} else if ((c === ')' || (braceKeyword && c === '}')) && scope.parent) {
|
||||
const closedScope = scope;
|
||||
scope = scope.parent;
|
||||
current.scope = scope;
|
||||
current.closedScope = closedScope;
|
||||
// Ordered metadata only; there is no executable argv in this event.
|
||||
commands.push({ ...command(null), scopeExit: closedScope });
|
||||
}
|
||||
i += (c === '&' || c === '|') && input[i + 1] === c ? 2 : 1;
|
||||
if (c === '\n') consumeHeredocs();
|
||||
continue;
|
||||
@@ -340,7 +381,7 @@ function scanShell(input, budget) {
|
||||
if (word && /^\d+$/.test(word.value)) word = null;
|
||||
flushWord();
|
||||
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
|
||||
current.redirects.push(redirect); pendingHeredocs.push(redirect);
|
||||
current.redirects.push(redirect); pendingHeredocs.push({ redirect, owner: current });
|
||||
i = delimiter.end; pendingRedirect = null; continue;
|
||||
}
|
||||
}
|
||||
@@ -359,7 +400,7 @@ function scanShell(input, budget) {
|
||||
begin(); word.value += c; i++;
|
||||
}
|
||||
flushCommand();
|
||||
return { commands, nested };
|
||||
return { commands, nested, rootScope };
|
||||
}
|
||||
|
||||
module.exports = { createBudget, scanShell, scanExpansions };
|
||||
|
||||
@@ -545,6 +545,21 @@ function countedClassification(command, quota) {
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(`
|
||||
globalThis.copiedElements = 0;
|
||||
globalThis.copiedStateEntries = 0;
|
||||
const NativeMap = Map;
|
||||
const NativeSet = Set;
|
||||
globalThis.Map = class extends NativeMap {
|
||||
constructor(entries) {
|
||||
super();
|
||||
if (entries) for (const [key, value] of entries) { globalThis.copiedStateEntries++; super.set(key, value); }
|
||||
}
|
||||
};
|
||||
globalThis.Set = class extends NativeSet {
|
||||
constructor(entries) {
|
||||
super();
|
||||
if (entries) for (const value of entries) { globalThis.copiedStateEntries++; super.add(value); }
|
||||
}
|
||||
};
|
||||
const originalSlice = Array.prototype.slice;
|
||||
Array.prototype.slice = function(start = 0, end = this.length) {
|
||||
const a = start < 0 ? Math.max(0, this.length + start) : Math.min(this.length, start);
|
||||
@@ -586,7 +601,8 @@ function countedClassification(command, quota) {
|
||||
assert.strictEqual(name, './lib/shell-scan');
|
||||
return instrumentedLexer;
|
||||
});
|
||||
return { result: hookModule.exports.run(command), copiedElements: context.copiedElements, spent, valueReads };
|
||||
const result = hookModule.exports.run(command);
|
||||
return { result, copiedElements: context.copiedElements, copiedStateEntries: context.copiedStateEntries, spent, valueReads };
|
||||
}
|
||||
for (const n of [64, 128]) {
|
||||
if (test(`opaque Git candidates avoid quadratic suffix copies at ${n}`, () => {
|
||||
@@ -1215,6 +1231,607 @@ for (const [family, expected, command] of reviewFollowupCases) {
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
|
||||
// Literal shell-state propagation; witness text is never executed. Pipeline-last
|
||||
// and conditional state changes are conservative alternatives, not flow proofs.
|
||||
const stickyEnvironmentCases = Object.freeze([
|
||||
[
|
||||
"literal exported parameter",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit -m x"
|
||||
],
|
||||
[
|
||||
"exported ordinary Git control",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git status"
|
||||
],
|
||||
[
|
||||
"literal exported parameter",
|
||||
2,
|
||||
"declare -x GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit -m x"
|
||||
],
|
||||
[
|
||||
"exported ordinary Git control",
|
||||
0,
|
||||
"declare -x GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git status"
|
||||
],
|
||||
[
|
||||
"literal exported parameter",
|
||||
2,
|
||||
"typeset -x GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit -m x"
|
||||
],
|
||||
[
|
||||
"exported ordinary Git control",
|
||||
0,
|
||||
"typeset -x GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git status"
|
||||
],
|
||||
[
|
||||
"literal exported parameter",
|
||||
2,
|
||||
"declare -gx GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit -m x"
|
||||
],
|
||||
[
|
||||
"exported ordinary Git control",
|
||||
0,
|
||||
"declare -gx GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git status"
|
||||
],
|
||||
[
|
||||
"literal exported parameter",
|
||||
2,
|
||||
"typeset -gx GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit -m x"
|
||||
],
|
||||
[
|
||||
"exported ordinary Git control",
|
||||
0,
|
||||
"typeset -gx GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git status"
|
||||
],
|
||||
[
|
||||
"literal exported parameter",
|
||||
2,
|
||||
"export -- GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit -m x"
|
||||
],
|
||||
[
|
||||
"exported ordinary Git control",
|
||||
0,
|
||||
"export -- GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git status"
|
||||
],
|
||||
[
|
||||
"sticky export order",
|
||||
2,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; export GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"sticky export order",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS; GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"sticky export order",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"\ngit push"
|
||||
],
|
||||
[
|
||||
"sticky export order",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=''; GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git am patches"
|
||||
],
|
||||
[
|
||||
"sticky export order",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; readonly GIT_CONFIG_PARAMETERS; git merge main"
|
||||
],
|
||||
[
|
||||
"sticky export order",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; command git rebase main"
|
||||
],
|
||||
[
|
||||
"sticky export order",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -i git status; git commit"
|
||||
],
|
||||
[
|
||||
"sticky export order",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -u GIT_CONFIG_PARAMETERS git status; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"declare GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"typeset GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"readonly GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\" echo safe; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"env GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\" echo safe; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"command -v export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"export -p; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"declare -xp GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; unset GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; unset -v GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; export -n GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; declare +x GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -i git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -u GIT_CONFIG_PARAMETERS git commit"
|
||||
],
|
||||
[
|
||||
"literal variable/export boundary",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; exec -c git commit"
|
||||
],
|
||||
[
|
||||
"sticky count/key/value",
|
||||
2,
|
||||
"export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; git commit"
|
||||
],
|
||||
[
|
||||
"sticky count/key/value",
|
||||
2,
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0; git push"
|
||||
],
|
||||
[
|
||||
"sticky count/key/value",
|
||||
2,
|
||||
"export GIT_CONFIG_COUNT=1; export GIT_CONFIG_KEY_0=core.hooksPath; export GIT_CONFIG_VALUE_0=/dev/null; git commit"
|
||||
],
|
||||
[
|
||||
"sticky count/key/value",
|
||||
2,
|
||||
"export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0; GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; git commit"
|
||||
],
|
||||
[
|
||||
"count export controls",
|
||||
0,
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; git commit"
|
||||
],
|
||||
[
|
||||
"count export controls",
|
||||
0,
|
||||
"export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; GIT_CONFIG_COUNT=0; git commit"
|
||||
],
|
||||
[
|
||||
"count export controls",
|
||||
0,
|
||||
"export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; unset GIT_CONFIG_COUNT; git commit"
|
||||
],
|
||||
[
|
||||
"count export controls",
|
||||
0,
|
||||
"export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; export -n GIT_CONFIG_VALUE_0; git commit"
|
||||
],
|
||||
[
|
||||
"count export controls",
|
||||
0,
|
||||
"export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; git status"
|
||||
],
|
||||
[
|
||||
"nested scope inherits shell state",
|
||||
2,
|
||||
"(export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit)"
|
||||
],
|
||||
[
|
||||
"nested scope inherits shell state",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; (git commit)"
|
||||
],
|
||||
[
|
||||
"nested scope inherits shell state",
|
||||
2,
|
||||
"{ export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; }; git commit"
|
||||
],
|
||||
[
|
||||
"nested scope inherits shell state",
|
||||
2,
|
||||
"{ export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit; } | cat"
|
||||
],
|
||||
[
|
||||
"nested scope inherits shell state",
|
||||
2,
|
||||
"(export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit) | cat"
|
||||
],
|
||||
[
|
||||
"nested scope inherits shell state",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; printf \"%s\" \"$(git commit)\""
|
||||
],
|
||||
[
|
||||
"nested scope inherits shell state",
|
||||
2,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; printf \"%s\" \"$(export GIT_CONFIG_PARAMETERS; git commit)\""
|
||||
],
|
||||
[
|
||||
"nested scope inherits shell state",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; cat <<EOF\n$(git commit)\nEOF"
|
||||
],
|
||||
[
|
||||
"isolated/data scope control",
|
||||
0,
|
||||
"(export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"); git commit"
|
||||
],
|
||||
[
|
||||
"isolated/data scope control",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\" | cat; git commit"
|
||||
],
|
||||
[
|
||||
"isolated/data scope control",
|
||||
0,
|
||||
"{ export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; } | cat; git commit"
|
||||
],
|
||||
[
|
||||
"isolated/data scope control",
|
||||
0,
|
||||
"printf x | { export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; }; git status"
|
||||
],
|
||||
[
|
||||
"isolated/data scope control",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\" & git commit"
|
||||
],
|
||||
[
|
||||
"isolated/data scope control",
|
||||
0,
|
||||
"{ export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; } & git commit"
|
||||
],
|
||||
[
|
||||
"isolated/data scope control",
|
||||
0,
|
||||
"printf \"%s\" \"$(export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\")\"; git commit"
|
||||
],
|
||||
[
|
||||
"isolated/data scope control",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; cat <<'EOF'\ngit commit\nEOF"
|
||||
],
|
||||
[
|
||||
"isolated/data scope control",
|
||||
0,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; printf \"%s\" \"$(git commit)\""
|
||||
],
|
||||
[
|
||||
"eval keeps same shell variables",
|
||||
2,
|
||||
"eval \"export GIT_CONFIG_PARAMETERS=\\\"'core.hooksPath=/dev/null'\\\"\"; git commit"
|
||||
],
|
||||
[
|
||||
"eval keeps same shell variables",
|
||||
2,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; eval \"export GIT_CONFIG_PARAMETERS\"; git commit"
|
||||
],
|
||||
[
|
||||
"eval keeps same shell variables",
|
||||
2,
|
||||
"eval \"GIT_CONFIG_PARAMETERS=\\\"'core.hooksPath=/dev/null'\\\"\"; export GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"child shell exported state",
|
||||
2,
|
||||
"bash -c \"export GIT_CONFIG_PARAMETERS=\\\"'core.hooksPath=/dev/null'\\\"; git commit\""
|
||||
],
|
||||
[
|
||||
"child shell exported state",
|
||||
2,
|
||||
"bash -c \"GIT_CONFIG_PARAMETERS=\\\"'core.hooksPath=/dev/null'\\\"; export GIT_CONFIG_PARAMETERS; git push\""
|
||||
],
|
||||
[
|
||||
"child shell state never leaks",
|
||||
0,
|
||||
"bash -c \"export GIT_CONFIG_PARAMETERS=\\\"'core.hooksPath=/dev/null'\\\"\"; git commit"
|
||||
],
|
||||
[
|
||||
"unexported shell values not child environment",
|
||||
0,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; bash -c \"git commit\""
|
||||
],
|
||||
[
|
||||
"exported values reach child environment",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; bash -c \"git commit\""
|
||||
],
|
||||
[
|
||||
"conditional reset may not execute",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; false && unset GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"conditional export may execute",
|
||||
2,
|
||||
"true && export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"last pipeline builtin may run in parent",
|
||||
2,
|
||||
"printf x | export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"assignment-only malformed quoted name remains data",
|
||||
0,
|
||||
"\"GIT_CONFIG_PARAMETERS=\\\"'core.hooksPath=/dev/null'\\\"\"; git commit"
|
||||
],
|
||||
[
|
||||
"readonly value cannot be cleared",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; readonly GIT_CONFIG_PARAMETERS; GIT_CONFIG_PARAMETERS=; git commit"
|
||||
],
|
||||
[
|
||||
"readonly value cannot be unset",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; readonly GIT_CONFIG_PARAMETERS; unset GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"readonly exported declaration",
|
||||
2,
|
||||
"declare -rx GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=; git commit"
|
||||
],
|
||||
[
|
||||
"readonly exported value updates refused",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; readonly GIT_CONFIG_PARAMETERS; export GIT_CONFIG_PARAMETERS=; git commit"
|
||||
],
|
||||
[
|
||||
"readonly unexported variable stays local",
|
||||
0,
|
||||
"readonly GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"readonly does not freeze export attribute",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; readonly GIT_CONFIG_PARAMETERS; export -n GIT_CONFIG_PARAMETERS; git commit"
|
||||
],
|
||||
[
|
||||
"external export basename is not builtin",
|
||||
0,
|
||||
"/tmp/export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"external declare basename is not builtin",
|
||||
0,
|
||||
"/tmp/declare -x GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"external command basename is not builtin",
|
||||
0,
|
||||
"/tmp/command export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"external env cannot change parent attributes",
|
||||
0,
|
||||
"env export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"command builtin retains export semantics",
|
||||
2,
|
||||
"command export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"last pipeline brace may affect parent",
|
||||
2,
|
||||
"printf x | { export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; }; git commit"
|
||||
],
|
||||
[
|
||||
"last pipeline subshell stays isolated",
|
||||
0,
|
||||
"printf x | (export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"); git commit"
|
||||
],
|
||||
[
|
||||
"nonfinal pipeline brace stays isolated",
|
||||
0,
|
||||
"printf x | { export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; } | cat; git commit"
|
||||
],
|
||||
[
|
||||
"background pipeline brace stays isolated",
|
||||
0,
|
||||
"printf x | { export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; } & git commit"
|
||||
],
|
||||
[
|
||||
"same-shell brace can clear variable",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; { unset GIT_CONFIG_PARAMETERS; }; git commit"
|
||||
],
|
||||
[
|
||||
"subshell reset does not clear parent",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; (unset GIT_CONFIG_PARAMETERS); git commit"
|
||||
],
|
||||
[
|
||||
"substitution reset does not clear parent",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; printf \"%s\" \"$(unset GIT_CONFIG_PARAMETERS)\"; git commit"
|
||||
],
|
||||
[
|
||||
"prefixed eval receives literal environment",
|
||||
2,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\" eval \"git commit\""
|
||||
],
|
||||
[
|
||||
"prefixed eval persistence is host-mode uncertain",
|
||||
2,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\" eval true; git commit"
|
||||
],
|
||||
[
|
||||
"prefixed eval with safe Git control",
|
||||
0,
|
||||
"GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\" eval \"git status\""
|
||||
],
|
||||
[
|
||||
"exported environment applies to Git executable case",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; /usr/bin/GIT.exe commit"
|
||||
],
|
||||
[
|
||||
"exported non-hook setting is safe",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'color.ui=core.hooksPath=/dev/null'\"; git commit"
|
||||
],
|
||||
[
|
||||
"unsupported declaration attributes cannot prove reset",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; declare -i GIT_CONFIG_PARAMETERS=0; git commit"
|
||||
],
|
||||
[
|
||||
"conditional local clear cannot prove export reset",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; false && GIT_CONFIG_PARAMETERS=; git commit"
|
||||
],
|
||||
[
|
||||
"conditional readonly does not permit unsafe clear proof",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; true && readonly GIT_CONFIG_PARAMETERS; GIT_CONFIG_PARAMETERS=; git commit"
|
||||
],
|
||||
[
|
||||
"conditional eval reset may not execute",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; false && eval 'unset GIT_CONFIG_PARAMETERS'; git commit"
|
||||
],
|
||||
[
|
||||
"unconditional eval reset does execute",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; eval 'unset GIT_CONFIG_PARAMETERS'; git commit"
|
||||
],
|
||||
[
|
||||
"nested conditional eval reset may not execute",
|
||||
2,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; false && eval \"eval \\\"unset GIT_CONFIG_PARAMETERS\\\"\"; git commit"
|
||||
],
|
||||
[
|
||||
"conditional eval safe Git control",
|
||||
0,
|
||||
"export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; false && eval 'unset GIT_CONFIG_PARAMETERS'; git status"
|
||||
],
|
||||
["uninvoked function reset is not proof", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; f() { unset GIT_CONFIG_PARAMETERS; }; git commit"],
|
||||
["uninvoked function safe Git control", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; f() { unset GIT_CONFIG_PARAMETERS; }; git status"],
|
||||
["uninvoked function reset is not proof", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; function f { unset GIT_CONFIG_PARAMETERS; }; git commit"],
|
||||
["uninvoked function safe Git control", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; function f { unset GIT_CONFIG_PARAMETERS; }; git status"],
|
||||
["uninvoked function reset is not proof", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; function f() { unset GIT_CONFIG_PARAMETERS; }; git commit"],
|
||||
["uninvoked function safe Git control", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; function f() { unset GIT_CONFIG_PARAMETERS; }; git status"],
|
||||
["unresolved assignment retains prior unsafe alternative", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git commit"],
|
||||
["unresolved assignment safe Git control", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git status"],
|
||||
["unresolved assignment retains prior unsafe alternative", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=${GIT_CONFIG_PARAMETERS}; git commit"],
|
||||
["unresolved assignment safe Git control", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=${GIT_CONFIG_PARAMETERS}; git status"],
|
||||
["unresolved assignment retains prior unsafe alternative", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; export GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git commit"],
|
||||
["unresolved assignment safe Git control", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; export GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git status"],
|
||||
["unresolved assignment retains prior unsafe alternative", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; declare -x GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git commit"],
|
||||
["unresolved assignment safe Git control", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; declare -x GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git status"],
|
||||
["unresolved assignment retains prior unsafe alternative", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=$(printf x); git commit"],
|
||||
["unresolved assignment safe Git control", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=$(printf x); git status"],
|
||||
["unresolved assignment retains prior unsafe alternative", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=`printf x`; git commit"],
|
||||
["unresolved assignment safe Git control", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=`printf x`; git status"],
|
||||
["literal dollar reset remains data", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS='$GIT_CONFIG_PARAMETERS'; git commit"],
|
||||
["literal dollar reset remains data", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=\\$GIT_CONFIG_PARAMETERS; git commit"],
|
||||
["literal dollar reset remains data", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=\"\\$GIT_CONFIG_PARAMETERS\"; git commit"],
|
||||
["literal dollar reset remains data", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=$'$GIT_CONFIG_PARAMETERS'; git commit"],
|
||||
["unresolved exported count assignment retains bypass", 2, "export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; GIT_CONFIG_COUNT=$GIT_CONFIG_COUNT; git commit"],
|
||||
["unresolved command-prefix count cannot prove reset", 2, "export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null; GIT_CONFIG_COUNT=$GIT_CONFIG_COUNT git commit"],
|
||||
["unresolved command-prefix parameters cannot prove reset", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\" git commit"],
|
||||
["literal prefix dollar is data", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; GIT_CONFIG_PARAMETERS='$GIT_CONFIG_PARAMETERS' git commit"],
|
||||
["unresolved export retains known local value and export attribute", 2, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; export GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git commit"],
|
||||
["unresolved export safe Git control", 0, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; export GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git status"],
|
||||
["unresolved export retains known local value and export attribute", 2, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; declare -x GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git commit"],
|
||||
["unresolved export safe Git control", 0, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; declare -x GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git status"],
|
||||
["unresolved export retains known local value and export attribute", 2, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; typeset -xr GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git commit"],
|
||||
["unresolved export safe Git control", 0, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; typeset -xr GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\"; git status"],
|
||||
["new literal bypass is retained alongside old dynamic value", 2, "export GIT_CONFIG_PARAMETERS=''; export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'$GIT_CONFIG_PARAMETERS\"; git commit"],
|
||||
["new literal bypass is retained alongside old dynamic value", 2, "export GIT_CONFIG_PARAMETERS=''; GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'$GIT_CONFIG_PARAMETERS\"; git commit"],
|
||||
["new literal prefix bypass is retained", 2, "export GIT_CONFIG_PARAMETERS=''; GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'$GIT_CONFIG_PARAMETERS\" git commit"],
|
||||
["dynamic env operand expands in caller before child reset", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -i GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\" git commit"],
|
||||
["quoted env operand is data after child reset", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -i GIT_CONFIG_PARAMETERS='$GIT_CONFIG_PARAMETERS' git commit"],
|
||||
["plain child reset remains effective", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -i git commit"],
|
||||
["dynamic env operand expands in caller before child reset", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -u GIT_CONFIG_PARAMETERS GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\" git commit"],
|
||||
["quoted env operand is data after child reset", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -u GIT_CONFIG_PARAMETERS GIT_CONFIG_PARAMETERS='$GIT_CONFIG_PARAMETERS' git commit"],
|
||||
["plain child reset remains effective", 0, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -u GIT_CONFIG_PARAMETERS git commit"],
|
||||
["dynamic env operand expands in caller before child reset", 2, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -i GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\" git commit"],
|
||||
["quoted env operand is data after child reset", 0, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -i GIT_CONFIG_PARAMETERS='$GIT_CONFIG_PARAMETERS' git commit"],
|
||||
["plain child reset remains effective", 0, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -i git commit"],
|
||||
["dynamic env operand expands in caller before child reset", 2, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -u GIT_CONFIG_PARAMETERS GIT_CONFIG_PARAMETERS=\"$GIT_CONFIG_PARAMETERS\" git commit"],
|
||||
["quoted env operand is data after child reset", 0, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -u GIT_CONFIG_PARAMETERS GIT_CONFIG_PARAMETERS='$GIT_CONFIG_PARAMETERS' git commit"],
|
||||
["plain child reset remains effective", 0, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; env -u GIT_CONFIG_PARAMETERS git commit"],
|
||||
["export print flag with assignment still exports", 2, "export -p GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git commit"],
|
||||
["export print-only keeps local variable unexported", 0, "GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; export -p; git commit"],
|
||||
["readonly print flag assignment prevents later reset", 2, "export GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; readonly -p GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; unset GIT_CONFIG_PARAMETERS; git commit"],
|
||||
["readonly print flag safe Git control", 0, "readonly -p GIT_CONFIG_PARAMETERS=\"'core.hooksPath=/dev/null'\"; git status"]
|
||||
]);
|
||||
for (const [family, expected, command] of stickyEnvironmentCases) {
|
||||
if (test(`${family} ${expected}: ${JSON.stringify(command)}`, () => {
|
||||
const result = runHook(command);
|
||||
assert.strictEqual(result.code, expected, result.stderr);
|
||||
if (expected === 2) assert.match(result.stderr, /core\.hooksPath/, 'Must identify the literal override, not exhaust the budget');
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
for (const n of [8, 12]) {
|
||||
if (test(`conditional environment alternatives charge copies within a shared quota at ${n}`, () => {
|
||||
const command = 'export GIT_CONFIG_COUNT=0; ' + 'true && GIT_CONFIG_COUNT=0; '.repeat(n) + 'git status';
|
||||
const result = countedClassification(command, 3000);
|
||||
assert.strictEqual(result.result.exitCode, 2, JSON.stringify(result));
|
||||
assert.match(result.result.stderr, /work budget/);
|
||||
assert.ok(result.spent >= 3000 && result.spent < 3100, JSON.stringify(result));
|
||||
// Include fixed module-level Set construction as a constant allowance.
|
||||
assert.ok(result.copiedStateEntries <= result.spent + 128, JSON.stringify(result));
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
const pureOnly = process.argv.includes('--pure-only');
|
||||
if (pureOnly) console.log('Pure classifier mode: 3 bounded Node routing checks omitted.');
|
||||
else {
|
||||
|
||||
Reference in New Issue
Block a user