944 lines
41 KiB
JavaScript
944 lines
41 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import ts from "typescript";
|
|
import { literalBashHeredocBodyRanges } from "./bash-heredoc.mjs";
|
|
import { isMap, isScalar, isSeq, parseAllDocuments } from "yaml";
|
|
|
|
|
|
/**
|
|
* Revision-state absence policy by source dialect.
|
|
* JS/TS syntax uses the TypeScript parser and YAML structure uses the installed YAML parser.
|
|
* Shell active consumers are executable code/expansions and jq filter arguments for bare or
|
|
* path-qualified jq, optionally through command or env. Quoted heredoc bodies are literal.
|
|
* PowerShell analyzes executable code and nested $() in expandable strings. Python policy is
|
|
* batched through the isolated stdlib AST helper. jq filters use a bounded path lexer after
|
|
* shell argv/wrapper resolution. Offset-preserving transformations keep AST spans stable.
|
|
*/
|
|
const revisionIdentifiers = new Set(["revision", "workspaceRevision", "selectedWorkspace"]);
|
|
|
|
function unwrapExpression(node) {
|
|
let current = node;
|
|
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) ||
|
|
ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current) ||
|
|
ts.isSatisfiesExpression(current)) {
|
|
current = current.expression;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function isRevisionName(value, caseInsensitive) {
|
|
if (typeof value !== "string") return false;
|
|
if (!caseInsensitive) return revisionIdentifiers.has(value);
|
|
const lower = value.toLowerCase();
|
|
return lower === "revision" || lower === "workspacerevision" || lower === "selectedworkspace";
|
|
}
|
|
|
|
function isRevisionExpression(node, caseInsensitive = false) {
|
|
const unwrapped = unwrapExpression(node);
|
|
if (ts.isIdentifier(unwrapped)) {
|
|
const normalized = unwrapped.text.startsWith("$") && !unwrapped.text.startsWith("$$") ? unwrapped.text.slice(1) : unwrapped.text;
|
|
return isRevisionName(normalized, caseInsensitive);
|
|
}
|
|
if (ts.isPropertyAccessExpression(unwrapped)) return isRevisionName(unwrapped.name.text, caseInsensitive);
|
|
if (ts.isElementAccessExpression(unwrapped) && unwrapped.argumentExpression) {
|
|
return isRevisionName(staticStringValue(unwrapped.argumentExpression), caseInsensitive);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function staticStringValue(node) {
|
|
const expression = unwrapExpression(node);
|
|
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
|
|
if (ts.isTemplateExpression(expression)) {
|
|
let value = expression.head.text;
|
|
for (const span of expression.templateSpans) {
|
|
const part = staticStringValue(span.expression);
|
|
if (part === undefined) return undefined;
|
|
value += part + span.literal.text;
|
|
}
|
|
return value;
|
|
}
|
|
if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
const left = staticStringValue(expression.left);
|
|
const right = staticStringValue(expression.right);
|
|
return left === undefined || right === undefined ? undefined : left + right;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function propertyNameText(name, caseInsensitive = false) {
|
|
if (!name) return undefined;
|
|
let value;
|
|
if (ts.isComputedPropertyName(name)) value = staticStringValue(name.expression);
|
|
else if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name) || ts.isNumericLiteral(name)) value = name.text;
|
|
else value = staticStringValue(name);
|
|
return caseInsensitive && typeof value === "string" ? value.toLowerCase() : value;
|
|
}
|
|
|
|
function objectBindingHasState(pattern, caseInsensitive) {
|
|
return pattern.elements.some((element) => {
|
|
if (element.dotDotDotToken) return false;
|
|
return propertyNameText(element.propertyName ?? element.name, caseInsensitive) === "state";
|
|
});
|
|
}
|
|
|
|
function objectLiteralHasState(object, caseInsensitive) {
|
|
return object.properties.some((property) =>
|
|
!ts.isSpreadAssignment(property) && propertyNameText(property.name, caseInsensitive) === "state");
|
|
}
|
|
|
|
function scriptKindFor(path) {
|
|
const lower = path.toLowerCase();
|
|
if (lower.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
if (lower.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
if (/\.(?:ts|mts|cts)$/u.test(lower)) return ts.ScriptKind.TS;
|
|
if (/\.(?:js|mjs|cjs)$/u.test(lower)) return ts.ScriptKind.JS;
|
|
return undefined;
|
|
}
|
|
|
|
function maskRange(output, source, start, end, keepEnds = false) {
|
|
for (let cursor = start; cursor < end; cursor += 1) {
|
|
if (source[cursor] === "\n" || source[cursor] === "\r") continue;
|
|
if (keepEnds && (cursor === start || cursor === end - 1)) continue;
|
|
output[cursor] = " ";
|
|
}
|
|
}
|
|
|
|
function lineEnd(source, start) {
|
|
const end = source.indexOf("\n", start);
|
|
return end < 0 ? source.length : end;
|
|
}
|
|
|
|
function quotedEnd(source, start, delimiter, escapes = "\\") {
|
|
for (let cursor = start + delimiter.length; cursor < source.length; cursor += 1) {
|
|
if (escapes.includes(source[cursor])) {
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
if (source.startsWith(delimiter, cursor)) return cursor + delimiter.length;
|
|
}
|
|
return source.length;
|
|
}
|
|
|
|
function balancedEnd(source, openIndex, opener, closer, escapes = "\\`") {
|
|
let depth = 1;
|
|
for (let cursor = openIndex + 1; cursor < source.length; cursor += 1) {
|
|
if (escapes.includes(source[cursor])) {
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
if (source[cursor] === "'" || source[cursor] === '"' || source[cursor] === "`") {
|
|
cursor = quotedEnd(source, cursor, source[cursor], escapes) - 1;
|
|
continue;
|
|
}
|
|
if (source[cursor] === opener) depth += 1;
|
|
else if (source[cursor] === closer && --depth === 0) return cursor;
|
|
}
|
|
return source.length - 1;
|
|
}
|
|
|
|
function restoreMasked(output, offset, masked) {
|
|
for (let cursor = 0; cursor < masked.length; cursor += 1) output[offset + cursor] = masked[cursor];
|
|
}
|
|
|
|
function exposeDollarSubexpressions(output, source, start, end, dialect) {
|
|
for (let cursor = start; cursor + 1 < end; cursor += 1) {
|
|
if (!source.startsWith("$(", cursor) || source[cursor - 1] === "`") continue;
|
|
const close = balancedEnd(source, cursor + 1, "(", ")");
|
|
output[cursor] = " ";
|
|
output[cursor + 1] = "(";
|
|
restoreMasked(output, cursor + 2, dialect === "shell" ? maskShellSource(source.slice(cursor + 2, close)) : maskPowerShellSource(source.slice(cursor + 2, close)));
|
|
if (close < source.length) output[close] = ")";
|
|
cursor = close;
|
|
}
|
|
}
|
|
|
|
|
|
function shellCommentStart(source, index) {
|
|
return source[index] === "#" && (index === 0 || /[ \t\r\n;|&()]/u.test(source[index - 1]));
|
|
}
|
|
|
|
function canonicalRevisionName(name) {
|
|
const lower = name.toLowerCase();
|
|
if (lower === "revision") return "revision";
|
|
if (lower === "workspacerevision") return "workspaceRevision";
|
|
return "selectedWorkspace";
|
|
}
|
|
|
|
function normalizePowerShellVariables(source) {
|
|
const output = source.split("");
|
|
const patterns = [
|
|
{ expression: /\$\{(?:[A-Za-z_][A-Za-z0-9_]*:)?(revision|workspaceRevision|selectedWorkspace)\}/giu, dollar: false },
|
|
{ expression: /\$(?:[A-Za-z_][A-Za-z0-9_]*:)(revision|workspaceRevision|selectedWorkspace)\b/giu, dollar: false },
|
|
{ expression: /\$(revision|workspaceRevision|selectedWorkspace)\b/giu, dollar: true },
|
|
];
|
|
for (const { expression, dollar } of patterns) {
|
|
for (const match of source.matchAll(expression)) {
|
|
const name = canonicalRevisionName(match[1]);
|
|
const replacement = `${dollar ? "$" : ""}${name}`.padEnd(match[0].length, " ");
|
|
for (let offset = 0; offset < match[0].length; offset += 1) output[match.index + offset] = replacement[offset];
|
|
}
|
|
}
|
|
let normalized = output.join("");
|
|
normalized = normalized.replace(/\.\s*state\b/giu, (match) => match.replace(/state/iu, "state"));
|
|
normalized = normalized.replace(/(["'])state\1/giu, (_match, quote) => `${quote}state${quote}`);
|
|
return normalized;
|
|
}
|
|
|
|
function maskShellSource(source) {
|
|
return maskShellFamilySource(source, false);
|
|
}
|
|
|
|
function maskPowerShellSource(source) {
|
|
return normalizePowerShellVariables(maskShellFamilySource(source, true));
|
|
}
|
|
|
|
function maskShellFamilySource(source, powershell) {
|
|
const output = source.split("");
|
|
let squareDepth = 0;
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
if (powershell && source.startsWith("<#", index)) {
|
|
const close = source.indexOf("#>", index + 2);
|
|
const end = close < 0 ? source.length : close + 2;
|
|
maskRange(output, source, index, end);
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
if (powershell ? source[index] === "#" : shellCommentStart(source, index)) {
|
|
const end = lineEnd(source, index);
|
|
maskRange(output, source, index, end);
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
if (powershell && source[index] === "`") {
|
|
maskRange(output, source, index, Math.min(index + 2, source.length));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (!powershell && source[index] === "`") {
|
|
const close = source.indexOf("`", index + 1);
|
|
const end = close < 0 ? source.length : close + 1;
|
|
maskRange(output, source, index, end);
|
|
restoreMasked(output, index + 1, maskShellSource(source.slice(index + 1, close < 0 ? source.length : close)));
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
const quote = source[index];
|
|
if (quote === "'" || quote === '"') {
|
|
const escapes = powershell ? "`" : quote === "'" ? "" : "\\";
|
|
const end = quotedEnd(source, index, quote, escapes);
|
|
const preserveKey = powershell && squareDepth > 0;
|
|
if (!preserveKey) maskRange(output, source, index, end, false);
|
|
if (quote === '"') {
|
|
exposeDollarSubexpressions(output, source, index + 1, end - 1, powershell ? "powershell" : "shell");
|
|
if (!powershell) {
|
|
for (let cursor = index + 1; cursor < end - 1; cursor += 1) {
|
|
if (source[cursor] !== "`" || source[cursor - 1] === "\\") continue;
|
|
const close = source.indexOf("`", cursor + 1);
|
|
if (close < 0 || close >= end) break;
|
|
restoreMasked(output, cursor + 1, maskShellSource(source.slice(cursor + 1, close)));
|
|
cursor = close;
|
|
}
|
|
}
|
|
}
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
if (source.startsWith("$(", index)) output[index] = " ";
|
|
if (source[index] === "[") squareDepth += 1;
|
|
else if (source[index] === "]" && squareDepth > 0) squareDepth -= 1;
|
|
}
|
|
return output.join("");
|
|
}
|
|
|
|
function maskUnknownSource(source) {
|
|
const output = source.split("");
|
|
let squareDepth = 0;
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
if (source.startsWith("/*", index)) {
|
|
const close = source.indexOf("*/", index + 2);
|
|
const end = close < 0 ? source.length : close + 2;
|
|
maskRange(output, source, index, end);
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
if (source[index] === "#" || source.startsWith("//", index)) {
|
|
const end = lineEnd(source, index);
|
|
maskRange(output, source, index, end);
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
const quote = source[index];
|
|
if (quote === "'" || quote === '"' || quote === "`") {
|
|
const end = quotedEnd(source, index, quote, "\\");
|
|
let after = end;
|
|
while (/[ \t]/u.test(source[after] ?? "")) after += 1;
|
|
if (!(squareDepth > 0 || source[after] === ":")) maskRange(output, source, index, end, true);
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
if (source[index] === "[") squareDepth += 1;
|
|
else if (source[index] === "]" && squareDepth > 0) squareDepth -= 1;
|
|
}
|
|
return output.join("");
|
|
}
|
|
|
|
function maskQuotedShellHeredocBodies(source, label = "<shell>") {
|
|
const output = source.split("");
|
|
for (const range of literalBashHeredocBodyRanges(source, label)) maskRange(output, source, range.start, range.end);
|
|
return output.join("");
|
|
}
|
|
|
|
function shellAssociativeRevisionAccess(source) {
|
|
let quote;
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
const character = source[index];
|
|
if (character === "\\") { index += 1; continue; }
|
|
if (quote === "'") { if (character === "'") quote = undefined; continue; }
|
|
if (character === "'") { quote = "'"; continue; }
|
|
if (character === '"') { quote = quote === '"' ? undefined : '"'; continue; }
|
|
if (character !== "$" || source[index + 1] !== "{") continue;
|
|
const close = source.indexOf("}", index + 2);
|
|
if (close < 0) break;
|
|
const expansion = source.slice(index, close + 1);
|
|
if (/^\$\{[ \t]*(?:revision|workspaceRevision|selectedWorkspace)[ \t]*\[[ \t]*(?:["']state["']|state)[ \t]*\][^}]*\}$/u.test(expansion)) return true;
|
|
index = close;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const shellCommandPrefixes = new Set(["if", "then", "elif", "else", "while", "until", "do"]);
|
|
const shellCommandClosers = new Set(["fi", "done", "esac"]);
|
|
const shellControlCharacters = new Set([";", "|", "&", "(", ")", "{", "}", "`"]);
|
|
|
|
function shellQuotedSubstitutionEnd(source, start, depth, budget) {
|
|
for (let index = start + 1; index < source.length; index += 1) {
|
|
budget.characters += 1;
|
|
if (budget.characters > 100_000) throw new Error("revision-state shell substitution size limit exceeded");
|
|
if (source[index] === "\\") { index += 1; continue; }
|
|
if (source[index] === '"') return index + 1;
|
|
if (source.startsWith("$(", index) || source.startsWith("<(", index) || source.startsWith(">(", index)) {
|
|
index = shellParenthesizedEnd(source, index + 1, depth + 1, budget) - 1;
|
|
} else if (source[index] === "`") {
|
|
const end = quotedEnd(source, index, "`", "\\");
|
|
if (end - 1 <= index || source[end - 1] !== "`") throw new Error("revision-state shell substitution has an unclosed backtick");
|
|
index = end - 1;
|
|
}
|
|
}
|
|
throw new Error("revision-state shell substitution has an unclosed quote");
|
|
}
|
|
|
|
function shellParenthesizedEnd(source, openIndex, depth, budget) {
|
|
if (depth > 64) throw new Error("revision-state shell substitution nesting limit exceeded");
|
|
for (let index = openIndex + 1; index < source.length; index += 1) {
|
|
budget.characters += 1;
|
|
if (budget.characters > 100_000) throw new Error("revision-state shell substitution size limit exceeded");
|
|
if (source[index] === "\\") { index += 1; continue; }
|
|
if (source[index] === "'") {
|
|
const end = quotedEnd(source, index, "'", "");
|
|
if (end - 1 <= index || source[end - 1] !== "'") throw new Error("revision-state shell substitution has an unclosed quote");
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
if (source[index] === '"') { index = shellQuotedSubstitutionEnd(source, index, depth, budget) - 1; continue; }
|
|
if (source[index] === "`") {
|
|
const end = quotedEnd(source, index, "`", "\\");
|
|
if (end - 1 <= index || source[end - 1] !== "`") throw new Error("revision-state shell substitution has an unclosed backtick");
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
if (source[index] === "#" && (index === openIndex + 1 || /[ \t\r\n;|&()]/u.test(source[index - 1]))) {
|
|
index = lineEnd(source, index);
|
|
continue;
|
|
}
|
|
if (source[index] === "(") { index = shellParenthesizedEnd(source, index, depth + 1, budget) - 1; continue; }
|
|
if (source[index] === ")") return index + 1;
|
|
}
|
|
throw new Error("revision-state shell process substitution is unbalanced");
|
|
}
|
|
|
|
function shellProcessSubstitutionEnd(source, start) {
|
|
if (!(source.startsWith("<(", start) || source.startsWith(">(", start))) return undefined;
|
|
return shellParenthesizedEnd(source, start + 1, 1, { characters: 0 });
|
|
}
|
|
|
|
function shellRedirectionAt(source, start) {
|
|
const match = source.slice(start).match(/^(?:&>>|&>|(?:[0-9]+|\{[A-Za-z_][A-Za-z0-9_]*\})?(?:<<<|<<-|<<|>>|<>|>\||<&|>&|<|>))/u);
|
|
if (!match) return undefined;
|
|
let end = start + match[0].length;
|
|
while (end < source.length && !/\s/u.test(source[end]) && !shellControlCharacters.has(source[end]) &&
|
|
source[end] !== "<" && source[end] !== ">" && source[end] !== "'" && source[end] !== '"') end += 1;
|
|
return { value: source.slice(start, end), end, needsOperand: end === start + match[0].length };
|
|
}
|
|
|
|
function shellLexTokens(source) {
|
|
const tokens = [];
|
|
const push = (value, start, end, type = "word") => {
|
|
tokens.push({ value, start, end, type });
|
|
if (tokens.length > 50_000) throw new Error("revision-state shell token limit exceeded");
|
|
};
|
|
for (let index = 0; index < source.length;) {
|
|
if (source[index] === "\n" || source[index] === "\r") { push(source[index], index, index + 1, "control"); index += 1; continue; }
|
|
if (/\s/u.test(source[index])) { index += 1; continue; }
|
|
if (source[index] === "#") { index = lineEnd(source, index); continue; }
|
|
const processEnd = shellProcessSubstitutionEnd(source, index);
|
|
if (processEnd !== undefined) {
|
|
push(source.slice(index, processEnd), index, processEnd);
|
|
index = processEnd;
|
|
continue;
|
|
}
|
|
const redirection = shellRedirectionAt(source, index);
|
|
if (redirection) {
|
|
push(redirection.value, index, redirection.end, "redirection");
|
|
tokens.at(-1).needsOperand = redirection.needsOperand;
|
|
index = redirection.end;
|
|
continue;
|
|
}
|
|
if (shellControlCharacters.has(source[index]) || source[index] === "!" && (index === 0 || /\s/u.test(source[index - 1]))) {
|
|
const start = index;
|
|
let value = source[index++];
|
|
if ((value === ";" || value === "|" || value === "&") && source[index] === value) value += source[index++];
|
|
push(value, start, index, "control");
|
|
continue;
|
|
}
|
|
const start = index;
|
|
let value = "";
|
|
while (index < source.length && !/\s/u.test(source[index]) && !shellControlCharacters.has(source[index]) && source[index] !== "<" && source[index] !== ">") {
|
|
const quote = source[index];
|
|
if (quote === "'" || quote === '"') {
|
|
const end = quotedEnd(source, index, quote, "\\");
|
|
value += source.slice(index + 1, end - 1);
|
|
index = end;
|
|
} else if (source[index] === "\\" && index + 1 < source.length) {
|
|
value += source[index + 1];
|
|
index += 2;
|
|
} else {
|
|
value += source[index++];
|
|
}
|
|
}
|
|
push(value, start, index);
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function shellCommandWords(source) {
|
|
const commands = [];
|
|
let words = [];
|
|
const finish = () => { if (words.length > 0) commands.push(words); words = []; };
|
|
for (const token of shellLexTokens(source)) {
|
|
if (token.type === "control") {
|
|
finish();
|
|
continue;
|
|
}
|
|
if (token.type === "word" && words.length === 0 && shellCommandPrefixes.has(token.value)) continue;
|
|
if (token.type === "word" && words.length === 0 && shellCommandClosers.has(token.value)) continue;
|
|
words.push(token);
|
|
}
|
|
finish();
|
|
return commands;
|
|
}
|
|
|
|
function shellExecutable(word) {
|
|
return word?.split("/").pop();
|
|
}
|
|
|
|
const shellWrapperSpecs = new Map([
|
|
["command", { kind: "options", operandOptions: new Set() }],
|
|
["env", { kind: "env", operandOptions: new Set(["-u", "--unset", "-C", "--chdir"]) }],
|
|
["sudo", { kind: "options", operandOptions: new Set(["-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-C", "--close-from", "-D", "--chdir"]) }],
|
|
["nice", { kind: "options", operandOptions: new Set(["-n", "--adjustment"]) }],
|
|
["time", { kind: "options", operandOptions: new Set(["-o", "--output", "-f", "--format"]) }],
|
|
["xargs", { kind: "options", operandOptions: new Set(["-I", "--replace", "-n", "--max-args", "-L", "--max-lines", "-P", "--max-procs", "-s", "--max-chars", "-d", "--delimiter"]) }],
|
|
["timeout", { kind: "timeout", operandOptions: new Set(["-k", "--kill-after", "-s", "--signal"]) }],
|
|
["stdbuf", { kind: "stdbuf", operandOptions: new Set(["-i", "--input", "-o", "--output", "-e", "--error"]) }],
|
|
["nohup", { kind: "options", operandOptions: new Set() }],
|
|
["exec", { kind: "options", operandOptions: new Set(["-a"]) }],
|
|
["coproc", { kind: "coproc", operandOptions: new Set() }],
|
|
]);
|
|
|
|
function skipShellMetadata(words, start) {
|
|
let index = start;
|
|
while (index < words.length) {
|
|
const token = words[index];
|
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token.value)) { index += 1; continue; }
|
|
if (token.type === "redirection") { index += token.needsOperand ? 2 : 1; continue; }
|
|
break;
|
|
}
|
|
return index;
|
|
}
|
|
|
|
function skipWrapperOptions(words, start, spec) {
|
|
let index = start;
|
|
while (index < words.length) {
|
|
const word = words[index].value;
|
|
if (word === "--") return index + 1;
|
|
if (spec.operandOptions.has(word)) { index += 2; continue; }
|
|
if (spec.kind === "stdbuf" && /^-(?:i|o|e).+/u.test(word)) { index += 1; continue; }
|
|
if (word.startsWith("-")) { index += 1; continue; }
|
|
break;
|
|
}
|
|
return index;
|
|
}
|
|
|
|
function shellJqArguments(words) {
|
|
let index = skipShellMetadata(words, 0);
|
|
let wrappers = 0;
|
|
while (index < words.length) {
|
|
const spec = shellWrapperSpecs.get(shellExecutable(words[index]?.value));
|
|
if (!spec) break;
|
|
if (wrappers >= 16) throw new Error("revision-state shell wrapper nesting exceeds policy limit");
|
|
wrappers += 1;
|
|
index = skipWrapperOptions(words, index + 1, spec);
|
|
if (spec.kind === "env") {
|
|
while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[index]?.value ?? "")) index += 1;
|
|
} else if (spec.kind === "timeout") {
|
|
if (index >= words.length) return undefined;
|
|
index += 1;
|
|
} else if (spec.kind === "coproc") {
|
|
index = skipShellMetadata(words, index);
|
|
const current = shellExecutable(words[index]?.value);
|
|
if (current !== "jq" && !shellWrapperSpecs.has(current) && /^[A-Za-z_][A-Za-z0-9_]*$/u.test(words[index]?.value ?? "")) {
|
|
const afterName = skipShellMetadata(words, index + 1);
|
|
const command = shellExecutable(words[afterName]?.value);
|
|
if (command === "jq" || shellWrapperSpecs.has(command)) index = afterName;
|
|
}
|
|
}
|
|
index = skipShellMetadata(words, index);
|
|
}
|
|
return shellExecutable(words[index]?.value) === "jq" ? words.slice(index + 1) : undefined;
|
|
}
|
|
|
|
const jqOptionOperands = new Map([
|
|
["--arg", 2], ["--argjson", 2], ["--slurpfile", 2], ["--rawfile", 2], ["--argfile", 2],
|
|
["-L", 1], ["--library-path", 1], ["--indent", 1],
|
|
["-f", 1], ["--from-file", 1],
|
|
]);
|
|
const jqFileFilterOptions = new Set(["-f", "--from-file"]);
|
|
|
|
function withoutShellRedirections(arguments_) {
|
|
const semantic = [];
|
|
for (let index = 0; index < arguments_.length; index += 1) {
|
|
const token = arguments_[index];
|
|
if (token.type === "redirection") { if (token.needsOperand) index += 1; continue; }
|
|
semantic.push(token);
|
|
}
|
|
return semantic;
|
|
}
|
|
|
|
function jqInvocation(arguments_) {
|
|
const semantic = withoutShellRedirections(arguments_);
|
|
let fromFile = false;
|
|
for (let index = 0; index < semantic.length; index += 1) {
|
|
const argument = semantic[index].value;
|
|
if (argument === "--") return { filter: fromFile ? undefined : semantic[index + 1], arguments_ };
|
|
const operands = jqOptionOperands.get(argument);
|
|
if (operands !== undefined) {
|
|
if (jqFileFilterOptions.has(argument)) fromFile = true;
|
|
index += operands;
|
|
continue;
|
|
}
|
|
if (argument.startsWith("-")) continue;
|
|
return { filter: fromFile ? undefined : semantic[index], arguments_ };
|
|
}
|
|
return { filter: undefined, arguments_ };
|
|
}
|
|
|
|
function maskShellJqLiteralArguments(source) {
|
|
const output = source.split("");
|
|
for (const words of shellCommandWords(source)) {
|
|
const arguments_ = shellJqArguments(words);
|
|
if (!arguments_) continue;
|
|
const invocation = jqInvocation(arguments_);
|
|
for (const argument of invocation.arguments_) {
|
|
if (argument === invocation.filter) continue;
|
|
const raw = source.slice(argument.start, argument.end);
|
|
if (!raw.includes("$") && !raw.includes("`")) maskRange(output, source, argument.start, argument.end);
|
|
}
|
|
}
|
|
return output.join("");
|
|
}
|
|
|
|
function jqStringEnd(source, start) {
|
|
for (let index = start + 1; index < source.length; index += 1) {
|
|
if (source[index] === "\\") { index += 1; continue; }
|
|
if (source[index] === '"') return index;
|
|
}
|
|
return source.length;
|
|
}
|
|
|
|
function jqInterpolationEnd(source, start) {
|
|
let depth = 1;
|
|
for (let index = start; index < source.length; index += 1) {
|
|
if (source[index] === '"') { index = jqStringEnd(source, index); continue; }
|
|
if (source[index] === "(") depth += 1;
|
|
else if (source[index] === ")" && --depth === 0) return index;
|
|
}
|
|
return source.length;
|
|
}
|
|
|
|
function jqTokens(source, budget = { tokens: 0, depth: 0 }) {
|
|
if (budget.depth >= 64) throw new Error("jq filter exceeds policy nesting limit");
|
|
budget.depth += 1;
|
|
const tokens = [];
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
budget.tokens += 1;
|
|
if (budget.tokens >= 10_000) throw new Error("jq filter exceeds policy token limit");
|
|
if (/\s/u.test(source[index])) continue;
|
|
if (source[index] === "#") { index = lineEnd(source, index); continue; }
|
|
if (source[index] === '"') {
|
|
const end = jqStringEnd(source, index);
|
|
const raw = source.slice(index, Math.min(end + 1, source.length));
|
|
let value;
|
|
if (!raw.includes("\\(")) {
|
|
try { value = JSON.parse(raw); } catch { value = undefined; }
|
|
}
|
|
tokens.push({ type: "string", value });
|
|
for (let cursor = index + 1; cursor < end; cursor += 1) {
|
|
if (source[cursor] === "\\" && source[cursor + 1] === "(") {
|
|
const close = jqInterpolationEnd(source, cursor + 2);
|
|
tokens.push(...jqTokens(source.slice(cursor + 2, close), budget));
|
|
cursor = close;
|
|
} else if (source[cursor] === "\\") cursor += 1;
|
|
}
|
|
index = end;
|
|
continue;
|
|
}
|
|
const variable = source.slice(index).match(/^\$([A-Za-z_][A-Za-z0-9_]*)/u);
|
|
if (variable) { tokens.push({ type: "variable", value: variable[1] }); index += variable[0].length - 1; continue; }
|
|
const identifier = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/u);
|
|
if (identifier) { tokens.push({ type: "identifier", value: identifier[0] }); index += identifier[0].length - 1; continue; }
|
|
const punctuation = { ".": "dot", "[": "open", "]": "close" }[source[index]];
|
|
tokens.push({ type: punctuation ?? "other", value: source[index] });
|
|
}
|
|
budget.depth -= 1;
|
|
return tokens;
|
|
}
|
|
|
|
function jqStaticString(tokens, cursor, depth = 0) {
|
|
if (depth >= 64) throw new Error("revision-state jq static-key nesting exceeds policy limit");
|
|
let index = cursor;
|
|
let value;
|
|
if (tokens[index]?.type === "string" && typeof tokens[index].value === "string") {
|
|
value = tokens[index].value;
|
|
index += 1;
|
|
} else if (tokens[index]?.type === "other" && tokens[index].value === "(") {
|
|
const nested = jqStaticString(tokens, index + 1, depth + 1);
|
|
if (!nested || tokens[nested.next]?.type !== "other" || tokens[nested.next].value !== ")") return undefined;
|
|
value = nested.value;
|
|
index = nested.next + 1;
|
|
} else return undefined;
|
|
while (tokens[index]?.type === "other" && tokens[index].value === "+") {
|
|
const right = jqStaticString(tokens, index + 1, depth + 1);
|
|
if (!right) return undefined;
|
|
value += right.value;
|
|
index = right.next;
|
|
}
|
|
return { value, next: index };
|
|
}
|
|
|
|
function jqBracketSegment(tokens, cursor) {
|
|
if (tokens[cursor]?.type !== "open") return undefined;
|
|
const expression = jqStaticString(tokens, cursor + 1);
|
|
return expression && tokens[expression.next]?.type === "close" ?
|
|
{ value: expression.value, next: expression.next + 1 } : undefined;
|
|
}
|
|
|
|
function jqPathSegment(tokens, cursor, allowBareBracket = true) {
|
|
if (tokens[cursor]?.type === "variable") return { value: tokens[cursor].value, next: cursor + 1 };
|
|
let index = cursor;
|
|
if (tokens[index]?.type === "dot") {
|
|
index += 1;
|
|
if (tokens[index]?.type === "identifier" || tokens[index]?.type === "string") return { value: tokens[index].value, next: index + 1 };
|
|
}
|
|
return allowBareBracket ? jqBracketSegment(tokens, index) : undefined;
|
|
}
|
|
|
|
function jqIdentityPipelineEnd(tokens, cursor) {
|
|
let index = cursor;
|
|
while (tokens[index]?.type === "other" && tokens[index].value === "(") index += 1;
|
|
if (tokens[index]?.type !== "dot") return undefined;
|
|
index += 1;
|
|
while (tokens[index]?.type === "other" && tokens[index].value === ")") index += 1;
|
|
return tokens[index]?.type === "other" && tokens[index].value === "|" ? index + 1 : undefined;
|
|
}
|
|
|
|
function jqTargetGrammarSupported(tokens) {
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
const token = tokens[index];
|
|
if (token.type === "identifier" && tokens[index - 1]?.type !== "dot") return false;
|
|
if (token.type === "open" && !jqBracketSegment(tokens, index)) return false;
|
|
if (token.type !== "other") continue;
|
|
if (["?", "(", ")", "|"].includes(token.value)) continue;
|
|
if (token.value === "+" && (tokens[index - 1]?.type === "string" || tokens[index - 1]?.value === ")") &&
|
|
(tokens[index + 1]?.type === "string" || tokens[index + 1]?.value === "(")) continue;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function jqContainsActiveTarget(tokens) {
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
if (tokens[index].type === "variable" && revisionIdentifiers.has(tokens[index].value)) return true;
|
|
if (tokens[index].type === "dot" && (tokens[index + 1]?.type === "identifier" || tokens[index + 1]?.type === "string") &&
|
|
revisionIdentifiers.has(tokens[index + 1].value)) return true;
|
|
if (tokens[index].type === "open" && (tokens[index - 1]?.type === "dot" || tokens[index - 1]?.type === "close" || tokens[index - 1]?.type === "identifier")) {
|
|
const key = jqStaticString(tokens, index + 1);
|
|
if (key && revisionIdentifiers.has(key.value)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function jqRevisionAnalysis(filter) {
|
|
const tokens = jqTokens(filter);
|
|
let activeTarget = jqContainsActiveTarget(tokens);
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
if (tokens[index].type !== "dot" && tokens[index].type !== "variable") continue;
|
|
const segments = [];
|
|
let cursor = index;
|
|
let pipelineBoundary = false;
|
|
while (cursor < tokens.length) {
|
|
if (pipelineBoundary && (tokens[cursor]?.type === "open" || tokens[cursor]?.type === "string")) {
|
|
segments.length = 0;
|
|
break;
|
|
}
|
|
if (pipelineBoundary && tokens[cursor]?.type === "variable") segments.length = 0;
|
|
const segment = jqPathSegment(tokens, cursor, !pipelineBoundary);
|
|
if (!segment) break;
|
|
pipelineBoundary = false;
|
|
segments.push(segment.value);
|
|
cursor = segment.next;
|
|
while (tokens[cursor]?.type === "other" && tokens[cursor].value === "?") cursor += 1;
|
|
while (tokens[cursor]?.type === "other" && tokens[cursor].value === ")") cursor += 1;
|
|
if (tokens[cursor]?.type === "other" && tokens[cursor].value === "|") {
|
|
cursor += 1;
|
|
while (tokens[cursor]?.type === "other" && tokens[cursor].value === "(") cursor += 1;
|
|
let identityEnd;
|
|
while ((identityEnd = jqIdentityPipelineEnd(tokens, cursor)) !== undefined) cursor = identityEnd;
|
|
pipelineBoundary = true;
|
|
}
|
|
}
|
|
if (segments.some((segment) => revisionIdentifiers.has(segment))) activeTarget = true;
|
|
for (let position = 0; position + 1 < segments.length; position += 1) {
|
|
if (revisionIdentifiers.has(segments[position]) && segments[position + 1] === "state") return "violation";
|
|
}
|
|
}
|
|
if (!activeTarget) return "safe";
|
|
return jqTargetGrammarSupported(tokens) ? "safe" : "unsupported";
|
|
}
|
|
|
|
function shellExecutableSubstitutionBodies(source, arithmeticContext = false) {
|
|
const bodies = [];
|
|
const addParenthesized = (start, kind) => {
|
|
const end = shellParenthesizedEnd(source, start + 1, 1, { characters: 0 });
|
|
bodies.push({ kind, start: start + 2, end: end - 1, source: source.slice(start + 2, end - 1) });
|
|
return end;
|
|
};
|
|
const addBacktick = (start) => {
|
|
const end = quotedEnd(source, start, "`", "\\");
|
|
if (end - 1 <= start || source[end - 1] !== "`") throw new Error("revision-state shell substitution has an unclosed backtick");
|
|
bodies.push({ kind: "backtick", start: start + 1, end: end - 1, source: source.slice(start + 1, end - 1) });
|
|
return end;
|
|
};
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
if (source[index] === "\\") { index += 1; continue; }
|
|
if (source[index] === "#" && (index === 0 || /[ \t\r\n;|&()]/u.test(source[index - 1]))) { index = lineEnd(source, index); continue; }
|
|
if (source[index] === "'") {
|
|
const end = quotedEnd(source, index, "'", "");
|
|
if (end - 1 <= index || source[end - 1] !== "'") throw new Error(`revision-state shell policy found an unclosed quote at offset ${index}`);
|
|
index = end - 1;
|
|
continue;
|
|
}
|
|
if (source[index] === '"') {
|
|
for (let cursor = index + 1; cursor < source.length; cursor += 1) {
|
|
if (source[cursor] === "\\") { cursor += 1; continue; }
|
|
if (source[cursor] === '"') { index = cursor; break; }
|
|
if (source.startsWith("$(", cursor)) {
|
|
const end = addParenthesized(cursor, source.startsWith("$((", cursor) ? "arithmetic" : "command");
|
|
cursor = end - 1;
|
|
} else if (source[cursor] === "`") {
|
|
cursor = addBacktick(cursor) - 1;
|
|
}
|
|
if (cursor + 1 >= source.length) throw new Error(`revision-state shell policy found an unclosed double quote at offset ${index}`);
|
|
}
|
|
continue;
|
|
}
|
|
if (!arithmeticContext && (source.startsWith("<(", index) || source.startsWith(">(", index))) {
|
|
index = addParenthesized(index, "process") - 1;
|
|
continue;
|
|
}
|
|
if (source.startsWith("$(", index)) {
|
|
const arithmetic = source.startsWith("$((", index);
|
|
index = addParenthesized(index, arithmetic ? "arithmetic" : "command") - 1;
|
|
continue;
|
|
}
|
|
if (source[index] === "`") index = addBacktick(index) - 1;
|
|
}
|
|
return bodies;
|
|
}
|
|
|
|
function removeBacktickBodyEscapes(source) {
|
|
let result = "";
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
if (source[index] === "\\" && index + 1 < source.length && ["$", "`", "\\", "\n"].includes(source[index + 1])) {
|
|
if (source[index + 1] !== "\n") result += source[index + 1];
|
|
index += 1;
|
|
} else {
|
|
result += source[index];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function shellJqRevisionAccess(source, budget = { characters: 0 }, depth = 0, arithmeticContext = false) {
|
|
if (depth > 32) throw new Error("revision-state executable shell substitution nesting limit exceeded");
|
|
budget.characters += source.length;
|
|
if (budget.characters > 500_000) throw new Error("revision-state executable shell substitution size limit exceeded");
|
|
if (!arithmeticContext) {
|
|
for (const words of shellCommandWords(source)) {
|
|
const arguments_ = shellJqArguments(words);
|
|
const filter = arguments_ && jqInvocation(arguments_).filter;
|
|
if (filter) {
|
|
const analysis = jqRevisionAnalysis(filter.value);
|
|
if (analysis === "violation") return true;
|
|
if (analysis === "unsupported") throw new Error("revision-state jq target grammar is unsupported");
|
|
}
|
|
}
|
|
}
|
|
for (const body of shellExecutableSubstitutionBodies(source, arithmeticContext)) {
|
|
const nestedSource = body.kind === "backtick" ? removeBacktickBodyEscapes(body.source) : body.source;
|
|
if (shellJqRevisionAccess(nestedSource, budget, depth + 1, body.kind === "arithmetic")) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function nonJsAnalysisSource(source, label) {
|
|
const lower = label.toLowerCase();
|
|
if (lower.endsWith(".sh")) return maskShellSource(maskShellJqLiteralArguments(maskQuotedShellHeredocBodies(source, label)));
|
|
if (lower.endsWith(".ps1")) return maskPowerShellSource(source);
|
|
return maskUnknownSource(source);
|
|
}
|
|
|
|
function revisionStateAstNodes(source, label) {
|
|
const knownKind = scriptKindFor(label);
|
|
const caseInsensitive = label.toLowerCase().endsWith(".ps1");
|
|
const analyzed = knownKind === undefined ? nonJsAnalysisSource(source, label) : source;
|
|
const file = ts.createSourceFile(label, analyzed, ts.ScriptTarget.Latest, true, knownKind ?? ts.ScriptKind.TS);
|
|
const matches = [];
|
|
function visit(node) {
|
|
if (ts.isPropertyAccessExpression(node) && node.name.text === "state" && isRevisionExpression(node.expression, caseInsensitive)) {
|
|
matches.push(node);
|
|
} else if (ts.isElementAccessExpression(node) && isRevisionExpression(node.expression, caseInsensitive) &&
|
|
node.argumentExpression && propertyNameText(node.argumentExpression, caseInsensitive) === "state") {
|
|
matches.push(node);
|
|
} else if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) && node.initializer &&
|
|
isRevisionExpression(node.initializer, caseInsensitive) && ts.isObjectBindingPattern(node.name) &&
|
|
objectBindingHasState(node.name, caseInsensitive)) {
|
|
matches.push(node);
|
|
} else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
isRevisionExpression(node.right, caseInsensitive)) {
|
|
const assignmentTarget = unwrapExpression(node.left);
|
|
if (ts.isObjectLiteralExpression(assignmentTarget) && objectLiteralHasState(assignmentTarget, caseInsensitive)) matches.push(node);
|
|
} else if (ts.isPropertyAssignment(node) && propertyNameText(node.name, caseInsensitive) === "revision" &&
|
|
ts.isObjectLiteralExpression(node.initializer) && objectLiteralHasState(node.initializer, caseInsensitive)) {
|
|
matches.push(node);
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
visit(file);
|
|
return matches;
|
|
}
|
|
|
|
|
|
function yamlScalarRevisionAccess(value) {
|
|
return /(?:^|[\s;=,(])(?:revision|workspaceRevision|selectedWorkspace)\s*(?:\.\s*state|\[\s*["']?state["']?\s*\])(?:$|[\s;,)])/u.test(value);
|
|
}
|
|
|
|
function validateYamlRevisionState(source, label) {
|
|
const documents = parseAllDocuments(source, { uniqueKeys: true, merge: true });
|
|
for (const document of documents) {
|
|
if (document.errors.length > 0) throw new Error(`${label}: revision-state policy cannot parse YAML`);
|
|
const walkAst = (node) => {
|
|
if (isScalar(node)) {
|
|
if (node.type === "PLAIN" && typeof node.value === "string" && yamlScalarRevisionAccess(node.value)) throw new Error(`${label}: forbidden revision-state access`);
|
|
return;
|
|
}
|
|
if (isSeq(node)) { for (const item of node.items) walkAst(item); return; }
|
|
if (isMap(node)) { for (const pair of node.items) walkAst(pair.value); }
|
|
};
|
|
walkAst(document.contents);
|
|
let resolved;
|
|
try { resolved = document.toJS({ mapAsMap: true, maxAliasCount: 50 }); }
|
|
catch { throw new Error(`${label}: revision-state YAML alias resolution failed`); }
|
|
const seen = new WeakSet();
|
|
const walkResolved = (value) => {
|
|
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
seen.add(value);
|
|
if (value instanceof Map) {
|
|
for (const [key, child] of value) {
|
|
if (revisionIdentifiers.has(String(key)) && child instanceof Map && child.has("state")) throw new Error(`${label}: forbidden revision-state access`);
|
|
walkResolved(child);
|
|
}
|
|
} else if (Array.isArray(value)) { for (const child of value) walkResolved(child); }
|
|
};
|
|
walkResolved(resolved);
|
|
}
|
|
}
|
|
|
|
function validateRevisionState(source, label) {
|
|
const lower = label.toLowerCase();
|
|
if (/\.(?:yaml|yml)(?:\.example)?$/u.test(lower)) {
|
|
validateYamlRevisionState(source, label);
|
|
return;
|
|
}
|
|
if (lower.endsWith(".sh")) {
|
|
const active = maskQuotedShellHeredocBodies(source, label);
|
|
try {
|
|
if (shellJqRevisionAccess(active) || shellAssociativeRevisionAccess(active)) throw new Error("forbidden revision-state access");
|
|
} catch (error) {
|
|
throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
if (lower.endsWith(".py") || lower.endsWith(".pyw")) throw new Error(`${label}: revision-state Python input was not batched`);
|
|
const matches = revisionStateAstNodes(source, label);
|
|
if (matches.length === 0) return;
|
|
const historical = 'revision.state !== "operational"';
|
|
const historicalCount = source.split(historical).length - 1;
|
|
const match = matches[0];
|
|
if (label === "backend/src/workspaces/registry.ts" && matches.length === 1 &&
|
|
match.getText() === "revision.state" && match.parent?.getText() === historical &&
|
|
historicalCount === 1) return;
|
|
throw new Error(`${label}: forbidden revision-state access`);
|
|
}
|
|
|
|
|
|
const pythonHelper = fileURLToPath(new URL("./revision_state_policy.py", import.meta.url));
|
|
|
|
function validatePythonRevisionStates(records) {
|
|
if (!Array.isArray(records) || records.length === 0) return;
|
|
let stdout;
|
|
try {
|
|
stdout = execFileSync("python3", ["-I", "-B", pythonHelper], {
|
|
input: JSON.stringify(records), encoding: "utf8", timeout: 5_000, maxBuffer: 4 * 1024 * 1024,
|
|
env: {
|
|
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
LANG: "C.UTF-8",
|
|
LC_ALL: "C.UTF-8",
|
|
PYTHONDONTWRITEBYTECODE: "1",
|
|
},
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
} catch (error) {
|
|
const detail = error?.stderr?.toString().trim();
|
|
throw new Error(`revision-state helper failed${detail ? `: ${detail}` : ""}`);
|
|
}
|
|
let result;
|
|
try { result = JSON.parse(stdout); }
|
|
catch { throw new Error("revision-state helper failed: invalid JSON output"); }
|
|
if (!result || !Array.isArray(result.violations) || result.violations.some((label) => typeof label !== "string")) throw new Error("revision-state helper failed: invalid result shape");
|
|
if (result.violations.length > 0) throw new Error(`${result.violations[0]}: forbidden revision-state access`);
|
|
}
|
|
|
|
export { validatePythonRevisionStates, validateRevisionState };
|