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

430 lines
15 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { TSNode } from "#src/access-intent/bash/parser";
import { getParser } from "#src/access-intent/bash/parser";
import {
collectCommandTokens,
collectPathCandidateTokens,
collectRedirectTokens,
extractCommandName,
} from "#src/access-intent/bash/token-collection";
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Depth-first search for the first node of the given type. */
function findNode(node: TSNode, type: string): TSNode | null {
if (node.type === type) return node;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
const found = findNode(child, type);
if (found) return found;
}
return null;
}
/** Parse a bash snippet and return the first `command` node. */
async function parseCommandNode(cmd: string): Promise<{
node: TSNode;
tree: { rootNode: TSNode; delete(): void };
}> {
const parser = await getParser();
const tree = parser.parse(cmd);
if (!tree) throw new Error("parser.parse returned null");
const node = findNode(tree.rootNode, "command");
if (!node) throw new Error(`no command node found in: ${cmd}`);
return { node, tree };
}
/** Parse a bash snippet and return the first `file_redirect` node. */
async function parseRedirectNode(cmd: string): Promise<{
node: TSNode;
tree: { rootNode: TSNode; delete(): void };
}> {
const parser = await getParser();
const tree = parser.parse(cmd);
if (!tree) throw new Error("parser.parse returned null");
const node = findNode(tree.rootNode, "file_redirect");
if (!node) throw new Error(`no file_redirect node found in: ${cmd}`);
return { node, tree };
}
// ── extractCommandName ────────────────────────────────────────────────────────
describe("extractCommandName", () => {
it("returns the basename for a bare command", async () => {
const { node, tree } = await parseCommandNode("sed 's/x/y/' file.txt");
try {
expect(extractCommandName(node)).toBe("sed");
} finally {
tree.delete();
}
});
it("strips the directory prefix from an absolute command path", async () => {
const { node, tree } = await parseCommandNode(
"/usr/bin/sed 's/x/y/' file.txt",
);
try {
expect(extractCommandName(node)).toBe("sed");
} finally {
tree.delete();
}
});
it("returns the substitution text when the command name is a command substitution", async () => {
// $(which sed) parses with a command_name child whose text is "$(which sed)";
// resolveNodeText returns that text, so extractCommandName returns its basename.
// PATTERN_FIRST_COMMANDS.get("$(which sed)") returns undefined, so
// collectCommandTokens falls back to generic collection — correct behaviour.
const { node, tree } = await parseCommandNode(
"$(which sed) 's/x/y/' file.txt",
);
try {
expect(extractCommandName(node)).toBe("$(which sed)");
} finally {
tree.delete();
}
});
});
// ── collectCommandTokens — pattern-first commands ─────────────────────────────
describe("collectCommandTokens — pattern-first commands", () => {
it("sed: skips the first positional (inline pattern) and collects the rest", async () => {
const { node, tree } = await parseCommandNode("sed 's/x/y/' a.txt b.txt");
try {
expect(collectCommandTokens(node)).toEqual(["a.txt", "b.txt"]);
} finally {
tree.delete();
}
});
it("sed -e: skips the explicit script arg-consuming flag and collects positionals", async () => {
const { node, tree } = await parseCommandNode("sed -e 's/x/y/' file.txt");
try {
// -e consumes the next argument (the script), so file.txt is the first positional
// Since hasExplicitScript is set by -e, the positional is not skipped
expect(collectCommandTokens(node)).toEqual(["file.txt"]);
} finally {
tree.delete();
}
});
it("sed -f: treats the next argument as a file path (file-consuming flag)", async () => {
const { node, tree } = await parseCommandNode(
"sed -f /scripts/script.sed file.txt",
);
try {
// -f consumes the next arg as a file path (extracted), and sets hasExplicitScript
expect(collectCommandTokens(node)).toEqual([
"/scripts/script.sed",
"file.txt",
]);
} finally {
tree.delete();
}
});
it("grep: skips the first positional (pattern) and collects file arguments", async () => {
const { node, tree } = await parseCommandNode(
"grep pattern /etc/hosts /etc/passwd",
);
try {
expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
} finally {
tree.delete();
}
});
it("grep -e: with explicit -e flag, all positionals are file arguments", async () => {
const { node, tree } = await parseCommandNode("grep -e pattern /etc/hosts");
try {
expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
} finally {
tree.delete();
}
});
it("grep: end-of-flags (--) causes subsequent args to be treated as positionals", async () => {
const { node, tree } = await parseCommandNode("grep -- pattern /etc/hosts");
try {
// After --, both 'pattern' (first positional) and '/etc/hosts' are positionals.
// pattern is the pattern positional and is skipped; /etc/hosts is collected.
expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
} finally {
tree.delete();
}
});
it("sd: skips the first two positionals (FIND and REPLACE_WITH) as patterns", async () => {
const { node, tree } = await parseCommandNode(
"sd find replace file.txt other.txt",
);
try {
expect(collectCommandTokens(node)).toEqual(["file.txt", "other.txt"]);
} finally {
tree.delete();
}
});
it("rg: skips the pattern positional and collects file/dir arguments", async () => {
const { node, tree } = await parseCommandNode("rg pattern /etc/");
try {
expect(collectCommandTokens(node)).toEqual(["/etc/"]);
} finally {
tree.delete();
}
});
});
// ── collectCommandTokens — generic commands ───────────────────────────────────
describe("collectCommandTokens — generic commands", () => {
it("collects all argument tokens after the command name", async () => {
const { node, tree } = await parseCommandNode("cat /etc/hosts /etc/passwd");
try {
expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
} finally {
tree.delete();
}
});
it("skips variable assignment prefixes", async () => {
const { node, tree } = await parseCommandNode("FOO=/bar cat /etc/hosts");
try {
expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
} finally {
tree.delete();
}
});
it("collects no tokens for a bare command with no arguments", async () => {
const { node, tree } = await parseCommandNode("ls");
try {
expect(collectCommandTokens(node)).toEqual([]);
} finally {
tree.delete();
}
});
});
// ── collectRedirectTokens ─────────────────────────────────────────────────────
describe("collectRedirectTokens", () => {
it("collects the destination path from a stdout redirect", async () => {
const { node, tree } = await parseRedirectNode(
"cat /etc/hosts > /tmp/out.txt",
);
try {
expect(collectRedirectTokens(node)).toEqual(["/tmp/out.txt"]);
} finally {
tree.delete();
}
});
it("collects the destination path from an append redirect", async () => {
const { node, tree } = await parseRedirectNode(
"echo hello >> /tmp/log.txt",
);
try {
expect(collectRedirectTokens(node)).toEqual(["/tmp/log.txt"]);
} finally {
tree.delete();
}
});
it("collects the source path from a stdin redirect", async () => {
const { node, tree } = await parseRedirectNode("cat < /etc/hosts");
try {
expect(collectRedirectTokens(node)).toEqual(["/etc/hosts"]);
} finally {
tree.delete();
}
});
describe("operands of a hosted nested command (#741)", () => {
it("collects the operand of a substitution used as the destination", async () => {
const { node, tree } = await parseRedirectNode(
"echo hi > $(cat /etc/shadow)",
);
try {
expect(collectRedirectTokens(node)).toEqual(["/etc/shadow"]);
} finally {
tree.delete();
}
});
it("collects the operand of a process substitution read as input", async () => {
const { node, tree } = await parseRedirectNode(
"cat < <(cat /etc/shadow)",
);
try {
expect(collectRedirectTokens(node)).toEqual(["/etc/shadow"]);
} finally {
tree.delete();
}
});
it("collects both the destination text and a concatenated operand", async () => {
const { node, tree } = await parseRedirectNode(
"echo hi > /tmp/$(cat /etc/shadow)",
);
try {
expect(collectRedirectTokens(node)).toEqual([
"/tmp/$(cat /etc/shadow)",
"/etc/shadow",
]);
} finally {
tree.delete();
}
});
});
});
// ── collectPathCandidateTokens ────────────────────────────────────────────────
describe("collectPathCandidateTokens", () => {
it("collects all argument tokens from a simple command via the program root", async () => {
const parser = await getParser();
const tree = parser.parse("cat /etc/hosts");
try {
if (!tree) throw new Error("parse returned null");
expect(collectPathCandidateTokens(tree.rootNode)).toEqual(["/etc/hosts"]);
} finally {
tree?.delete();
}
});
it("collects redirect destinations as well as command arguments", async () => {
const parser = await getParser();
const tree = parser.parse("cat /etc/hosts > /tmp/out.txt");
try {
if (!tree) throw new Error("parse returned null");
expect(collectPathCandidateTokens(tree.rootNode)).toEqual([
"/etc/hosts",
"/tmp/out.txt",
]);
} finally {
tree?.delete();
}
});
it("returns empty array for heredoc-only content (SKIP_SUBTREE_TYPES)", async () => {
const parser = await getParser();
const tree = parser.parse("cat <<EOF\nhello\nEOF");
try {
if (!tree) throw new Error("parse returned null");
// heredoc_body is in SKIP_SUBTREE_TYPES — its text must not be collected
const tokens = collectPathCandidateTokens(tree.rootNode);
expect(tokens).not.toContain("hello");
} finally {
tree?.delete();
}
});
describe("operands hosted in a heredoc body (#741)", () => {
async function collectFrom(command: string): Promise<string[]> {
const parser = await getParser();
const tree = parser.parse(command);
if (!tree) throw new Error("parse returned null");
try {
return collectPathCandidateTokens(tree.rootNode);
} finally {
tree.delete();
}
}
it("collects the operand of an interpolating heredoc body", async () => {
expect(await collectFrom("cat <<EOF\n$(cat /etc/shadow)\nEOF")).toEqual([
"/etc/shadow",
]);
});
it.each([
["single-quoted", "cat <<'EOF'\n$(cat /etc/shadow)\nEOF"],
["double-quoted", 'cat <<"EOF"\n$(cat /etc/shadow)\nEOF'],
])("collects nothing from a %s heredoc body", async (_label, command) => {
expect(await collectFrom(command)).toEqual([]);
});
it("never collects heredoc prose, even alongside a substitution", async () => {
expect(
await collectFrom(
"cat <<EOF\n/etc/passwd is prose\n$(cat /etc/shadow)\nEOF",
),
).toEqual(["/etc/shadow"]);
});
it("collects the operand of a herestring substitution", async () => {
expect(await collectFrom("cat <<< $(cat /etc/shadow)")).toEqual([
"/etc/shadow",
]);
});
});
it("recurses into command substitution to collect nested tokens", async () => {
const parser = await getParser();
const tree = parser.parse("cat $(echo /etc/hosts)");
try {
if (!tree) throw new Error("parse returned null");
// The command_substitution is a non-command, non-redirect node — recurse
const tokens = collectPathCandidateTokens(tree.rootNode);
// /etc/hosts is inside the substitution, collected by recursion
expect(tokens).toContain("/etc/hosts");
} finally {
tree?.delete();
}
});
});
describe("embedded --opt=value extraction (#645)", () => {
async function tokensOf(cmd: string): Promise<string[]> {
const { node, tree } = await parseCommandNode(cmd);
try {
return collectCommandTokens(node);
} finally {
tree.delete();
}
}
it("emits the value of a long option carrying an inline path", async () => {
// The issue's second repro: the flag token itself is rejected by the
// shape prelude, so the embedded path had to be split out to be seen.
expect(await tokensOf("grep --file=/tmp/patterns target")).toContain(
"/tmp/patterns",
);
});
it("emits the embedded value for a non-pattern-first command too", async () => {
expect(await tokensOf("tar --directory=/etc -xf a.tar")).toContain("/etc");
});
it("preserves the original flag token", async () => {
expect(await tokensOf("cat --file=/tmp/x")).toContain("--file=/tmp/x");
});
it("emits a bare value, leaving it for the shape gates to drop", async () => {
// --format=json yields "json", which names nothing and is dropped later.
expect(await tokensOf("cat --format=json")).toContain("json");
});
it("splits the single-dash form", async () => {
expect(await tokensOf("cat -o=/tmp/out")).toContain("/tmp/out");
});
it("does not split a flag with no value", async () => {
const tokens = await tokensOf("grep --recursive target");
expect(tokens).not.toContain("");
expect(tokens).not.toContain("--recursive");
});
it("does not split a non-flag token containing '='", async () => {
// FOO=bar is a variable_assignment, never an argument token.
expect(await tokensOf("cat a=b")).toEqual(["a=b"]);
});
it("keeps only the first '=' as the separator", async () => {
expect(await tokensOf("cat --opt=/tmp/a=b")).toContain("/tmp/a=b");
});
});