mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(evals): local-hook detector credits 'set it up by hand'; read-only grammar steers globbed options to the quoted form Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: changelog and fixture touchfile for the 2026-10-21 paid flake repairs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.174.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
419 lines
23 KiB
TypeScript
419 lines
23 KiB
TypeScript
/**
|
|
* Shared read-only Bash grammar for paid-eval fixtures (GSTA-49).
|
|
*
|
|
* A fixture whose contract is not about which reads happen opts in by also
|
|
* accepting what this grammar accepts and by declaring readOnlyInterfaceText()
|
|
* to its actor. Fixtures whose contract is specifically about which reads
|
|
* happen keep their narrow lists.
|
|
*
|
|
* Accepted: one line of simple commands from READ_TOOLS joined by ;, && or ||,
|
|
* where a | pipe may feed only a STDIN_TOOLS filter (tr only there, with literal
|
|
* sets), plus cd into a directory
|
|
* inside the root. Every path operand, path-valued option and unquoted glob
|
|
* match must resolve physically (symlinks and .. applied) inside the fixture
|
|
* root and outside its denied paths.
|
|
* Stderr may be discarded with 2>/dev/null or merged with 2>&1 after a word.
|
|
* Rejected: every other tool, every other redirect, substitutions, variables, tilde and
|
|
* brace expansion, backgrounding, multi-line input, and the write, exec,
|
|
* symlink-following and output-file options of the listed tools.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
|
|
export type ReadOnlyScope = {
|
|
/** Fixture working root: every resolved path must stay inside it. */
|
|
root: string;
|
|
/** Starting directory; defaults to the root and must be inside it. */
|
|
cwd?: string;
|
|
/** Physical paths that must never be read, listed or searched (credentials, private actor state). */
|
|
deny?: (physical: string) => boolean;
|
|
};
|
|
|
|
type Word = { text: string; glob: boolean; quoted: boolean };
|
|
type Token = Word | { op: '|' | ';' | '&&' | '||' };
|
|
|
|
type ToolSpec = {
|
|
/** A whole option word that needs no separate value. */
|
|
flag: RegExp;
|
|
/** An option word that takes the next word as a non-path value. */
|
|
valued?: RegExp;
|
|
/** An option word that takes the next word as a path. */
|
|
pathValued?: RegExp;
|
|
/** An option word carrying a path in capture group 1. */
|
|
pathAttached?: RegExp;
|
|
/** The first operand is a pattern unless a pattern option was given. */
|
|
pattern?: boolean;
|
|
/** Directory operands are searched by content, so the deny scan applies below them. */
|
|
recursive?: (options: string[]) => boolean;
|
|
maxOperands?: number;
|
|
/** Arguments are literal text, never paths: no globs, and no option before the first argument except echo's -n/-e/-E. */
|
|
literal?: boolean;
|
|
};
|
|
|
|
const HEAD_TAIL: ToolSpec = {
|
|
flag: /^(?:-[qvz]+|-\d+|-[nc][+-]?\d+[a-zA-Z]*|--(?:quiet|silent|verbose|zero-terminated)|--(?:lines|bytes)=[+-]?\d+[a-zA-Z]*)$/,
|
|
valued: /^-[nc]$/,
|
|
};
|
|
const CHECKSUM: ToolSpec = { flag: /^(?:-[btz]+|--(?:binary|text|tag|zero))$/ };
|
|
|
|
export const READ_TOOLS: Readonly<Record<string, ToolSpec>> = {
|
|
pwd: { flag: /^-[LP]$/, maxOperands: 0 },
|
|
ls: { flag: /^(?:-[aAbBcCdfFgGhiklmnNopqQrRsStuUvwxX1Z]+|--(?:all|almost-all|human-readable|recursive|reverse|size|inode|directory|classify|full-time|group-directories-first|numeric-uid-gid|no-group|color=never|sort=(?:none|size|time|version|extension|name)|time-style=[\w+%:.-]+))$/ },
|
|
wc: { flag: /^(?:-[clmwL]+|--(?:lines|bytes|chars|words|max-line-length|total=(?:auto|always|only|never)))$/ },
|
|
head: HEAD_TAIL,
|
|
tail: HEAD_TAIL,
|
|
cat: { flag: /^(?:-[AbeEnstTuv]+|--(?:number|number-nonblank|show-all|show-ends|show-tabs|show-nonprinting|squeeze-blank))$/ },
|
|
stat: { flag: /^(?:-[Lft]+|-c.+|--(?:dereference|file-system|terse)|--(?:format|printf)=.+)$/, valued: /^-c$/ },
|
|
file: { flag: /^(?:-[bhiLkNr0]+|--(?:brief|mime|mime-type|mime-encoding|dereference|no-dereference|keep-going|raw|no-pad))$/ },
|
|
du: { flag: /^(?:-[abchkmsSxl0]+|-d\d+|--(?:all|apparent-size|bytes|total|human-readable|summarize|separate-dirs|one-file-system|count-links|null|si)|--max-depth=\d+|--exclude=.+)$/, valued: /^-d$/ },
|
|
find: { flag: /^$/ },
|
|
sed: { flag: /^$/ },
|
|
grep: {
|
|
flag: /^(?:-[EFGPiywxvcLlnhHoqsrabIUzZT]+|-[mABC]\d+|--(?:extended-regexp|fixed-strings|basic-regexp|perl-regexp|ignore-case|no-ignore-case|word-regexp|line-regexp|invert-match|count|files-with-matches|files-without-match|line-number|no-filename|with-filename|only-matching|quiet|silent|no-messages|recursive|text|byte-offset|null|null-data|initial-tab|colou?r(?:=(?:never|auto|always))?)|--(?:max-count|after-context|before-context|context)=\d+|--(?:include|exclude|exclude-dir|label|binary-files)=.+|--directories=(?:read|skip|recurse)|--devices=(?:read|skip))$/,
|
|
valued: /^-[mABCdD]$/,
|
|
pathValued: /^-f$/,
|
|
pathAttached: /^(?:-f|--file=)(.+)$/,
|
|
pattern: true,
|
|
recursive: options => options.some((option, index) => /^-[^-]*r/.test(option) || option === '--recursive'
|
|
|| option === '--directories=recurse' || (option === '-d' && options[index + 1] === 'recurse')),
|
|
},
|
|
rg: {
|
|
flag: /^(?:-[iSsFwxvclnNHIoqauU0.]+|-[mABCdMj]\d+|--(?:hidden|no-ignore|ignore-case|smart-case|case-sensitive|fixed-strings|word-regexp|line-regexp|invert-match|count|count-matches|files|files-with-matches|files-without-match|line-number|no-line-number|with-filename|no-filename|only-matching|quiet|heading|no-heading|json|vimgrep|unrestricted|text|null|no-messages|stats|column|no-config)|--colou?r=(?:never|auto|always)|--sort=(?:none|path|modified|accessed|created)|--(?:max-count|max-depth|context|after-context|before-context|max-columns|threads)=\d+|--(?:glob|iglob|type|type-not|encoding)=.+)$/,
|
|
valued: /^(?:-[gtTmABCdMjE]|--(?:glob|iglob|type|type-not|max-count|max-depth|context|after-context|before-context|max-columns|threads|encoding|sort))$/,
|
|
pathValued: /^(?:-f|--file)$/,
|
|
pathAttached: /^--file=(.+)$/,
|
|
pattern: true,
|
|
recursive: () => true,
|
|
},
|
|
sort: { flag: /^(?:-[bdfgiMhnRrsuVcCzm]+|-[kt].+|--(?:ignore-leading-blanks|dictionary-order|ignore-case|general-numeric-sort|ignore-nonprinting|month-sort|human-numeric-sort|numeric-sort|random-sort|reverse|stable|unique|version-sort|check|merge|zero-terminated)|--(?:key|field-separator)=.+)$/, valued: /^-[kt]$/ },
|
|
uniq: { flag: /^(?:-[cdDiuz]+|-[fsw]\d+|--(?:count|repeated|unique|ignore-case|zero-terminated)|--(?:skip-fields|skip-chars|check-chars)=\d+)$/, valued: /^-[fsw]$/, maxOperands: 1 },
|
|
cut: { flag: /^(?:-[sz]+|-[bcfd].+|--(?:complement|only-delimited|zero-terminated)|--(?:bytes|characters|fields|delimiter|output-delimiter)=.+)$/, valued: /^-[bcfd]$/ },
|
|
nl: { flag: /^(?:-p|-[bdfhilnsvw].+|--no-renumber|--(?:body-numbering|section-delimiter|footer-numbering|header-numbering|line-increment|join-blank-lines|number-format|number-separator|starting-line-number|number-width)=.+)$/, valued: /^-[bdfhilnsvw]$/ },
|
|
sha256sum: CHECKSUM,
|
|
sha1sum: CHECKSUM,
|
|
sha512sum: CHECKSUM,
|
|
md5sum: CHECKSUM,
|
|
echo: { flag: /^-[neE]+$/, literal: true },
|
|
printf: { flag: /^--$/, literal: true },
|
|
readlink: { flag: /^-[efmnqsvz]+$/ },
|
|
tr: { flag: /^-[cCdst]+$/, maxOperands: 2 },
|
|
realpath: { flag: /^-[eLmPqsz]+$/ },
|
|
};
|
|
|
|
/** Tools that may receive a pipe. Each one only prints what it reads. */
|
|
export const STDIN_TOOLS: ReadonlySet<string> = new Set(['wc', 'head', 'tail', 'cat', 'grep', 'rg', 'sort', 'uniq', 'cut', 'nl', 'sha256sum', 'sha1sum', 'sha512sum', 'md5sum', 'sed', 'tr']);
|
|
|
|
const FIND_FLAGS = new Set(['-print', '-print0', '-prune', '-empty', '-true', '-false', '-a', '-o', '-and', '-or', '-not',
|
|
'-readable', '-writable', '-executable', '-depth', '-nowarn', '-xdev', '-mount', '-quit', '-ls', '(', ')', '!', ',']);
|
|
const FIND_VALUES = new Set(['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', '-regextype',
|
|
'-type', '-xtype', '-maxdepth', '-mindepth', '-size', '-mtime', '-mmin', '-atime', '-amin', '-ctime', '-cmin', '-perm',
|
|
'-user', '-group', '-uid', '-gid', '-links', '-inum', '-lname', '-ilname', '-used', '-printf']);
|
|
const FIND_PATHS = new Set(['-newer', '-anewer', '-cnewer', '-samefile']);
|
|
|
|
/** Interface text an opted-in fixture declares to its actor. */
|
|
export function readOnlyInterfaceText(root: string): string {
|
|
return `Shared read-only grammar: Bash also accepts pure local reads whose every path, unquoted glob matches included, resolves inside ${root} with no .., symlink or absolute-path escape: ${Object.keys(READ_TOOLS).join(', ')} (find without -exec, -ok, -delete or -fprint*; sed only as -n with a line-range print script such as sed -n 1,200p; no output-file, symlink-following or external-program options; echo and printf with literal arguments only, no printf -v; tr only after a pipe, with -c, -C, -d, -s or -t and one or two literal sets that hold no $, backtick, brace or unquoted glob). An unquoted glob may stand only as a path operand; quote a glob inside an option or pattern (grep -rn word . --include='*.md', find . -name '*.md'), because the shell expands an unquoted one before the command runs. Join only these reads with ;, && or ||, pipe only into ${[...STDIN_TOOLS].join(', ')}, and cd only into a directory inside that root. Redirects other than 2>/dev/null or 2>&1 right after a read's arguments, tee, substitutions, variables, tilde or brace expansion, background jobs, multi-line commands and every other tool stay outside this grammar.`;
|
|
}
|
|
|
|
export function readOnlyCommandAllowed(command: string, scope: ReadOnlyScope): boolean {
|
|
return readOnlyCommandRejection(command, scope) === undefined;
|
|
}
|
|
|
|
/** Why the grammar rejects a command, or undefined for an accepted pure local read. */
|
|
export function readOnlyCommandRejection(command: string, scope: ReadOnlyScope): string | undefined {
|
|
const tokens = tokenize(command.trim());
|
|
if (typeof tokens === 'string') return tokens;
|
|
let root: string;
|
|
try { root = fs.realpathSync.native(scope.root); } catch { return 'fixture root does not exist'; }
|
|
const start = physicalPath(path.resolve(scope.root, scope.cwd ?? '.'));
|
|
if (!start || !inside(start, root)) return 'starting directory outside root';
|
|
const context: Context = { root, deny: scope.deny ?? (() => false) };
|
|
let cwds = [start];
|
|
let pipeline: Word[][] = [[]];
|
|
const finish = (): string | undefined => {
|
|
if (pipeline.some(words => !words.length)) return 'empty command';
|
|
if (pipeline.length === 1 && pipeline[0][0].text === 'cd' && !pipeline[0][0].quoted) {
|
|
const targets = cdTargets(pipeline[0], cwds, context);
|
|
if (typeof targets === 'string') return targets;
|
|
cwds = [...new Set([...cwds, ...targets])];
|
|
} else {
|
|
for (const [index, words] of pipeline.entries()) {
|
|
const rejection = commandRejection(words, index > 0, cwds, context);
|
|
if (rejection) return rejection;
|
|
}
|
|
}
|
|
pipeline = [[]];
|
|
};
|
|
for (const [index, token] of tokens.entries()) {
|
|
if (!('op' in token)) { pipeline.at(-1)!.push(token); continue; }
|
|
if (token.op === '|') { pipeline.push([]); continue; }
|
|
if (token.op === ';' && index === tokens.length - 1) break;
|
|
const rejection = finish();
|
|
if (rejection) return rejection;
|
|
}
|
|
return finish();
|
|
}
|
|
|
|
function tokenize(command: string): Token[] | string {
|
|
if (!command) return 'empty command';
|
|
if (/[\x00-\x08\x0a-\x1f\x7f]/.test(command)) return 'multi-line or control characters';
|
|
const tokens: Token[] = [];
|
|
let word: Word | undefined;
|
|
const end = () => { if (word) tokens.push(word); word = undefined; };
|
|
const append = (text: string, quoted: boolean) => {
|
|
word ??= { text: '', glob: false, quoted: false };
|
|
word.text += text;
|
|
word.quoted ||= quoted;
|
|
};
|
|
for (let i = 0; i < command.length; i++) {
|
|
const c = command[i];
|
|
if (c === ' ' || c === '\t') { end(); continue; }
|
|
if (c === '|' || c === '&' || c === ';') {
|
|
end();
|
|
const pair = command.slice(i, i + 2);
|
|
if (pair === '||' || pair === '&&') { tokens.push({ op: pair }); i++; continue; }
|
|
if (c === '&' || pair === '|&' || pair === ';;') return 'background job or unsupported operator';
|
|
tokens.push({ op: c as '|' | ';' });
|
|
continue;
|
|
}
|
|
if (c === "'") {
|
|
const close = command.indexOf("'", i + 1);
|
|
if (close < 0) return 'unterminated quote';
|
|
append(command.slice(i + 1, close), true);
|
|
i = close;
|
|
continue;
|
|
}
|
|
if (c === '"') {
|
|
let text = '';
|
|
for (i++; i < command.length && command[i] !== '"'; i++) {
|
|
if (command[i] === '$' || command[i] === '`') return 'substitution or variable';
|
|
if (command[i] === '\\' && /[$`"\\]/.test(command[i + 1] ?? '"')) return 'escape inside double quotes';
|
|
text += command[i];
|
|
}
|
|
if (i >= command.length) return 'unterminated quote';
|
|
append(text, true);
|
|
continue;
|
|
}
|
|
if (c === '\\') {
|
|
const next = command[i + 1];
|
|
if (word || !next || !'()!'.includes(next) || !/^[ \t]?$/.test(command[i + 2] ?? '')) return 'shell escape';
|
|
tokens.push({ text: next, glob: false, quoted: true });
|
|
i++;
|
|
continue;
|
|
}
|
|
if (c === '$' || c === '`') return 'substitution or variable';
|
|
const discard = c === '>' && word?.text === '2' && !word.quoted && tokens.length > 0 && !('op' in tokens.at(-1)!)
|
|
? /^>(?:\/dev\/null|&1)(?=[ \t;|&]|$)/.exec(command.slice(i)) : null;
|
|
if (discard) { word = undefined; i += discard[0].length - 1; continue; }
|
|
if (c === '>' || c === '<') return 'redirect';
|
|
if ('(){}'.includes(c)) return 'subshell, grouping or brace expansion';
|
|
if (!word && (c === '~' || c === '#')) return 'tilde expansion or comment';
|
|
if (!/[A-Za-z0-9_./:@%+,=^!*?[\]-]/.test(c)) return `unsupported unquoted character ${JSON.stringify(c)}`;
|
|
append(c, false);
|
|
if ('*?['.includes(c)) word!.glob = true;
|
|
}
|
|
end();
|
|
return tokens;
|
|
}
|
|
|
|
type Context = { root: string; deny: (physical: string) => boolean };
|
|
|
|
function cdTargets(words: Word[], cwds: string[], context: Context): string[] | string {
|
|
if (words.length !== 2 || words[1].glob || words[1].text.startsWith('-')) return 'cd needs exactly one literal directory';
|
|
const targets: string[] = [];
|
|
for (const cwd of cwds) {
|
|
const target = physicalPath(path.isAbsolute(words[1].text) ? words[1].text : `${cwd}/${words[1].text}`);
|
|
if (!target || !inside(target, context.root)) return 'cd outside root';
|
|
if (!fs.statSync(target, { throwIfNoEntry: false })?.isDirectory()) return 'cd target is not a directory';
|
|
targets.push(target);
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
function commandRejection(words: Word[], piped: boolean, cwds: string[], context: Context): string | undefined {
|
|
const [name, ...args] = words;
|
|
if (name.quoted || name.glob) return 'quoted or globbed command name';
|
|
if (name.text === 'cd') return 'cd inside a pipeline';
|
|
const spec = READ_TOOLS[name.text];
|
|
if (!spec) return `unlisted tool: ${name.text}`;
|
|
if (piped && !STDIN_TOOLS.has(name.text)) return `${name.text} cannot read a pipe`;
|
|
if (name.text === 'find') return findRejection(args, cwds, context);
|
|
if (name.text === 'tr') return trRejection(args, piped);
|
|
if (name.text === 'sed') {
|
|
const start = args.findIndex(arg => !/^(?:-n|--quiet|--silent)$/.test(arg.text));
|
|
const [script, ...files] = start < 0 ? [] : args.slice(start);
|
|
if (!script || !/^(?:\d+|\$)(?:,(?:\d+|\$))?p$/.test(script.text) || files.some(file => file.text.startsWith('-')))
|
|
return 'sed takes only -n and a line-range print script';
|
|
return pathsRejection(files, cwds, context, false);
|
|
}
|
|
if (spec.literal) {
|
|
if (args.some(arg => arg.glob)) return 'unquoted glob in literal arguments';
|
|
if (name.text === 'printf' && args[0]?.text.startsWith('-') && args[0].text !== '--') return `unsupported printf option: ${args[0].text}`;
|
|
return undefined;
|
|
}
|
|
const options: string[] = [];
|
|
const operands: Word[] = [];
|
|
const paths: Word[] = [];
|
|
let patternGiven = false;
|
|
let endOfOptions = false;
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (endOfOptions || !arg.text.startsWith('-') || arg.text === '-') { operands.push(arg); continue; }
|
|
if (arg.text === '--') { endOfOptions = true; continue; }
|
|
if (arg.glob) return `globbed option: ${arg.text}; quote the glob: ${arg.text.replace(/^(--?[^=]+=)?(.*)$/s, "$1'$2'")}`;
|
|
options.push(arg.text);
|
|
const attached = spec.pathAttached?.exec(arg.text);
|
|
if (attached) { paths.push({ text: attached[1], glob: false, quoted: true }); patternGiven = true; continue; }
|
|
const patternOption = spec.pattern && /^(?:-e|--regexp)(?:=.+|.+)?$/.exec(arg.text);
|
|
if (patternOption && arg.text !== '-e' && arg.text !== '--regexp') { patternGiven = true; continue; }
|
|
const takesPath = spec.pathValued?.test(arg.text) ?? false;
|
|
if (takesPath || patternOption || spec.valued?.test(arg.text)) {
|
|
const value = args[++i];
|
|
if (!value) return `missing value for ${arg.text}`;
|
|
if (value.glob) return `unquoted glob in ${arg.text} value`;
|
|
options.push(value.text);
|
|
if (takesPath) paths.push(value);
|
|
if (takesPath || patternOption) patternGiven = true;
|
|
continue;
|
|
}
|
|
if (!spec.flag.test(arg.text)) return `unsupported ${name.text} option: ${arg.text}`;
|
|
}
|
|
if (spec.pattern && !patternGiven) {
|
|
const pattern = operands.shift();
|
|
if (!pattern) return `${name.text} needs a pattern`;
|
|
if (pattern.glob) return 'unquoted glob as a pattern';
|
|
}
|
|
if (spec.maxOperands !== undefined && operands.length > spec.maxOperands) return `${name.text} takes at most ${spec.maxOperands} operand(s)`;
|
|
const recursive = spec.recursive?.(options) ?? false;
|
|
if (recursive && !operands.length) operands.push({ text: '.', glob: false, quoted: true });
|
|
return pathsRejection([...operands.filter(operand => operand.text !== '-'), ...paths], cwds, context, recursive);
|
|
}
|
|
|
|
function trRejection(args: Word[], piped: boolean): string | undefined {
|
|
if (!piped) return 'tr reads only a pipe';
|
|
let start = 0;
|
|
while (start < args.length && args[start].text.startsWith('-') && args[start].text.length > 1) {
|
|
if (args[start].text === '--') { start++; break; }
|
|
if (!READ_TOOLS.tr.flag.test(args[start].text)) return `unsupported tr option: ${args[start].text}`;
|
|
start++;
|
|
}
|
|
const sets = args.slice(start);
|
|
if (!sets.length || sets.length > READ_TOOLS.tr.maxOperands!) return 'tr takes one or two literal sets';
|
|
if (sets.some(set => set.glob || /[$`{}]/.test(set.text))) return 'tr sets must be literal';
|
|
}
|
|
|
|
function findRejection(args: Word[], cwds: string[], context: Context): string | undefined {
|
|
let i = args[0]?.text === '-P' ? 1 : 0;
|
|
const paths: Word[] = [];
|
|
for (; i < args.length && !/^[-(!),]/.test(args[i].text); i++) paths.push(args[i]);
|
|
if (!paths.length) paths.push({ text: '.', glob: false, quoted: true });
|
|
for (; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg.glob) return `unquoted glob in find expression: ${arg.text}`;
|
|
if (FIND_FLAGS.has(arg.text)) continue;
|
|
if (!FIND_VALUES.has(arg.text) && !FIND_PATHS.has(arg.text)) return `unsupported find primary: ${arg.text}`;
|
|
const value = args[++i];
|
|
if (!value) return `missing value for ${arg.text}`;
|
|
if (value.glob) return `unquoted glob in ${arg.text} value`;
|
|
if (FIND_PATHS.has(arg.text)) paths.push(value);
|
|
}
|
|
return pathsRejection(paths, cwds, context, false);
|
|
}
|
|
|
|
function pathsRejection(words: Word[], cwds: string[], context: Context, recursive: boolean): string | undefined {
|
|
for (const cwd of cwds) {
|
|
for (const word of words) {
|
|
if (!word.text) return 'empty path';
|
|
const candidates = word.glob ? expandGlob(word.text, cwd, context) : [word.text];
|
|
if (typeof candidates === 'string') return candidates;
|
|
for (const candidate of candidates) {
|
|
if (word.glob && candidate.startsWith('-')) return 'glob expands to an option';
|
|
const physical = physicalPath(path.isAbsolute(candidate) ? candidate : `${cwd}/${candidate}`);
|
|
if (!physical || !inside(physical, context.root)) return `path outside root: ${candidate}`;
|
|
if (context.deny(physical)) return `denied path: ${candidate}`;
|
|
if (recursive && deniedBelow(physical, context)) return `recursive search reaches a denied path: ${candidate}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Bash pathname expansion without dotglob, globstar or extglob; a pattern with no match stays literal. */
|
|
function expandGlob(pattern: string, cwd: string, context: Context): string[] | string {
|
|
const segments = pattern.split('/');
|
|
if (segments.includes('..')) return 'glob with ..';
|
|
const first = segments.findIndex(segment => /[*?[]/.test(segment));
|
|
const prefix = segments.slice(0, first).join('/');
|
|
const base = physicalPath(pattern.startsWith('/') ? prefix || '/' : `${cwd}/${prefix}`);
|
|
if (!base || !inside(base, context.root)) return `glob outside root: ${pattern}`;
|
|
let matches = [{ shown: prefix, actual: base }];
|
|
for (const segment of segments.slice(first)) {
|
|
const regex = globSegment(segment);
|
|
matches = matches.flatMap(match => {
|
|
const join = (name: string) => ({ shown: match.shown || pattern.startsWith('/') ? `${match.shown}/${name}` : name, actual: path.join(match.actual, name) });
|
|
if (!regex) return segment === '' || fs.lstatSync(path.join(match.actual, segment), { throwIfNoEntry: false }) ? [join(segment)] : [];
|
|
let names: string[];
|
|
try { names = fs.readdirSync(match.actual); } catch { return []; }
|
|
return names.filter(name => regex.test(name) && (segment.startsWith('.') || !name.startsWith('.'))).sort().map(join);
|
|
});
|
|
}
|
|
return matches.length ? matches.map(match => match.shown) : [pattern];
|
|
}
|
|
|
|
function globSegment(segment: string): RegExp | undefined {
|
|
if (!/[*?[]/.test(segment)) return undefined;
|
|
let source = '';
|
|
for (let i = 0; i < segment.length; i++) {
|
|
const c = segment[i];
|
|
const close = c === '[' ? segment.indexOf(']', i + 2) : -1;
|
|
if (c === '*') source += '[^/]*';
|
|
else if (c === '?') source += '[^/]';
|
|
else if (close > 0) {
|
|
const body = segment.slice(i + 1, close).replace(/\\/g, '\\\\');
|
|
source += `[${body.startsWith('!') ? '^' + body.slice(1) : body}]`;
|
|
i = close;
|
|
} else source += c.replace(/[\\^$.|+(){}[\]]/g, '\\$&');
|
|
}
|
|
return new RegExp(`^${source}$`);
|
|
}
|
|
|
|
/** The kernel's view of a path: symlinks resolved in order and .. applied to the resolved parent. */
|
|
function physicalPath(file: string): string | null {
|
|
const parts = file.split('/');
|
|
let current = '/';
|
|
for (const [index, part] of parts.entries()) {
|
|
if (part === '' || part === '.') continue;
|
|
if (part === '..') { current = path.dirname(current); continue; }
|
|
const next = path.join(current, part);
|
|
const entry = fs.lstatSync(next, { throwIfNoEntry: false });
|
|
if (!entry) {
|
|
const rest = parts.slice(index + 1);
|
|
return rest.includes('..') ? null : path.join(next, ...rest);
|
|
}
|
|
if (!entry.isSymbolicLink()) { current = next; continue; }
|
|
try { current = fs.realpathSync.native(next); } catch { return null; }
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function inside(file: string, root: string): boolean {
|
|
return file === root || file.startsWith(root === '/' ? root : root + '/');
|
|
}
|
|
|
|
/** True when a content search below directory would reach a denied path (or the tree is too large to prove it would not). */
|
|
function deniedBelow(directory: string, context: Context): boolean {
|
|
if (!fs.lstatSync(directory, { throwIfNoEntry: false })?.isDirectory()) return false;
|
|
let budget = 20_000;
|
|
const pending = [directory];
|
|
while (pending.length) {
|
|
const current = pending.pop()!;
|
|
let entries: fs.Dirent[];
|
|
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return true; }
|
|
for (const entry of entries) {
|
|
const child = path.join(current, entry.name);
|
|
if (--budget < 0 || context.deny(child)) return true;
|
|
if (entry.isDirectory()) pending.push(child);
|
|
}
|
|
}
|
|
return false;
|
|
}
|