Files
my-pi/pi-permission-system/src/access-intent/bash/token-collection.ts
T

434 lines
14 KiB
TypeScript

import { basename } from "node:path";
import {
EXECUTION_HOST_TYPES,
forEachNestedExecution,
NESTED_EXECUTION_CONTEXTS,
} from "#src/access-intent/bash/nested-execution";
import {
ARG_NODE_TYPES,
resolveNodeText,
SKIP_SUBTREE_TYPES,
} from "#src/access-intent/bash/node-text";
import type { TSNode } from "#src/access-intent/bash/parser";
// ── Public surface ─────────────────────────────────────────────────────────
/**
* Recursively visit the AST and collect resolved text of nodes that
* represent command arguments or redirect destinations.
*
* Reads no text from `heredoc_body`, `heredoc_end`, or `comment` subtrees, but
* still descends an execution host for the commands it hosts — an interpolating
* heredoc body runs its substitution even though its prose is never an operand
* (#741). That is why the {@link EXECUTION_HOST_TYPES} branch sits above the
* {@link SKIP_SUBTREE_TYPES} check: `heredoc_body` is in both sets, and the
* host reading is the one that must win.
*
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
* argument skipping to avoid collecting inline patterns/scripts
* as path candidates. For all other commands, collects all
* arguments generically.
*/
export function collectPathCandidateTokens(node: TSNode): string[] {
if (node.type === "command") return collectCommandTokens(node);
if (node.type === "file_redirect") return collectRedirectTokens(node);
if (EXECUTION_HOST_TYPES.has(node.type)) {
return collectHostedExecutionTokens(node);
}
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
const tokens: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) tokens.push(...collectPathCandidateTokens(child));
}
return tokens;
}
/**
* Select the collection strategy for a `command` node: pattern-first
* commands use `collectPatternCommandTokens`; all others use
* `collectGenericCommandTokens`.
*/
export function collectCommandTokens(node: TSNode): string[] {
const commandName = extractCommandName(node);
const config = commandName
? PATTERN_FIRST_COMMANDS.get(commandName)
: undefined;
const tokens = config
? collectPatternCommandTokens(node, config)
: collectGenericCommandTokens(node);
return [...tokens, ...collectEmbeddedOptionValues(node)];
}
/**
* Collect redirect-destination tokens from a `file_redirect` node.
*
* The destination itself is an argument value (`> out.txt`), but it can also
* host a command that really runs (`> $(cat /etc/shadow)`, `< <(cmd)`), whose
* own operands are path candidates too — so each child is both read for its
* text and searched for nested executions (#741).
*
* Both passes are needed: a substitution can be the destination outright, or be
* concatenated into it (`> ${DIR}/$(cmd)`), and a `concatenation` is itself an
* argument node.
*/
export function collectRedirectTokens(node: TSNode): string[] {
const tokens: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (ARG_NODE_TYPES.has(child.type)) {
tokens.push(resolveNodeText(child));
}
tokens.push(...collectHostedExecutionTokens(child));
}
return tokens;
}
/**
* Collect the path-candidate tokens of every command nested inside `node`'s
* execution contexts, reading none of the host subtree's own text.
*
* This is what lets a heredoc body contribute its substitution's operands while
* its prose stays out of the path surface entirely.
*
* `node` may be a context outright (`> $(cmd)`) or merely contain one
* (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
* subtree, so the first case is checked here.
*/
function collectHostedExecutionTokens(node: TSNode): string[] {
if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
return collectPathCandidateTokens(node);
}
const tokens: string[] = [];
forEachNestedExecution(node, (contextNode) => {
tokens.push(...collectPathCandidateTokens(contextNode));
});
return tokens;
}
/**
* Extract the command name from a `command` node.
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
* if the command name cannot be determined (e.g. variable expansion).
*/
export function extractCommandName(node: TSNode): string | undefined {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === "command_name") {
const text = resolveNodeText(child);
return text ? basename(text) : undefined;
}
}
return undefined;
}
// ── Private helpers and config ─────────────────────────────────────────────
/**
* A long or short option carrying its value inline: one or two leading dashes,
* a name containing no `=` or whitespace, then `=` and a non-empty value.
* Only the first `=` separates, so `--opt=/tmp/a=b` yields `/tmp/a=b`.
*/
const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
/**
* The values embedded in this command's `--opt=value` argument tokens.
*
* Read straight from the argument nodes rather than from the collected token
* list, because a pattern-first command's collector classifies a flag and never
* emits it — so `grep --file=/tmp/patterns` would otherwise lose the path.
*
* This is token *preprocessing*, not classification: the extracted value is
* handed to the ordinary shape classifiers and existence probe, so
* `--file=/tmp/patterns` reaches the path surfaces while `--format=json`
* yields a bare `json` that names nothing and is dropped. Keeping the split
* here is what lets the projection see option-embedded paths without per-command
* option tables (ADR 0009, #645).
*/
function collectEmbeddedOptionValues(node: TSNode): string[] {
const values: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === "command_name" || child.type === "variable_assignment")
continue;
if (!ARG_NODE_TYPES.has(child.type)) continue;
const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
if (value !== undefined) values.push(value);
}
return values;
}
interface PatternCommandConfig {
/** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
readonly argConsumingFlags: ReadonlySet<string>;
/** Flags that consume the next argument as a file path */
readonly fileConsumingFlags: ReadonlySet<string>;
/**
* Number of leading positional arguments that are patterns/scripts, not paths.
* Default: 1 (covers sed, awk, grep, rg).
* sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
*/
readonly patternPositionals?: number;
}
/**
* Commands whose first N positional arguments are inline patterns/scripts,
* not filesystem paths. The map stores per-command flag configuration so
* the walker can correctly identify which arguments are consumed by flags
* vs. which are positional.
*/
const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
new Map([
[
"sed",
{
argConsumingFlags: new Set(["-e", "-i"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"awk",
{
argConsumingFlags: new Set(["-e", "-F", "-v"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"gawk",
{
argConsumingFlags: new Set(["-e", "-F", "-v"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"nawk",
{
argConsumingFlags: new Set(["-e", "-F", "-v"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"grep",
{
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"egrep",
{
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"fgrep",
{
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"rg",
{
argConsumingFlags: new Set([
"-e",
"-A",
"-B",
"-C",
"-m",
"-g",
"-t",
"-T",
"-j",
"-M",
"-r",
"-E",
]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"sd",
{
argConsumingFlags: new Set(["-n", "-f"]),
fileConsumingFlags: new Set([]),
patternPositionals: 2,
},
],
]);
/**
* Describes what the walker should do when it encounters a flag word inside
* a pattern-first command. Using a discriminated union lets the `switch` in
* `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
* assertion (which would trigger the Biome/ESLint assertion conflict).
*/
type PatternCommandFlagDirective =
| { kind: "end-of-flags" }
| { kind: "regular-flag" }
| {
kind: "consume-arg";
nextArgAction: "skip" | "extract";
setsExplicitScript: boolean;
};
/**
* Classify a flag word from a pattern-first command into a directive that
* tells the walker how to handle the flag and its following argument.
*/
function classifyPatternCommandFlag(
text: string,
config: PatternCommandConfig,
): PatternCommandFlagDirective {
if (text === "--") return { kind: "end-of-flags" };
if (config.argConsumingFlags.has(text)) {
return {
kind: "consume-arg",
nextArgAction: "skip",
setsExplicitScript: text === "-e" || text === "-f",
};
}
if (config.fileConsumingFlags.has(text)) {
return {
kind: "consume-arg",
nextArgAction: "extract",
setsExplicitScript: true,
};
}
return { kind: "regular-flag" };
}
/**
* Collect path-candidate tokens from a command known to have
* pattern/script arguments in leading positional slots.
*
* Uses position-based skipping: the first N positional arguments
* (where N = patternPositionals, default 1) are assumed to be
* inline patterns/scripts and are skipped. Remaining positional
* arguments are collected as path candidates.
*
* Flags listed in `argConsumingFlags` consume the next argument
* (skipped). Flags in `fileConsumingFlags` consume the next
* argument as a file path (collected). The flags `-e` and `-f`
* additionally signal that an explicit script was provided via
* flag, so no inline positional script is expected.
*/
function collectPatternCommandTokens(
node: TSNode,
config: PatternCommandConfig,
): string[] {
const patternPositionals = config.patternPositionals ?? 1;
let hasExplicitScript = false;
let positionalsSeen = 0;
let nextArgAction: "skip" | "extract" | null = null;
let pastEndOfFlags = false;
const tokens: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
// Skip command_name and variable_assignment nodes.
if (child.type === "command_name" || child.type === "variable_assignment")
continue;
// Only process argument-like nodes; recurse into others
// (e.g. command_substitution) for nested commands.
if (!ARG_NODE_TYPES.has(child.type)) {
tokens.push(...collectPathCandidateTokens(child));
continue;
}
const text = resolveNodeText(child);
// Handle consumed argument from previous flag.
if (nextArgAction === "skip") {
nextArgAction = null;
continue;
}
if (nextArgAction === "extract") {
tokens.push(text);
nextArgAction = null;
continue;
}
// Flag detection (only before "--" end-of-flags marker).
if (
!pastEndOfFlags &&
child.type === "word" &&
text.startsWith("-") &&
text.length > 1
) {
const directive = classifyPatternCommandFlag(text, config);
switch (directive.kind) {
case "end-of-flags":
pastEndOfFlags = true;
break;
case "consume-arg":
nextArgAction = directive.nextArgAction;
if (directive.setsExplicitScript) hasExplicitScript = true;
break;
case "regular-flag":
break;
}
continue;
}
// Positional argument.
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
positionalsSeen++;
continue; // Skip: this is an inline pattern/script.
}
// File argument — collect as path candidate.
tokens.push(text);
}
return tokens;
}
/**
* Collect all argument tokens from a generic (non-pattern-first) command node,
* skipping the command name and variable assignments.
*/
function collectGenericCommandTokens(node: TSNode): string[] {
const tokens: string[] = [];
let seenCommandName = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === "command_name") {
seenCommandName = true;
continue;
}
// Skip variable_assignment nodes (FOO=/bar)
if (child.type === "variable_assignment") continue;
// If there was no explicit command_name node, the first word-like
// child is the command name itself — skip it.
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
seenCommandName = true;
continue;
}
// Argument nodes: resolve their text and collect.
if (ARG_NODE_TYPES.has(child.type)) {
tokens.push(resolveNodeText(child));
continue;
}
// Recurse into other children (e.g. command_substitution nested in args)
tokens.push(...collectPathCandidateTokens(child));
}
return tokens;
}