/** * @file Regression tests for the lane git surface's hardening: the file viewer * and untracked-file diff never read a secret file or follow a symlink out of * the lane, `git status` paths survive C-quoting (non-ASCII, spaces, quotes), * discarding a staged rename restores the original path, and untracked reads * are async (no fs.*Sync on the poll path). Real git fixture, like * git-diff-ops.test.js. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach, afterEach } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const { collectChanges, parseNumstat, readFileAt, readImageAt } = require("../lib/git-diff"); const { runGitAction } = require("../lib/git-ops"); const { gitFacts } = require("../lib/worktree"); let ROOT; let LANE; const g = (cwd, ...args) => { const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; return execFileSync("git", args, { cwd, encoding: "utf8", env }); }; function write(dir, file, body) { fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); fs.writeFileSync(path.join(dir, file), body); } beforeEach(() => { ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-githarden-")); LANE = path.join(ROOT, "lane"); g(ROOT, "init", "-q", "-b", "main", LANE); g(LANE, "config", "user.email", "t@h"); g(LANE, "config", "user.name", "t"); write(LANE, "a.txt", "one\ntwo\n"); write(LANE, "old name.txt", "keep me\nplease\n"); g(LANE, "add", "-A"); g(LANE, "commit", "-qm", "init"); write(ROOT, "outside.txt", "TOP SECRET OUTSIDE\n"); }); afterEach(() => fs.rmSync(ROOT, { recursive: true, force: true })); describe("readFileAt: secret files and symlink escapes", () => { it("refuses .claude/settings.local.json at rev=work", async () => { write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}'); for (const p of [".claude/settings.local.json", "./.claude//settings.local.json"]) { await assert.rejects(readFileAt(LANE, p, "work"), { code: "ESECRET" }); } }); it("refuses the secret file at a committed revision too", async () => { write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}'); g(LANE, "add", "-f", ".claude/settings.local.json"); g(LANE, "commit", "-qm", "oops"); await assert.rejects(readFileAt(LANE, ".claude/settings.local.json", "HEAD"), { code: "ESECRET", }); }); it("refuses a symlink whose target is outside the lane", async () => { fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "link.txt")); await assert.rejects(readFileAt(LANE, "link.txt", "work"), { code: "EBADPATH" }); }); it("refuses a symlink that points at the secret file", async () => { write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}'); fs.symlinkSync(".claude/settings.local.json", path.join(LANE, "innocent.json")); await assert.rejects(readFileAt(LANE, "innocent.json", "work"), { code: "ESECRET" }); }); it("still reads a symlink that stays inside the lane", async () => { fs.symlinkSync("a.txt", path.join(LANE, "alias.txt")); const out = await readFileAt(LANE, "alias.txt", "work"); assert.deepEqual(out.lines, ["one", "two"]); }); }); describe("collectChanges: untracked content reads", () => { it("never shows the content of an untracked symlink pointing outside the lane", async () => { fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "escape.txt")); const out = await collectChanges(LANE, { range: "work", full: true }); const entry = out.files.find((f) => f.path === "escape.txt"); assert.ok(entry, "the symlink is still listed"); assert.equal(JSON.stringify(entry).includes("TOP SECRET"), false); }); it("never shows the secret file's content in a diff", async () => { write(LANE, ".claude/settings.local.json", '{"k":"sk-old"}'); g(LANE, "add", "-f", ".claude/settings.local.json"); g(LANE, "commit", "-qm", "tracked by mistake"); write(LANE, ".claude/settings.local.json", '{"k":"sk-leak"}'); const out = await collectChanges(LANE, { range: "work", full: true }); assert.equal(JSON.stringify(out).includes("sk-leak"), false); }); it("does not use synchronous fs reads", async () => { write(LANE, "new.txt", "x\ny\n"); const saved = { readFileSync: fs.readFileSync, statSync: fs.statSync }; fs.readFileSync = () => { throw new Error("sync read"); }; fs.statSync = () => { throw new Error("sync stat"); }; let out; try { out = await collectChanges(LANE, { range: "work", full: true }); } finally { Object.assign(fs, saved); } const entry = out.files.find((f) => f.path === "new.txt"); assert.equal(entry.added, 2); assert.equal(entry.hunks[0].lines.length, 2); }); }); describe("C-quoted status paths", () => { const NAME = 'tài liệu "mới".txt'; it("collectChanges reports a non-ASCII/quoted path verbatim", async () => { write(LANE, NAME, "xin chào\n"); write(LANE, "a.txt", "one\ntwo\nthree\n"); g(LANE, "mv", "old name.txt", "mới tên.txt"); const out = await collectChanges(LANE, { range: "work" }); const paths = out.files.map((f) => f.path); assert.ok(paths.includes(NAME), paths.join("|")); assert.ok(paths.includes("mới tên.txt"), paths.join("|")); assert.equal(out.files.find((f) => f.path === NAME).added, 1); assert.equal(out.files.find((f) => f.path === "a.txt").added, 1); }); it("stage accepts a non-ASCII/quoted path", async () => { write(LANE, NAME, "xin chào\n"); await runGitAction(LANE, "stage", { paths: [NAME] }); assert.match(g(LANE, "diff", "--cached", "--name-only", "-z"), /mới/); }); it("branch range and commit range report renamed non-ASCII paths", async () => { g(LANE, "checkout", "-qb", "feat"); g(LANE, "mv", "old name.txt", "mới tên.txt"); g(LANE, "commit", "-qm", "rename"); const branch = await collectChanges(LANE, { range: "branch", base: "main" }); assert.deepEqual( branch.files.map((f) => f.path), ["mới tên.txt"] ); const commit = await collectChanges(LANE, { range: "commit", sha: "HEAD" }); assert.deepEqual( commit.files.map((f) => f.path), ["mới tên.txt"] ); }); }); describe("discard of a staged rename", () => { it("restores the original path and removes the new one", async () => { g(LANE, "mv", "old name.txt", "new name.txt"); await runGitAction(LANE, "discard", { paths: ["new name.txt"], confirm: true }); assert.equal(fs.readFileSync(path.join(LANE, "old name.txt"), "utf8"), "keep me\nplease\n"); assert.equal(fs.existsSync(path.join(LANE, "new name.txt")), false); assert.equal(g(LANE, "status", "--porcelain"), ""); }); it("still requires confirm: true", async () => { g(LANE, "mv", "old name.txt", "new name.txt"); await assert.rejects(runGitAction(LANE, "discard", { paths: ["new name.txt"] }), { code: "ECONFIRM", }); }); }); describe("readImageAt: an image's bytes for the viewer", () => { // Every byte value, so a utf8 round-trip anywhere would show. const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i)); it("reads the working file and a committed one byte for byte", async () => { write(LANE, "img/logo.PNG", bytes); g(LANE, "add", "-A"); g(LANE, "commit", "-qm", "img"); const work = await readImageAt(LANE, "img/logo.PNG", "work"); assert.equal(work.type, "image/png"); assert.deepEqual(work.buf, bytes); const head = await readImageAt(LANE, "img/logo.PNG", "HEAD"); assert.deepEqual(head.buf, bytes); }); it("refuses a non-image, a missing file and a symlink out of the lane", async () => { await assert.rejects(readImageAt(LANE, "a.txt", "work"), { code: "ENOTIMAGE" }); await assert.rejects(readImageAt(LANE, "none.png", "work"), { code: "ENOFILE" }); fs.writeFileSync(path.join(ROOT, "outside.png"), bytes); fs.symlinkSync(path.join(ROOT, "outside.png"), path.join(LANE, "leak.png")); await assert.rejects(readImageAt(LANE, "leak.png", "work")); await assert.rejects(readImageAt(LANE, "../outside.png", "work")); }); }); describe("GET /api/lanes/:id/git/raw", () => { it("serves an image with its type, and an SVG sandboxed", async () => { process.env.DASHBOARD_DB_PATH = path.join(ROOT, "raw.db"); process.env.DASHBOARD_REMOTE_SYNC_MS = "0"; process.env.DASHBOARD_LIVENESS_PROBE = "0"; const { createApp, startServer } = require("../index"); const server = await startServer(createApp(), 0); const base = `http://127.0.0.1:${server.address().port}`; try { write(LANE, "pic.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255])); write( LANE, "x.svg", '' ); const created = await fetch(`${base}/api/lanes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: LANE, title: "raw" }), }).then((r) => r.json()); const id = created.lane.id; const png = await fetch(`${base}/api/lanes/${id}/git/raw?path=pic.png`); assert.equal(png.status, 200); assert.equal(png.headers.get("content-type"), "image/png"); assert.deepEqual( Buffer.from(await png.arrayBuffer()), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255]) ); const svg = await fetch(`${base}/api/lanes/${id}/git/raw?path=x.svg`); assert.equal(svg.headers.get("content-type"), "image/svg+xml"); assert.match(svg.headers.get("content-security-policy"), /sandbox/); assert.equal(svg.headers.get("x-content-type-options"), "nosniff"); const txt = await fetch(`${base}/api/lanes/${id}/git/raw?path=a.txt`); assert.equal(txt.status, 400); assert.equal((await txt.json()).error.code, "ENOTIMAGE"); await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" }); } finally { server.close(); } }); }); describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => { it("answers 403 for the old proxy key file (settings.local.json)", async () => { process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db"); process.env.DASHBOARD_REMOTE_SYNC_MS = "0"; process.env.DASHBOARD_LIVENESS_PROBE = "0"; const { createApp, startServer } = require("../index"); const server = await startServer(createApp(), 0); const base = `http://127.0.0.1:${server.address().port}`; try { write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}'); const created = await fetch(`${base}/api/lanes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: LANE, title: "secret" }), }).then((r) => r.json()); const id = created.lane.id; const r = await fetch( `${base}/api/lanes/${id}/git/file?path=.claude/settings.local.json&rev=work` ); assert.equal(r.status, 403); const body = await r.json(); assert.equal(body.error.code, "ESECRET"); assert.equal(JSON.stringify(body).includes("sk-x"), false); await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" }); } finally { server.close(); } }); }); describe("unstage of a staged rename", () => { it("unstages both halves: nothing staged, the new file left untracked, the old one deleted in the tree only", async () => { g(LANE, "mv", "old name.txt", "new name.txt"); await runGitAction(LANE, "unstage", { paths: ["new name.txt"] }); assert.equal(g(LANE, "diff", "--cached", "--name-only"), ""); assert.equal(fs.readFileSync(path.join(LANE, "new name.txt"), "utf8"), "keep me\nplease\n"); }); }); describe("readFileAt: one lexical guard for every rev", () => { let SUB; beforeEach(() => { write(LANE, ".claude/settings.local.json", '{"k":"sk-parent"}'); write(LANE, "root-only.txt", "ROOT CONTENT\n"); write(LANE, "pkg/in.txt", "inside\n"); write(LANE, "pkg/.claude/settings.local.json", '{"k":"sk-sub"}'); g(LANE, "add", "-f", "-A"); g(LANE, "commit", "-qm", "sub"); SUB = path.join(LANE, "pkg"); }); it("refuses ../ and absolute paths at a committed rev in a subdirectory lane", async () => { for (const p of [ "../.claude/settings.local.json", "./../root-only.txt", "x/../../root-only.txt", "/etc/passwd", ]) { await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "EBADPATH" }, p); } }); it("reads committed paths relative to the lane, not the repo root", async () => { const out = await readFileAt(SUB, "./in.txt", "HEAD"); assert.deepEqual(out.lines, ["inside"]); assert.equal(out.path, "in.txt"); // A repo-root path is NOT reachable by naming it without ../ await assert.rejects(readFileAt(SUB, "root-only.txt", "HEAD"), { code: "ENOFILE" }); }); it("refuses the lane's own secret file in its normalised spellings", async () => { for (const p of ["./.claude/settings.local.json", ".claude//settings.local.json"]) { await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "ESECRET" }, p); } }); }); describe("paths are literal, never pathspec patterns", () => { it("discarding an untracked 'new*.js' deletes only that file", async () => { for (const f of ["new*.js", "new1.js", "new2.js"]) write(LANE, f, "x\n"); await runGitAction(LANE, "discard", { paths: ["new*.js"], confirm: true }); assert.equal(fs.existsSync(path.join(LANE, "new*.js")), false); assert.equal(fs.existsSync(path.join(LANE, "new1.js")), true); assert.equal(fs.existsSync(path.join(LANE, "new2.js")), true); }); it("staging 'app/[id]/page.js' does not stage app/i/page.js", async () => { write(LANE, "app/[id]/page.js", "a\n"); write(LANE, "app/i/page.js", "b\n"); g(LANE, "add", "-A"); g(LANE, "commit", "-qm", "app"); write(LANE, "app/[id]/page.js", "a2\n"); write(LANE, "app/i/page.js", "b2\n"); await runGitAction(LANE, "stage", { paths: ["app/[id]/page.js"] }); assert.equal(g(LANE, "diff", "--cached", "--name-only"), "app/[id]/page.js\n"); }); it("a single-file diff of 'a[b].txt' carries only that file's hunks", async () => { write(LANE, "a[b].txt", "one\n"); write(LANE, "ab.txt", "one\n"); g(LANE, "add", "-A"); g(LANE, "commit", "-qm", "brackets"); write(LANE, "a[b].txt", "one\nmine\n"); write(LANE, "ab.txt", "one\nOTHER\n"); const out = await collectChanges(LANE, { range: "work", file: "a[b].txt" }); assert.equal(JSON.stringify(out.files[0].hunks).includes("OTHER"), false); assert.equal(out.files[0].hunks.length, 1); }); }); describe("a lane whose cwd is a subdirectory of its repo", () => { let SUB; beforeEach(() => { write(LANE, "pkg/in.txt", "one\n"); g(LANE, "add", "-A"); g(LANE, "commit", "-qm", "pkg"); SUB = path.join(LANE, "pkg"); write(LANE, "a.txt", "one\ntwo\nOUTSIDE EDIT\n"); write(LANE, "outside-new.txt", "no\n"); write(SUB, "in.txt", "one\ntwo\n"); write(SUB, "fresh.txt", "a\nb\nc\n"); }); it("lists only its own changes, lane-relative, with hunks", async () => { const out = await collectChanges(SUB, { range: "work", full: true }); assert.deepEqual( out.files.map((f) => f.path), ["fresh.txt", "in.txt"] ); const tracked = out.files.find((f) => f.path === "in.txt"); assert.equal(tracked.added, 1); assert.equal(tracked.hunks.length, 1); const fresh = out.files.find((f) => f.path === "fresh.txt"); assert.equal(fresh.added, 3); assert.equal(fresh.hunks[0].lines.length, 3); }); it("branch and commit ranges are lane-relative and lane-scoped too", async () => { g(LANE, "checkout", "-qb", "feat"); g(LANE, "add", "-A"); g(LANE, "commit", "-qm", "both"); const branch = await collectChanges(SUB, { range: "branch", base: "main", full: true }); assert.deepEqual( branch.files.map((f) => f.path), ["fresh.txt", "in.txt"] ); assert.equal(branch.files.find((f) => f.path === "in.txt").added, 1); const commit = await collectChanges(SUB, { range: "commit", sha: "HEAD", file: "in.txt" }); assert.equal(commit.files[0].hunks.length, 1); }); it("stage, unstage and discard work on lane-relative paths", async () => { await runGitAction(SUB, "stage", { paths: ["in.txt", "fresh.txt"] }); assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\npkg/in.txt\n"); await runGitAction(SUB, "unstage", { paths: ["in.txt"] }); assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\n"); await runGitAction(SUB, "discard", { paths: ["in.txt"], confirm: true }); assert.equal(fs.readFileSync(path.join(SUB, "in.txt"), "utf8"), "one\n"); }); it("cannot name a change outside its cwd", async () => { for (const p of ["../a.txt", "a.txt", "../outside-new.txt"]) { await assert.rejects( runGitAction(SUB, "discard", { paths: [p], confirm: true }), { code: "EBADPATH" }, p ); } assert.equal(fs.existsSync(path.join(LANE, "outside-new.txt")), true); }); it("gitFacts (the lane card) counts and lists only the lane's own files, cwd-relative", async () => { write(SUB, "mới tên.txt", "x\n"); const facts = await gitFacts(SUB); const paths = facts.files.map((f) => f.path).sort(); assert.deepEqual(paths, ["fresh.txt", "in.txt", "mới tên.txt"]); assert.equal(facts.dirty, 1); assert.equal(facts.untracked, 2); assert.equal(facts.files.find((f) => f.path === "in.txt").added, 1); }); it("commit sweeps in only the lane's own files", async () => { await runGitAction(SUB, "commit", { message: "lane only" }); assert.equal( g(LANE, "show", "--name-only", "--format=", "HEAD"), "pkg/fresh.txt\npkg/in.txt\n" ); assert.match(g(LANE, "status", "--porcelain"), /a\.txt/); }); it("refuses to commit while something OUTSIDE the lane is staged, and leaves it staged", async () => { const before = g(LANE, "rev-parse", "HEAD"); g(LANE, "add", "a.txt"); for (const only of [undefined, "staged"]) { g(SUB, "add", "in.txt"); await assert.rejects(runGitAction(SUB, "commit", { message: "lane only", only }), { code: "ESTAGEDOUTSIDE", }); } assert.match(g(LANE, "diff", "--cached", "--name-only"), /^a\.txt$/m); assert.equal(g(LANE, "rev-parse", "HEAD"), before); }); }); describe("reads larger than the git() buffer report truncated, not missing", () => { const BIG = `${"x".repeat(99)}\n`.repeat(100_000); // 10 MB it("readFileAt at a rev", async () => { write(LANE, "big.txt", BIG); g(LANE, "add", "-A"); g(LANE, "commit", "-qm", "big"); const out = await readFileAt(LANE, "big.txt", "HEAD"); assert.equal(out.truncated, true); assert.ok(out.lines.length > 1000); }); it("a single-file diff", async () => { write(LANE, "big.txt", "seed\n"); g(LANE, "add", "-A"); g(LANE, "commit", "-qm", "seed"); write(LANE, "big.txt", BIG); const out = await collectChanges(LANE, { range: "work", file: "big.txt" }); assert.equal(out.files[0].truncated, true); assert.ok(out.files[0].hunks[0].lines.length > 100); }); }); describe("a page with megabyte-long lines (inlined base64) keeps every hunk", () => { const blob = (c) => c.repeat(3 * 1024 * 1024); // one 3 MB line it("cuts the long rows down and still shows the hunks after them", async () => { const body = (mid, end) => [ "

title

", ...Array.from({ length: 40 }, (_, i) => `row ${i}`), mid, ...Array.from({ length: 40 }, (_, i) => `tail ${i}`), end, "", ].join("\n"); write(LANE, "deck.html", body(`