mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user