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

289 lines
11 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 } 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",
]);
});
});
});
});