mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 19:44:48 +01:00
Fable 5.1 (CI's eval model) read the grammar's 'Join them with ;, && or ||' as covering Git reads: PR E2E 39186278010 failed 5 docsync cases on 21 joined commands. The interface now says joins cover only the grammar's own reads and Git, Preamble, lifecycle and candidate commands each run as their own Bash call. - read-only grammar: sed -n with a line-range print script (file or pipe) - docsync: a trailing ; on a declared read is a harmless wrapper; the completed- read check credits a cat inside an accepted grammar line (not piped, no cd); the Claude config dir is listable and settings.json readable while .claude.json and every other config file stay denied - focused Fable reruns: ship-docsync-missing-asset and ship-docsync-failure pass Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
399 lines
21 KiB
TypeScript
399 lines
21 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, 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.
|
|
* Rejected: every other tool, redirects, 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]+$/ },
|
|
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']);
|
|
|
|
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). Join only these reads with ;, && or ||, pipe only into ${[...STDIN_TOOLS].join(', ')}, and cd only into a directory inside that root. Redirects, 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';
|
|
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 === '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}`;
|
|
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 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;
|
|
}
|