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("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("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 `/$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", ]); }); }); }); });