feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,84 @@
import { describe, expect, test } from "vitest";
import { classifyWin32BashToken } from "#src/access-intent/bash/msys-bash-tokens";
describe("classifyWin32BashToken", () => {
describe("device paths", () => {
test.each([
"/dev/null",
"/dev/stdin",
"/dev/stdout",
"/dev/stderr",
])("%s is a device", (token) => {
expect(classifyWin32BashToken(token)).toEqual({ kind: "device" });
});
});
describe("MSYS drive mounts", () => {
test("translates /c/Users/x to C:\\Users\\x", () => {
expect(classifyWin32BashToken("/c/Users/x")).toEqual({
kind: "drive-mount",
windowsPath: "C:\\Users\\x",
});
});
test("uppercases the drive letter", () => {
expect(classifyWin32BashToken("/d/secrets/pw.txt")).toEqual({
kind: "drive-mount",
windowsPath: "D:\\secrets\\pw.txt",
});
});
test("accepts an already-uppercase mount letter", () => {
expect(classifyWin32BashToken("/C/x")).toEqual({
kind: "drive-mount",
windowsPath: "C:\\x",
});
});
test("bare /c translates to the drive root C:\\", () => {
expect(classifyWin32BashToken("/c")).toEqual({
kind: "drive-mount",
windowsPath: "C:\\",
});
});
test("trailing-slash /c/ translates to the drive root C:\\", () => {
expect(classifyWin32BashToken("/c/")).toEqual({
kind: "drive-mount",
windowsPath: "C:\\",
});
});
});
describe("other POSIX absolutes", () => {
test.each([
"/tmp/foo",
"/usr/bin",
"/etc/hosts",
"/mingw64/bin",
])("%s is a posix-absolute", (token) => {
expect(classifyWin32BashToken(token)).toEqual({
kind: "posix-absolute",
});
});
test("a two-letter first segment is not a drive mount", () => {
expect(classifyWin32BashToken("/cc/x")).toEqual({
kind: "posix-absolute",
});
});
});
describe("plain tokens", () => {
test.each([
"src/foo.ts",
"foo.ts",
"C:\\Users\\x",
"C:/Users/x",
"../up",
])("%s is plain", (token) => {
expect(classifyWin32BashToken(token)).toEqual({ kind: "plain" });
});
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
forEachNestedExecution,
NESTED_EXECUTION_CONTEXTS,
} from "#src/access-intent/bash/nested-execution";
import { getParser, type TSNode } from "#src/access-intent/bash/parser";
import type { BashCommandContext } from "#src/types";
/** Parse a bash snippet and collect every nested execution context found. */
async function visitContexts(
command: string,
): Promise<{ text: string; context: BashCommandContext }[]> {
const parser = await getParser();
const tree = parser.parse(command);
if (!tree) throw new Error("parser.parse returned null");
const found: { text: string; context: BashCommandContext }[] = [];
try {
forEachNestedExecution(tree.rootNode, (node: TSNode, context) => {
found.push({ text: node.text, context });
});
} finally {
tree.delete();
}
return found;
}
describe("NESTED_EXECUTION_CONTEXTS", () => {
it("maps the substitution node types to their execution context", () => {
expect([...NESTED_EXECUTION_CONTEXTS]).toEqual([
["command_substitution", "command_substitution"],
["process_substitution", "process_substitution"],
]);
});
it("omits subshell, which the command enumerator emits whole", () => {
expect(NESTED_EXECUTION_CONTEXTS.has("subshell")).toBe(false);
});
});
describe("forEachNestedExecution", () => {
it("finds a substitution in argument position", async () => {
expect(await visitContexts("echo $(rm x)")).toEqual([
{ text: "$(rm x)", context: "command_substitution" },
]);
});
it("finds a backtick substitution", async () => {
expect(await visitContexts("echo `rm x`")).toEqual([
{ text: "`rm x`", context: "command_substitution" },
]);
});
it("finds a process substitution", async () => {
expect(await visitContexts("diff <(cat /etc/shadow)")).toEqual([
{ text: "<(cat /etc/shadow)", context: "process_substitution" },
]);
});
it("finds a substitution hosted in a redirect destination", async () => {
expect(await visitContexts("echo hi > $(rm x)")).toEqual([
{ text: "$(rm x)", context: "command_substitution" },
]);
});
it("finds a substitution hosted in an interpolating heredoc body", async () => {
expect(await visitContexts("cat <<EOF\n$(rm e)\nEOF")).toEqual([
{ text: "$(rm e)", context: "command_substitution" },
]);
});
it("finds nothing in a quoted heredoc body, which does not interpolate", async () => {
expect(await visitContexts("cat <<'EOF'\n$(rm e)\nEOF")).toEqual([]);
});
it("does not descend past a context it finds", async () => {
// The outer substitution is visited; the inner one is left to the visitor.
expect(await visitContexts("echo $(echo $(rm x))")).toEqual([
{ text: "$(echo $(rm x))", context: "command_substitution" },
]);
});
it("finds each substitution of a chain in source order", async () => {
expect(await visitContexts("echo $(rm a) && echo `rm b`")).toEqual([
{ text: "$(rm a)", context: "command_substitution" },
{ text: "`rm b`", context: "command_substitution" },
]);
});
it("finds nothing in a command with no nested execution", async () => {
expect(await visitContexts("npm install pkg > out.txt")).toEqual([]);
});
});
@@ -0,0 +1,180 @@
import { homedir } from "node:os";
import { describe, expect, it } from "vitest";
import {
resolveNodeText,
SKIP_SUBTREE_TYPES,
} from "#src/access-intent/bash/node-text";
import { makeTSNode } from "#test/helpers/fake-ts-node";
describe("SKIP_SUBTREE_TYPES", () => {
it("contains the three node types that must not be descended", () => {
expect(SKIP_SUBTREE_TYPES.has("heredoc_body")).toBe(true);
expect(SKIP_SUBTREE_TYPES.has("heredoc_end")).toBe(true);
expect(SKIP_SUBTREE_TYPES.has("comment")).toBe(true);
});
it("does not contain common argument node types", () => {
expect(SKIP_SUBTREE_TYPES.has("word")).toBe(false);
expect(SKIP_SUBTREE_TYPES.has("string")).toBe(false);
expect(SKIP_SUBTREE_TYPES.has("raw_string")).toBe(false);
});
});
describe("resolveNodeText", () => {
describe("word nodes", () => {
it("returns the node text unchanged", () => {
expect(resolveNodeText(makeTSNode("word", "hello"))).toBe("hello");
});
});
describe("raw_string nodes (single-quoted)", () => {
it("strips surrounding single quotes", () => {
expect(resolveNodeText(makeTSNode("raw_string", "'content'"))).toBe(
"content",
);
});
it("strips single quotes around a path", () => {
expect(resolveNodeText(makeTSNode("raw_string", "'/etc/hosts'"))).toBe(
"/etc/hosts",
);
});
it("returns text as-is when not fully single-quoted", () => {
// A raw_string node without enclosing quotes (defensive fallback)
expect(resolveNodeText(makeTSNode("raw_string", "noquotes"))).toBe(
"noquotes",
);
});
});
describe("string nodes (double-quoted)", () => {
it("concatenates inner word children, skipping quote delimiters", () => {
const quoteOpen = makeTSNode('"', '"');
const content = makeTSNode("string_content", "hello world");
const quoteClose = makeTSNode('"', '"');
const node = makeTSNode("string", '"hello world"', [
quoteOpen,
content,
quoteClose,
]);
expect(resolveNodeText(node)).toBe("hello world");
});
it("concatenates multiple inner children", () => {
const quoteOpen = makeTSNode('"', '"');
const part1 = makeTSNode("string_content", "foo");
const part2 = makeTSNode("simple_expansion", "$BAR");
const quoteClose = makeTSNode('"', '"');
const node = makeTSNode("string", '"foo$BAR"', [
quoteOpen,
part1,
part2,
quoteClose,
]);
expect(resolveNodeText(node)).toBe("foo$BAR");
});
it("returns empty string for an empty double-quoted string", () => {
const quoteOpen = makeTSNode('"', '"');
const quoteClose = makeTSNode('"', '"');
const node = makeTSNode("string", '""', [quoteOpen, quoteClose]);
expect(resolveNodeText(node)).toBe("");
});
});
describe("string_content, simple_expansion, and expansion nodes", () => {
it("returns text as-is for string_content", () => {
expect(resolveNodeText(makeTSNode("string_content", "plain text"))).toBe(
"plain text",
);
});
it("resolves a plain $HOME reference to the home directory", () => {
// The children matter: the resolver discriminates a plain reference from
// an operator-bearing expansion structurally, not by text prefix (#694).
const node = makeTSNode("simple_expansion", "$HOME", [
makeTSNode("$", "$"),
makeTSNode("variable_name", "HOME"),
]);
expect(resolveNodeText(node)).toBe(homedir());
});
it("resolves a plain ${HOME} reference to the home directory", () => {
const node = makeTSNode("expansion", "${HOME}", [
makeTSNode("${", "${"),
makeTSNode("variable_name", "HOME"),
makeTSNode("}", "}"),
]);
expect(resolveNodeText(node)).toBe(homedir());
});
it("returns text as-is for a variable outside the resolvable set", () => {
const node = makeTSNode("expansion", "${VAR}", [
makeTSNode("${", "${"),
makeTSNode("variable_name", "VAR"),
makeTSNode("}", "}"),
]);
expect(resolveNodeText(node)).toBe("${VAR}");
});
it("returns text as-is for an expansion carrying an operator", () => {
const node = makeTSNode("expansion", "${HOME:-/tmp}", [
makeTSNode("${", "${"),
makeTSNode("variable_name", "HOME"),
makeTSNode(":-", ":-"),
makeTSNode("word", "/tmp"),
makeTSNode("}", "}"),
]);
expect(resolveNodeText(node)).toBe("${HOME:-/tmp}");
});
});
describe("concatenation nodes", () => {
it("concatenates resolved children", () => {
const word = makeTSNode("word", "/etc/");
const expansion = makeTSNode("simple_expansion", "$FILE", [
makeTSNode("$", "$"),
makeTSNode("variable_name", "FILE"),
]);
const node = makeTSNode("concatenation", "/etc/$FILE", [word, expansion]);
expect(resolveNodeText(node)).toBe("/etc/$FILE");
});
it("concatenates a resolved $HOME reference with its suffix", () => {
const expansion = makeTSNode("simple_expansion", "$HOME", [
makeTSNode("$", "$"),
makeTSNode("variable_name", "HOME"),
]);
const suffix = makeTSNode("word", "/sub");
const node = makeTSNode("concatenation", "$HOME/sub", [
expansion,
suffix,
]);
expect(resolveNodeText(node)).toBe(`${homedir()}/sub`);
});
it("handles nested concatenation-of-string", () => {
// A concatenation whose child is a double-quoted string
const quoteOpen = makeTSNode('"', '"');
const content = makeTSNode("string_content", "bar");
const quoteClose = makeTSNode('"', '"');
const inner = makeTSNode("string", '"bar"', [
quoteOpen,
content,
quoteClose,
]);
const prefix = makeTSNode("word", "foo");
const node = makeTSNode("concatenation", 'foo"bar"', [prefix, inner]);
expect(resolveNodeText(node)).toBe("foobar");
});
});
describe("default fallback", () => {
it("returns the raw text for unknown node types", () => {
expect(resolveNodeText(makeTSNode("unknown_type", "rawtext"))).toBe(
"rawtext",
);
});
});
});
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
getParser,
getWarmBashParser,
resetWarmBashParser,
warmBashParser,
} from "#src/access-intent/bash/parser";
describe("getParser", () => {
it("parses a simple bash command and returns a non-null root node", async () => {
const parser = await getParser();
const tree = parser.parse("echo hi");
expect(tree).not.toBeNull();
expect(tree?.rootNode).toBeDefined();
expect(tree?.rootNode.type).toBe("program");
tree?.delete();
});
it("returns the same memoized parser instance on repeated calls", async () => {
const first = await getParser();
const second = await getParser();
expect(first).toBe(second);
});
});
describe("warm parser", () => {
beforeEach(() => {
resetWarmBashParser();
});
afterEach(() => {
resetWarmBashParser();
});
it("returns null before the parser is warmed", () => {
expect(getWarmBashParser()).toBeNull();
});
it("exposes the parser synchronously after warm-up", async () => {
await warmBashParser();
const parser = getWarmBashParser();
expect(parser).not.toBeNull();
const tree = parser?.parse("echo hi");
expect(tree?.rootNode.type).toBe("program");
tree?.delete();
});
it("hands out the same memoized parser as getParser", async () => {
await warmBashParser();
expect(getWarmBashParser()).toBe(await getParser());
});
it("resetWarmBashParser clears the cached parser", async () => {
await warmBashParser();
expect(getWarmBashParser()).not.toBeNull();
resetWarmBashParser();
expect(getWarmBashParser()).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
import { homedir } from "node:os";
import { describe, expect, it } from "vitest";
import { getParser, type TSNode } from "#src/access-intent/bash/parser";
import { resolvePlainVariableExpansion } from "#src/access-intent/bash/shell-variable-expansion";
import { makeTSNode } from "#test/helpers/fake-ts-node";
/** `$NAME` as tree-sitter-bash builds it: a `$` delimiter plus the name. */
function simpleExpansion(name: string): TSNode {
return makeTSNode("simple_expansion", `$${name}`, [
makeTSNode("$", "$"),
makeTSNode("variable_name", name),
]);
}
/** `${NAME}` as tree-sitter-bash builds it: brace delimiters plus the name. */
function bracedExpansion(name: string): TSNode {
return makeTSNode("expansion", `\${${name}}`, [
makeTSNode("${", "${"),
makeTSNode("variable_name", name),
makeTSNode("}", "}"),
]);
}
function findNodeOfType(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);
const found = child ? findNodeOfType(child, type) : null;
if (found) return found;
}
return null;
}
describe("resolvePlainVariableExpansion", () => {
describe("resolvable variables", () => {
it("resolves $HOME to the OS home directory", () => {
expect(resolvePlainVariableExpansion(simpleExpansion("HOME"))).toBe(
homedir(),
);
});
it("resolves ${HOME} to the OS home directory", () => {
expect(resolvePlainVariableExpansion(bracedExpansion("HOME"))).toBe(
homedir(),
);
});
it("resolves $PWD to the base-relative marker", () => {
// The shell's working directory is the projection's effective base, so
// the base-relative form resolves correctly after any `cd` folding
// without threading a base into this pure function.
expect(resolvePlainVariableExpansion(simpleExpansion("PWD"))).toBe(".");
});
it("resolves ${PWD} to the base-relative marker", () => {
expect(resolvePlainVariableExpansion(bracedExpansion("PWD"))).toBe(".");
});
});
describe("variables outside the resolvable set", () => {
it.each([
"HOMEDIR",
"CURRENT",
"PATH",
"PWDX",
"TMPDIR",
])("leaves $%s unresolved", (name) => {
expect(resolvePlainVariableExpansion(simpleExpansion(name))).toBeNull();
expect(resolvePlainVariableExpansion(bracedExpansion(name))).toBeNull();
});
});
describe("expansions carrying an operator", () => {
it("leaves ${HOME:-/tmp} unresolved", () => {
const node = makeTSNode("expansion", "${HOME:-/tmp}", [
makeTSNode("${", "${"),
makeTSNode("variable_name", "HOME"),
makeTSNode(":-", ":-"),
makeTSNode("word", "/tmp"),
makeTSNode("}", "}"),
]);
expect(resolvePlainVariableExpansion(node)).toBeNull();
});
it("leaves ${#HOME} unresolved", () => {
const node = makeTSNode("expansion", "${#HOME}", [
makeTSNode("${", "${"),
makeTSNode("#", "#"),
makeTSNode("variable_name", "HOME"),
makeTSNode("}", "}"),
]);
expect(resolvePlainVariableExpansion(node)).toBeNull();
});
});
describe("nodes that are not a plain variable reference", () => {
it("returns null for a node with no children", () => {
expect(
resolvePlainVariableExpansion(makeTSNode("simple_expansion", "$HOME")),
).toBeNull();
});
it("returns null for a node with no variable_name child", () => {
const node = makeTSNode("expansion", "${}", [
makeTSNode("${", "${"),
makeTSNode("}", "}"),
]);
expect(resolvePlainVariableExpansion(node)).toBeNull();
});
it("returns null for a variable_assignment naming a resolvable variable", () => {
// `HOME=/tmp` binds the name; it is not a reference to its value.
const node = makeTSNode("variable_assignment", "HOME=/tmp", [
makeTSNode("variable_name", "HOME"),
makeTSNode("=", "="),
makeTSNode("word", "/tmp"),
]);
expect(resolvePlainVariableExpansion(node)).toBeNull();
});
});
describe("fidelity to the shapes tree-sitter-bash actually produces", () => {
it.each([
["ls $HOME", "simple_expansion", homedir()],
["ls ${HOME}", "expansion", homedir()],
["ls $PWD", "simple_expansion", "."],
["ls ${PWD}", "expansion", "."],
["ls ${HOME:-/tmp}", "expansion", null],
["ls ${#HOME}", "expansion", null],
["ls $HOMEDIR", "simple_expansion", null],
])("resolves %s to %s", async (command, nodeType, expected) => {
const parser = await getParser();
const tree = parser.parse(command);
expect(tree).not.toBeNull();
if (!tree) return;
try {
const node = findNodeOfType(tree.rootNode, nodeType);
expect(node).not.toBeNull();
if (!node) return;
expect(resolvePlainVariableExpansion(node)).toBe(expected);
} finally {
tree.delete();
}
});
});
});
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
resetWarmBashParser,
warmBashParser,
} from "#src/access-intent/bash/parser";
import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
describe("parseBashCommandsSync", () => {
beforeEach(() => {
resetWarmBashParser();
});
afterEach(() => {
resetWarmBashParser();
});
it("returns null when the parser is not warm", () => {
expect(parseBashCommandsSync("echo hi")).toBeNull();
});
describe("once warm", () => {
beforeEach(async () => {
await warmBashParser();
});
it("returns a single unit for a lone command", () => {
expect(parseBashCommandsSync("echo hi")).toEqual([{ text: "echo hi" }]);
});
it("decomposes a chained command into its units", () => {
expect(parseBashCommandsSync("cd /repo && npm install x")).toEqual([
{ text: "cd /repo" },
{ text: "npm install x" },
]);
});
it("descends into a command substitution, tagging its context", () => {
expect(parseBashCommandsSync("echo $(rm -rf /)")).toEqual([
{ text: "echo $(rm -rf /)" },
{ text: "rm -rf /", context: "command_substitution" },
]);
});
it("flags an opaque wrapper", () => {
expect(parseBashCommandsSync('bash -c "rm -rf /"')).toEqual([
{
text: 'bash -c "rm -rf /"',
wrapperKind: "opaque-payload",
executedUnit: "rm -rf /",
},
]);
});
it("returns an empty array for a comment-only command", () => {
expect(parseBashCommandsSync("# just a comment")).toEqual([]);
});
it("returns an empty array for an empty command", () => {
expect(parseBashCommandsSync("")).toEqual([]);
});
});
});
@@ -0,0 +1,446 @@
import { describe, expect, test } from "vitest";
import {
classifyBareTokenCandidate,
classifyTokenAsPathCandidate,
classifyTokenAsRuleCandidate,
} from "#src/access-intent/bash/token-classification";
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
// ── Shared rejection behaviour ─────────────────────────────────────────────
//
// Both classifiers delegate to the private `rejectNonPathToken` predicate for
// the six shared rejection cases tested below. Testing via both exports
// pins that predicate through each caller.
describe("classifyTokenAsPathCandidate", () => {
describe("shared rejection: rejectNonPathToken", () => {
test("empty string → null", () => {
expect(classifyTokenAsPathCandidate("")).toBeNull();
});
test("flag (leading dash) → null", () => {
expect(classifyTokenAsPathCandidate("-r")).toBeNull();
expect(classifyTokenAsPathCandidate("--recursive")).toBeNull();
});
test("env assignment (= before any /) → null", () => {
expect(classifyTokenAsPathCandidate("FOO=/bar")).toBeNull();
expect(classifyTokenAsPathCandidate("HOME=/home/user")).toBeNull();
});
test("env-like token where = comes after / is NOT rejected as assignment", () => {
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues
// Starts with /, so path candidate accepts it.
expect(classifyTokenAsPathCandidate("/foo=bar")).toBe("/foo=bar");
});
test("URL → null", () => {
expect(classifyTokenAsPathCandidate("https://example.com")).toBeNull();
expect(classifyTokenAsPathCandidate("http://localhost:3000")).toBeNull();
expect(classifyTokenAsPathCandidate("file:///tmp/foo")).toBeNull();
expect(
classifyTokenAsPathCandidate("git+ssh://github.com/a/b"),
).toBeNull();
});
test("@scope/package → null", () => {
expect(classifyTokenAsPathCandidate("@foo/bar")).toBeNull();
expect(classifyTokenAsPathCandidate("@scope/pkg")).toBeNull();
});
test("@/ prefix is NOT rejected (it looks like an absolute-rooted scoped path)", () => {
// @/ passes the @ guard; then for path candidate it doesn't start with /
// or ~/, and doesn't contain .., so it returns null anyway from the
// acceptance gate — but the rejection is not due to the @ guard.
// This test documents that @/ is not rejected by the shared rejection.
// The path classifier then rejects it for not matching any acceptance shape.
expect(classifyTokenAsPathCandidate("@/foo/bar")).toBeNull();
});
test("regex metacharacters → null", () => {
// REGEX_METACHAR_PATTERN: .*, .+, \|, \(, \), [...], ^/
expect(classifyTokenAsPathCandidate("foo.*")).toBeNull();
expect(classifyTokenAsPathCandidate("bar.+")).toBeNull();
expect(classifyTokenAsPathCandidate("a\\|b")).toBeNull();
expect(classifyTokenAsPathCandidate("\\(group\\)")).toBeNull();
expect(classifyTokenAsPathCandidate("[abc]")).toBeNull();
expect(classifyTokenAsPathCandidate("^/start")).toBeNull();
});
});
describe("path-candidate acceptance gate", () => {
test("absolute path (starts with /) → returned as-is", () => {
expect(classifyTokenAsPathCandidate("/etc/hosts")).toBe("/etc/hosts");
expect(classifyTokenAsPathCandidate("/tmp")).toBe("/tmp");
expect(classifyTokenAsPathCandidate("/home/user/file.txt")).toBe(
"/home/user/file.txt",
);
});
test("bare-slash token (filesystem root) → returned as-is", () => {
// `find /` scans the whole filesystem from root — a deliberate
// external-directory access the gate must see, not drop (#583).
expect(classifyTokenAsPathCandidate("/")).toBe("/");
expect(classifyTokenAsPathCandidate("//")).toBe("//");
expect(classifyTokenAsPathCandidate("///")).toBe("///");
});
test("home-relative path (starts with ~/) → returned as-is", () => {
expect(classifyTokenAsPathCandidate("~/Documents")).toBe("~/Documents");
expect(classifyTokenAsPathCandidate("~/.ssh/config")).toBe(
"~/.ssh/config",
);
});
test("parent-traversal (contains ..) → returned as-is", () => {
expect(classifyTokenAsPathCandidate("../../etc/passwd")).toBe(
"../../etc/passwd",
);
expect(classifyTokenAsPathCandidate("../foo")).toBe("../foo");
expect(classifyTokenAsPathCandidate("..")).toBe("..");
});
test("plain word with no path shape → null", () => {
expect(classifyTokenAsPathCandidate("hello")).toBeNull();
expect(classifyTokenAsPathCandidate("myfile.txt")).toBeNull();
});
test("dot-file (starts with .) → null (strict path gate)", () => {
// Path candidate does NOT accept dot-files; rule candidate does.
expect(classifyTokenAsPathCandidate(".env")).toBeNull();
expect(classifyTokenAsPathCandidate(".gitignore")).toBeNull();
});
test("relative path with / but no leading / or ~/ → null (strict path gate)", () => {
// Path candidate does NOT accept bare relative paths; rule candidate does.
expect(classifyTokenAsPathCandidate("src/foo.ts")).toBeNull();
expect(classifyTokenAsPathCandidate("./build")).toBeNull();
});
});
describe("Windows drive-letter acceptance gate", () => {
test("forward-slash drive path → returned as-is", () => {
expect(classifyTokenAsPathCandidate("C:/Windows/win.ini")).toBe(
"C:/Windows/win.ini",
);
expect(classifyTokenAsPathCandidate("D:/secrets/password.txt")).toBe(
"D:/secrets/password.txt",
);
});
test("backslash drive path → returned as-is", () => {
expect(classifyTokenAsPathCandidate("C:\\Windows\\win.ini")).toBe(
"C:\\Windows\\win.ini",
);
expect(classifyTokenAsPathCandidate("D:\\secrets\\password.txt")).toBe(
"D:\\secrets\\password.txt",
);
});
test("lowercase drive letter → returned as-is", () => {
expect(classifyTokenAsPathCandidate("c:/foo")).toBe("c:/foo");
});
test("single-letter scheme with double-slash (c://x) → null (URL_PATTERN fires first)", () => {
// c:// matches URL_PATTERN before the drive-letter check runs.
expect(classifyTokenAsPathCandidate("c://x")).toBeNull();
});
test("drive-relative path without separator (C:foo) → null", () => {
// No / or \ after the colon — not an absolute drive path per node:path.
expect(classifyTokenAsPathCandidate("C:foo")).toBeNull();
});
});
});
describe("classifyTokenAsRuleCandidate", () => {
describe("shared rejection: rejectNonPathToken", () => {
test("empty string → null", () => {
expect(classifyTokenAsRuleCandidate("", posixPathFlavor)).toBeNull();
});
test("flag (leading dash) → null", () => {
expect(classifyTokenAsRuleCandidate("-r", posixPathFlavor)).toBeNull();
expect(
classifyTokenAsRuleCandidate("--recursive", posixPathFlavor),
).toBeNull();
});
test("env assignment (= before any /) → null", () => {
expect(
classifyTokenAsRuleCandidate("FOO=/bar", posixPathFlavor),
).toBeNull();
expect(
classifyTokenAsRuleCandidate("HOME=/home/user", posixPathFlavor),
).toBeNull();
});
test("env-like token where = comes after / is NOT rejected as assignment", () => {
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues.
// Contains /, so rule candidate accepts it.
expect(classifyTokenAsRuleCandidate("/foo=bar", posixPathFlavor)).toBe(
"/foo=bar",
);
});
test("URL → null", () => {
expect(
classifyTokenAsRuleCandidate("https://example.com", posixPathFlavor),
).toBeNull();
expect(
classifyTokenAsRuleCandidate("http://localhost:3000", posixPathFlavor),
).toBeNull();
expect(
classifyTokenAsRuleCandidate("file:///tmp/foo", posixPathFlavor),
).toBeNull();
});
test("@scope/package → null", () => {
expect(
classifyTokenAsRuleCandidate("@foo/bar", posixPathFlavor),
).toBeNull();
expect(
classifyTokenAsRuleCandidate("@scope/pkg", posixPathFlavor),
).toBeNull();
});
test("regex metacharacters → null", () => {
expect(classifyTokenAsRuleCandidate("foo.*", posixPathFlavor)).toBeNull();
expect(classifyTokenAsRuleCandidate("bar.+", posixPathFlavor)).toBeNull();
expect(classifyTokenAsRuleCandidate("a\\|b", posixPathFlavor)).toBeNull();
expect(classifyTokenAsRuleCandidate("[abc]", posixPathFlavor)).toBeNull();
expect(
classifyTokenAsRuleCandidate("^/start", posixPathFlavor),
).toBeNull();
});
});
describe("rule-candidate acceptance gate (broader than path)", () => {
test("absolute path (starts with /) → returned as-is", () => {
expect(classifyTokenAsRuleCandidate("/etc/hosts", posixPathFlavor)).toBe(
"/etc/hosts",
);
});
test("bare-slash token (filesystem root) → returned as-is", () => {
// Root is a path-shaped token via `hasPathSeparator`; a `path` rule for
// `/` must be able to match it, same as any other absolute (#583).
expect(classifyTokenAsRuleCandidate("/", posixPathFlavor)).toBe("/");
expect(classifyTokenAsRuleCandidate("//", posixPathFlavor)).toBe("//");
});
test("home-relative path (starts with ~/) → returned as-is", () => {
expect(classifyTokenAsRuleCandidate("~/Documents", posixPathFlavor)).toBe(
"~/Documents",
);
});
test("parent-traversal (contains ..) → returned as-is", () => {
expect(classifyTokenAsRuleCandidate("../foo", posixPathFlavor)).toBe(
"../foo",
);
expect(classifyTokenAsRuleCandidate("..", posixPathFlavor)).toBe("..");
});
test("dot-file (starts with .) → returned as-is", () => {
// Rule candidate accepts dot-files; path candidate does not.
expect(classifyTokenAsRuleCandidate(".env", posixPathFlavor)).toBe(
".env",
);
expect(classifyTokenAsRuleCandidate(".gitignore", posixPathFlavor)).toBe(
".gitignore",
);
});
test("current-dir relative (starts with ./) → returned as-is", () => {
expect(classifyTokenAsRuleCandidate("./src", posixPathFlavor)).toBe(
"./src",
);
expect(
classifyTokenAsRuleCandidate("./build/output.js", posixPathFlavor),
).toBe("./build/output.js");
});
test("relative path containing / → returned as-is", () => {
// Rule candidate accepts any token with / (not already rejected).
expect(classifyTokenAsRuleCandidate("src/foo.ts", posixPathFlavor)).toBe(
"src/foo.ts",
);
expect(
classifyTokenAsRuleCandidate(
"packages/pi-foo/index.ts",
posixPathFlavor,
),
).toBe("packages/pi-foo/index.ts");
});
test("plain word with no path shape → null", () => {
expect(classifyTokenAsRuleCandidate("hello", posixPathFlavor)).toBeNull();
expect(
classifyTokenAsRuleCandidate("myfile.txt", posixPathFlavor),
).toBeNull();
});
});
describe("Windows drive-letter acceptance gate", () => {
test("forward-slash drive path → returned as-is", () => {
// Forward-slash form was already accepted via token.includes("/").
// The explicit branch makes it first-class and order-independent.
expect(
classifyTokenAsRuleCandidate("C:/Windows/win.ini", posixPathFlavor),
).toBe("C:/Windows/win.ini");
});
test("backslash drive path → returned as-is (new: no forward slash)", () => {
// Previously dropped by both classifiers; the backslash form has no /
// so the includes("/") branch could not catch it.
expect(
classifyTokenAsRuleCandidate(
"D:\\secrets\\password.txt",
posixPathFlavor,
),
).toBe("D:\\secrets\\password.txt");
expect(
classifyTokenAsRuleCandidate("C:\\Windows\\win.ini", posixPathFlavor),
).toBe("C:\\Windows\\win.ini");
});
test("lowercase drive letter (backslash) → returned as-is", () => {
expect(classifyTokenAsRuleCandidate("c:\\foo", posixPathFlavor)).toBe(
"c:\\foo",
);
});
test("drive-relative path without separator (C:foo) → null", () => {
expect(classifyTokenAsRuleCandidate("C:foo", posixPathFlavor)).toBeNull();
});
});
describe("Windows backslash-relative acceptance gate (win32 flavor, #520)", () => {
test("backslash-relative token accepted under the win32 flavor", () => {
expect(classifyTokenAsRuleCandidate("dir\\file", win32PathFlavor)).toBe(
"dir\\file",
);
});
test("backslash-relative token rejected under the posix flavor", () => {
expect(
classifyTokenAsRuleCandidate("dir\\file", posixPathFlavor),
).toBeNull();
});
test("backslash regex-metacharacter token still rejected under the win32 flavor", () => {
// rejectNonPathToken's REGEX_METACHAR_PATTERN fires before the separator
// branch is reached, regardless of flavor.
expect(classifyTokenAsRuleCandidate("a\\|b", win32PathFlavor)).toBeNull();
expect(
classifyTokenAsRuleCandidate("\\(group\\)", win32PathFlavor),
).toBeNull();
});
test("backslash traversal accepted regardless of flavor (already via ..)", () => {
expect(classifyTokenAsRuleCandidate("..\\secret", posixPathFlavor)).toBe(
"..\\secret",
);
expect(classifyTokenAsRuleCandidate("..\\secret", win32PathFlavor)).toBe(
"..\\secret",
);
});
});
describe("rule-vs-path divergence", () => {
const dotFiles = [".env", ".gitignore", ".eslintrc"];
const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
for (const tok of dotFiles) {
test(`dot-file "${tok}": rule accepts, path rejects`, () => {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
});
}
for (const tok of relPaths) {
test(`relative path "${tok}": rule accepts, path rejects`, () => {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
});
}
const sharedAccepted = ["/etc/hosts", "~/docs", "../sibling"];
for (const tok of sharedAccepted) {
test(`"${tok}": both classifiers accept`, () => {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
});
}
const winDrivePaths = [
"C:/Windows/win.ini",
"D:\\secrets\\password.txt",
"c:/foo",
];
for (const tok of winDrivePaths) {
test(`Windows drive path "${tok}": both classifiers accept`, () => {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
});
}
const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
for (const tok of sharedRejected) {
test(`"${tok}": both classifiers reject`, () => {
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBeNull();
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
});
}
});
});
describe("classifyBareTokenCandidate", () => {
// Prelude-only: returns the token when nothing about its *shape* rules out
// being a path. Whether it names a real entry is the existence probe's
// question, decided by the resolver (ADR 0009), not by this classifier.
test("bare word → returned unchanged", () => {
expect(classifyBareTokenCandidate("id_rsa")).toBe("id_rsa");
expect(classifyBareTokenCandidate("key.pem")).toBe("key.pem");
expect(classifyBareTokenCandidate("outside-link")).toBe("outside-link");
});
test("bare word that names no file is still returned — existence is not its question", () => {
expect(classifyBareTokenCandidate("status")).toBe("status");
expect(classifyBareTokenCandidate("build")).toBe("build");
});
test("consults no policy — identical result for every token of the same shape", () => {
expect(classifyBareTokenCandidate("anything")).toBe("anything");
});
describe("shared rejection prelude", () => {
test("flag (leading dash) → null", () => {
expect(classifyBareTokenCandidate("-r")).toBeNull();
expect(classifyBareTokenCandidate("--recursive")).toBeNull();
});
test("env assignment → null", () => {
expect(classifyBareTokenCandidate("FOO=/bar")).toBeNull();
});
test("URL → null", () => {
expect(classifyBareTokenCandidate("https://example.com")).toBeNull();
});
test("@scope/package → null", () => {
expect(classifyBareTokenCandidate("@foo/bar")).toBeNull();
});
test("regex metacharacters → null", () => {
expect(classifyBareTokenCandidate("foo.*")).toBeNull();
});
test("empty string → null", () => {
expect(classifyBareTokenCandidate("")).toBeNull();
});
});
});
@@ -0,0 +1,429 @@
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");
});
});
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import {
type CommandWord,
classifyWrapperWords,
executedUnitOf,
} from "#src/access-intent/bash/wrapper-analysis";
/**
* Split a command unit into words the way the AST walk does: whitespace
* separated, but a quoted span is one word carrying its quotes — tree-sitter
* emits a `string`/`raw_string` argument as a single named child.
*
* `program.test.ts` pins the real node adapter end to end; this stands in for it
* so the extraction rules can be exercised without a parse.
*/
function words(unitText: string): CommandWord[] {
const out: CommandWord[] = [];
const pattern = /"[^"]*"|'[^']*'|\S+/g;
let match = pattern.exec(unitText);
while (match !== null) {
out.push({ text: match[0], offset: match.index });
match = pattern.exec(unitText);
}
return out;
}
describe("classifyWrapperWords", () => {
describe("opaque payloads", () => {
it.each([
"eval rm",
"bash -c rm",
"sh -c rm",
"dash -c rm",
"zsh -c rm",
"ksh -c rm",
"bash -ec rm",
"bash -xc rm",
"/bin/bash -c rm",
])("flags %s", (unit) => {
expect(classifyWrapperWords(words(unit))).toBe("opaque-payload");
});
it("does not flag a shell running a script file", () => {
expect(classifyWrapperWords(words("bash script.sh"))).toBeUndefined();
});
it("does not flag a -c cluster after the end-of-options marker", () => {
expect(classifyWrapperWords(words("bash -- -c"))).toBeUndefined();
});
});
describe("indirection wrappers", () => {
it.each([
"sudo aws s3 ls",
"env FOO=bar aws",
"xargs grep foo",
"timeout 10 grep foo",
"nice -n 5 make",
"doas ls",
"flock /tmp/lock ls",
])("flags %s", (unit) => {
expect(classifyWrapperWords(words(unit))).toBe("indirection");
});
it.each([
"find . -exec grep foo {} ;",
"find . -execdir rm {} ;",
"fd -x rm",
"fd --exec-batch rm",
])("flags the exec-conditional %s", (unit) => {
expect(classifyWrapperWords(words(unit))).toBe("indirection");
});
it("does not flag a bare search", () => {
expect(classifyWrapperWords(words("find . -name x"))).toBeUndefined();
});
});
describe("ordinary commands", () => {
it.each([
"ls -la",
"grep -c foo file",
"git status",
])("does not flag %s", (unit) => {
expect(classifyWrapperWords(words(unit))).toBeUndefined();
});
it("does not flag an empty word list", () => {
expect(classifyWrapperWords([])).toBeUndefined();
});
});
});
describe("executedUnitOf", () => {
/** Extract from a unit spelled as plain whitespace-separated words. */
function executedUnit(unitText: string): string | null {
return executedUnitOf(unitText, words(unitText));
}
describe("opaque payloads", () => {
it.each([
['bash -c "rm -rf /"', "rm -rf /"],
["bash -c 'rm -rf /'", "rm -rf /"],
['sh -ec "make build"', "make build"],
['/bin/bash -c "ls"', "ls"],
['eval "rm x"', "rm x"],
])("names the inner program of %s", (unit, expected) => {
expect(executedUnit(unit)).toBe(expected);
});
it("returns null when the payload argument is missing", () => {
expect(executedUnit("bash -c")).toBeNull();
});
});
describe("indirection wrappers", () => {
it.each([
["sudo aws s3 rm", "aws s3 rm"],
["sudo -u root aws s3 rm", "aws s3 rm"],
["sudo -- ls -la", "ls -la"],
["xargs grep foo", "grep foo"],
["xargs -0 -n1 grep foo", "grep foo"],
["xargs -I{} rm {}", "rm {}"],
["timeout 10 grep foo", "grep foo"],
["timeout -s KILL 10 grep foo", "grep foo"],
["nice -n 5 make build", "make build"],
["env FOO=bar grep foo", "grep foo"],
["flock /tmp/lock aws s3 ls", "aws s3 ls"],
["watch -n 2 ls", "ls"],
])("names the inner command of %s", (unit, expected) => {
expect(executedUnit(unit)).toBe(expected);
});
it("preserves the inner command's original spacing and quoting", () => {
expect(executedUnit("sudo grep 'a b' x")).toBe("grep 'a b' x");
});
it.each([
"xargs",
"sudo",
"sudo -u root",
"timeout 10",
])("returns null when %s names no inner command", (unit) => {
expect(executedUnit(unit)).toBeNull();
});
it("returns null rather than guessing past an unknown trailing option", () => {
expect(executedUnit("xargs --unknown-opt")).toBeNull();
});
});
describe("exec-conditional wrappers", () => {
it.each([
["find . -name x -exec grep foo {} ;", "grep foo {}"],
["find . -exec rm {} +", "rm {}"],
["find . -execdir grep foo {} ;", "grep foo {}"],
["fd -x rm", "rm"],
["fd --exec-batch rm -f", "rm -f"],
])("names the per-result command of %s", (unit, expected) => {
expect(executedUnit(unit)).toBe(expected);
});
it("returns null when the exec flag ends the command", () => {
expect(executedUnit("find . -exec")).toBeNull();
});
});
describe("nested wrappers", () => {
it.each([
["sudo timeout 5 xargs grep foo", "grep foo"],
["sudo bash -c 'rm x'", "rm x"],
["timeout 10 sudo -u root aws s3 rm", "aws s3 rm"],
])("unwraps %s to its innermost command", (unit, expected) => {
expect(executedUnit(unit)).toBe(expected);
});
});
describe("nothing to add", () => {
it("returns null for an ordinary command", () => {
expect(executedUnit("grep foo")).toBeNull();
});
it("returns null for an empty word list", () => {
expect(executedUnitOf("", [])).toBeNull();
});
});
});