// The project checkouts in a role workspace (src/checkout.mjs), against // real git repositories in a temp directory. Every case also checks that // the root's files and refs are byte for byte what they were. import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CONFIG_MAX_BYTES, CheckoutError, ensureCheckouts, gitEnv } from "../src/checkout.mjs"; function tmp(t) { const dir = mkdtempSync(join(tmpdir(), "mosaic-checkout-")); t.after(() => { spawnSync("chmod", ["-R", "u+rwx", dir]); rmSync(dir, { recursive: true, force: true }); }); return dir; } // git for the test's own setup, with no user config. function git(cwd, ...args) { const r = spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.invalid", ...args], { cwd, encoding: "utf8", env: gitEnv() }); assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`); return r.stdout.trim(); } // A repository on `main` with one commit. function repo(dir, name = "root") { const root = join(dir, name); mkdirSync(root); git(root, "init", "-q", "-b", "main"); writeFileSync(join(root, "README"), "one\n"); git(root, "add", "README"); git(root, "commit", "-q", "-m", "one"); return root; } function commit(root, text) { writeFileSync(join(root, "README"), text); git(root, "commit", "-q", "-am", text.trim()); return git(root, "rev-parse", "HEAD"); } // Every path under `dir` with its type, mode, link count and contents or // link target. function fingerprint(dir) { const h = createHash("sha256"); const walk = (d) => { for (const name of readdirSync(d).sort()) { const p = join(d, name); const st = lstatSync(p); h.update(`${p}\0${st.mode}\0${st.nlink}\0`); if (st.isDirectory()) walk(p); else if (st.isSymbolicLink()) h.update(readlinkSync(p)); else if (st.isFile()) h.update(readFileSync(p)); } }; walk(dir); return h.digest("hex"); } // A root, an empty workspace, and `ensure()` for the one project `stack`. function world(t) { const dir = tmp(t); const root = repo(dir); const workspace = join(dir, "ws"); mkdirSync(workspace, { mode: 0o700 }); const projects = { stack: { root } }; return { dir, root, workspace, clone: join(workspace, "stack"), ensure: () => ensureCheckouts({ workspace, projects }) }; } // ensure() returning its one checkout, with the root unchanged by it. async function once(w) { const before = fingerprint(w.root); const list = await w.ensure(); assert.equal(list.length, 1); assert.equal(fingerprint(w.root), before, "the root is unchanged"); return list[0]; } test("a first launch clones the root at its branch, with the root's path as origin", async (t) => { const w = world(t); assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "cloned", branch: "main" }); assert.equal(git(w.clone, "symbolic-ref", "--short", "HEAD"), "main"); assert.equal(git(w.clone, "rev-parse", "HEAD"), git(w.root, "rev-parse", "HEAD")); assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root); assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "one\n"); assert.deepEqual(readdirSync(w.workspace), ["stack"], "no temp directory is left"); // --no-hardlinks: no file under .git is shared with the root, and no hook came with it. const files = readdirSync(join(w.clone, ".git"), { recursive: true }).map((p) => join(w.clone, ".git", p)).filter((p) => lstatSync(p).isFile()); assert.ok(files.some((p) => p.includes("/objects/"))); for (const p of files) assert.equal(lstatSync(p).nlink, 1, p); assert.equal(existsSync(join(w.clone, ".git", "hooks")), false); }); // A host stopped mid-clone must not leave a half clone where the next // launch takes it for the session's. A git on PATH runs the real one, then // notes whether /stack exists yet. test("the clone is not at its place until git clone has finished", async (t) => { const w = world(t); const bin = join(w.dir, "bin"); mkdirSync(bin); const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim(); const seen = join(w.dir, "seen"); writeFileSync(join(bin, "git"), `#!/bin/sh\n'${real}' "$@"; s=$?\ncase " $* " in *" clone "*) if [ -e '${w.clone}' ]; then echo there; else echo absent; fi >> '${seen}';; esac\nexit $s\n`, { mode: 0o755 }); const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } }); assert.equal(list[0].outcome, "cloned"); assert.equal(readFileSync(seen, "utf8"), "absent\n"); assert.deepEqual(readdirSync(w.workspace), ["stack"]); }); test("a later launch fast-forwards a clean clone after a commit to the root, then finds it current", async (t) => { const w = world(t); await once(w); const head = commit(w.root, "two\n"); assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "updated", branch: "main" }); assert.equal(git(w.clone, "rev-parse", "HEAD"), head); assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "two\n"); assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "current", branch: "main" }); assert.equal(git(w.clone, "status", "--porcelain"), ""); }); test("a clone ahead of the root keeps its commits and counts as current", async (t) => { const w = world(t); await once(w); const mine = commit(w.clone, "mine\n"); assert.equal((await once(w)).outcome, "current"); assert.equal(git(w.clone, "rev-parse", "HEAD"), mine); }); test("a dirty clone, one on another branch and one diverged are kept as they are", async (t) => { for (const [name, edit, reason] of [ ["a modified file", (c) => writeFileSync(join(c, "README"), "mine\n"), "dirty"], ["an untracked file", (c) => writeFileSync(join(c, "NEW"), "mine\n"), "dirty"], ["a staged file", (c) => (writeFileSync(join(c, "NEW"), "x"), git(c, "add", "NEW")), "dirty"], ["another branch", (c) => git(c, "checkout", "-q", "-b", "work"), "other-branch"], ["a detached HEAD", (c) => git(c, "checkout", "-q", "--detach"), "other-branch"], ["a diverged branch", (c) => commit(c, "mine\n"), "not-fast-forward"], ]) { await t.test(name, async (t) => { const w = world(t); await once(w); edit(w.clone); commit(w.root, "two\n"); const head = git(w.clone, "rev-parse", "HEAD"); const before = reason === "not-fast-forward" ? null : fingerprint(w.clone); assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason }); assert.equal(git(w.clone, "rev-parse", "HEAD"), head, "HEAD didn't move"); // A diverged clone gets the fetch into refs/remotes, and nothing else. if (before) assert.equal(fingerprint(w.clone), before, "the clone is exactly as it was"); else assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "mine\n"); }); } }); // A repository outside the workspace whose config and hooks run a command // that touches `marker`, for the cases that point git there. function bait(dir, marker) { const evil = repo(dir, "evil"); const script = join(dir, "run.sh"); writeFileSync(script, `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 }); git(evil, "config", "core.fsmonitor", script); git(evil, "config", "core.hooksPath", join(evil, ".git", "hooks")); mkdirSync(join(evil, ".git", "hooks"), { recursive: true }); for (const h of ["post-merge", "post-checkout", "reference-transaction", "pre-auto-gc", "post-index-change"]) writeFileSync(join(evil, ".git", "hooks", h), `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 }); return { evil, script }; } // A repository at /sub whose own config names a clean filter that // runs the bait script, applied to a tracked file left modified at the same // size, so any git status inside it must hash the file and run the filter. // Returns its commit. function nested(dir, b) { const sub = join(dir, "sub"); mkdirSync(sub); git(sub, "init", "-q", "-b", "main"); writeFileSync(join(sub, "data"), "one\n"); writeFileSync(join(sub, ".gitattributes"), "data filter=x\n"); git(sub, "add", "data", ".gitattributes"); git(sub, "commit", "-q", "-m", "sub"); git(sub, "config", "filter.x.clean", b.script); writeFileSync(join(sub, "data"), "ONE\n"); return git(sub, "rev-parse", "HEAD"); } const gitlink = (c, b) => git(c, "update-index", "--add", "--cacheinfo", `160000,${nested(c, b)},sub`); // /sub carries its own gitlink to /sub/sub, which alone names the filter. const gitlinkNested = (c, b) => { const outer = join(c, "sub"); mkdirSync(outer); git(outer, "init", "-q", "-b", "main"); writeFileSync(join(outer, "f"), "x\n"); git(outer, "add", "f"); gitlink(outer, b); git(outer, "commit", "-q", "-m", "outer"); git(c, "update-index", "--add", "--cacheinfo", `160000,${git(outer, "rev-parse", "HEAD")},sub`); }; const gitmodules = (c) => (writeFileSync(join(c, ".gitmodules"), '[submodule "sub"]\n\tpath = sub\n\turl = ./sub\n'), git(c, "add", ".gitmodules")); // Each way a session could make the host's git run a command or write // outside the clone. Every one keeps the clone, nothing runs, and neither // the root nor the bait repository changes. test("a clone the session rigged is kept, and git runs nothing and writes nothing outside it", async (t) => { const egit = (b) => join(b.evil, ".git"); for (const [name, edit, reason] of [ ["core.fsmonitor", (c, b) => git(c, "config", "core.fsmonitor", b.script), "config-changed"], ["core.hooksPath", (c, b) => git(c, "config", "core.hooksPath", join(egit(b), "hooks")), "config-changed"], ["a hook file", (c, b) => { mkdirSync(join(c, ".git", "hooks")); writeFileSync(join(c, ".git", "hooks", "post-merge"), readFileSync(join(egit(b), "hooks", "post-merge")), { mode: 0o755 }); }, "hooks-present"], ["include.path", (c, b) => git(c, "config", "include.path", join(egit(b), "config")), "config-changed"], ["includeIf", (c, b) => git(c, "config", "includeIf.gitdir:/.path", join(egit(b), "config")), "config-changed"], ["a filter driver", (c, b) => { git(c, "config", "filter.x.smudge", `${b.script}; cat`); mkdirSync(join(c, ".git", "info"), { recursive: true }); writeFileSync(join(c, ".git", "info", "attributes"), "* filter=x\n"); }, "config-changed"], ["origin moved", (c, b) => git(c, "remote", "set-url", "origin", b.evil), "config-changed"], ["a second origin url", (c, b) => git(c, "config", "--add", "remote.origin.url", b.evil), "config-changed"], ["the origin url twice, both the root", (c) => git(c, "config", "--add", "remote.origin.url", git(c, "config", "--get", "remote.origin.url")), "config-changed"], ["an ext:: origin", (c, b) => git(c, "remote", "set-url", "origin", `ext::${b.script}`), "config-changed"], ["a branch tracking another remote", (c) => git(c, "config", "branch.main.remote", "elsewhere"), "config-changed"], ["a branch merging a tag", (c) => git(c, "config", "branch.main.merge", "refs/tags/x"), "config-changed"], ["a fetch line onto local branches", (c) => git(c, "config", "remote.origin.fetch", "+refs/heads/*:refs/heads/*"), "config-changed"], ["no origin", (c) => git(c, "remote", "remove", "origin"), "config-changed"], ["a config file over the size cap", (c) => writeFileSync(join(c, ".git", "config"), `${readFileSync(join(c, ".git", "config"), "utf8")}#${"x".repeat(CONFIG_MAX_BYTES)}\n`), "config-changed"], ["config a symlink to /dev/zero", (c) => (rmSync(join(c, ".git", "config")), symlinkSync("/dev/zero", join(c, ".git", "config"))), "not-a-clone"], ["the clone a symlink to the bait", (c, b) => (rmSync(c, { recursive: true }), symlinkSync(b.evil, c)), "not-a-clone"], ["the clone a file", (c) => (rmSync(c, { recursive: true }), writeFileSync(c, "x")), "not-a-clone"], ["the clone a plain directory", (c) => rmSync(join(c, ".git"), { recursive: true }), "not-a-clone"], [".git a gitdir: file", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), writeFileSync(join(c, ".git"), `gitdir: ${egit(b)}\n`)), "not-a-clone"], [".git a symlink", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), symlinkSync(egit(b), join(c, ".git"))), "not-a-clone"], [".git/logs a symlink", (c, b) => (rmSync(join(c, ".git", "logs"), { recursive: true }), symlinkSync(join(egit(b), "logs"), join(c, ".git", "logs"))), "not-a-clone"], [".git/refs/heads a symlink", (c, b) => (rmSync(join(c, ".git", "refs", "heads"), { recursive: true }), symlinkSync(join(egit(b), "refs", "heads"), join(c, ".git", "refs", "heads"))), "not-a-clone"], [".git/objects a symlink", (c, b) => (rmSync(join(c, ".git", "objects"), { recursive: true }), symlinkSync(join(egit(b), "objects"), join(c, ".git", "objects"))), "not-a-clone"], ["a reflog hard-linked to a file outside", (c, b) => (rmSync(join(c, ".git", "logs", "HEAD")), linkSync(join(egit(b), "logs", "HEAD"), join(c, ".git", "logs", "HEAD"))), "not-a-clone"], ["HEAD a fifo", (c) => (rmSync(join(c, ".git", "HEAD")), spawnSync("mkfifo", [join(c, ".git", "HEAD")])), "not-a-clone"], ["a commondir file", (c, b) => writeFileSync(join(c, ".git", "commondir"), `${egit(b)}\n`), "not-a-clone"], ["an alternates file", (c, b) => (mkdirSync(join(c, ".git", "objects", "info"), { recursive: true }), writeFileSync(join(c, ".git", "objects", "info", "alternates"), `${join(egit(b), "objects")}\n`)), "not-a-clone"], // A submodule's own config is outside every check above, and git status // runs a second git inside a populated submodule. ["a gitlink staged, no .gitmodules", gitlink, "submodule"], ["a gitlink and .gitmodules staged", (c, b) => (gitlink(c, b), gitmodules(c)), "submodule"], ["a gitlink to a repository with its own gitlink", gitlinkNested, "submodule"], ["a gitlink and .gitmodules committed", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "commit", "-q", "-m", "sub")), "submodule"], ["a gitlink and submodule.active in config", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "config", "submodule.active", ".")), "config-changed"], ["a .git/modules with no gitlink", (c, b) => { const m = join(c, ".git", "modules", "sub"); git(c, "init", "-q", "--bare", "--template=", m); git(m, "config", "filter.x.clean", b.script); }, "submodule"], ]) { await t.test(name, async (t) => { const w = world(t); const marker = join(w.dir, "RAN"); const b = bait(w.dir, marker); await once(w); edit(w.clone, b); commit(w.root, "two\n"); const evilBefore = fingerprint(b.evil); assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason }); assert.equal(existsSync(marker), false, "nothing the session wrote ran"); assert.equal(fingerprint(b.evil), evilBefore, "nothing outside the clone was written"); }); } }); test("a root that can't be cloned refuses with the project id and leaves nothing behind", async (t) => { for (const [name, make, why] of [ ["missing", (w) => rmSync(w.root, { recursive: true }), /can't be read/], ["not a repository", (w) => rmSync(join(w.root, ".git"), { recursive: true }), /can't be read/], ["a plain directory inside another repository", (w) => { rmSync(join(w.root, ".git"), { recursive: true }); git(w.dir, "init", "-q", "-b", "main"); git(w.dir, "commit", "-q", "--allow-empty", "-m", "outer"); }, /can't be read/], ["unreadable", (w) => chmodSync(w.root, 0o000), /can't be read/], ["on no branch", (w) => git(w.root, "checkout", "-q", "--detach"), /no branch checked out/], ["missing an object", (w) => { // git starts writing the clone, then can't check the file out. const blob = git(w.root, "rev-parse", "HEAD:README"); rmSync(join(w.root, ".git", "objects", blob.slice(0, 2), blob.slice(2))); }, /git clone of .* failed/], ]) { await t.test(name, async (t) => { const w = world(t); make(w); await assert.rejects(w.ensure, (e) => e instanceof CheckoutError && e.project === "stack" && why.test(e.message)); assert.deepEqual(readdirSync(w.workspace), [], "no clone and no temp directory"); }); } }); test("a clone whose root is on no branch, or gone, is kept and named", async (t) => { const w = world(t); await once(w); git(w.root, "checkout", "-q", "--detach"); assert.equal((await once(w)).reason, "root-detached"); rmSync(join(w.root, ".git"), { recursive: true }); // The clone's config still names this path, so its check passes and the root's read fails. assert.deepEqual(await w.ensure(), [{ project: "stack", path: w.clone, outcome: "kept", reason: "root-unreadable" }]); }); test("no credential or forge remote of the root reaches a clone", async (t) => { const w = world(t); git(w.root, "remote", "add", "origin", "https://bot:s3cr3t-token@git.example/acme/stack.git"); git(w.root, "remote", "add", "gitea", "ssh://git@git.example/acme/stack.git"); git(w.root, "config", "http.extraHeader", "Authorization: token s3cr3t-token"); const secret = () => spawnSync("grep", ["-rlE", "s3cr3t|git\\.example", join(w.clone, ".git")], { encoding: "utf8" }).stdout; await once(w); assert.equal(git(w.clone, "remote"), "origin"); assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root); assert.equal(secret(), ""); // And the fast-forward keeps it that way. commit(w.root, "two\n"); assert.equal((await once(w)).outcome, "updated"); assert.equal(secret(), ""); }); test("every project in the business file gets its own clone at its own branch", async (t) => { const dir = tmp(t); const a = repo(dir, "a"); const b = repo(dir, "b"); git(b, "checkout", "-q", "-b", "dev"); const workspace = join(dir, "ws"); mkdirSync(workspace); const list = await ensureCheckouts({ workspace, projects: { alpha: { root: a }, beta: { root: b } } }); assert.deepEqual(list.map((c) => [c.project, c.path, c.outcome, c.branch]), [["alpha", join(workspace, "alpha"), "cloned", "main"], ["beta", join(workspace, "beta"), "cloned", "dev"]]); assert.equal(git(join(workspace, "beta"), "symbolic-ref", "--short", "HEAD"), "dev"); }); // A git on PATH that, for `git clone`, runs `body` in place of the real one. // Every other git is the real one. function slowClone(w, body) { const bin = join(w.dir, "bin"); mkdirSync(bin); const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim(); writeFileSync(join(bin, "git"), `#!/bin/bash\ncase " $* " in *" clone "*) ${body} ;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 }); return { PATH: `${bin}:${process.env.PATH}` }; } // True once `pid` has gone, or is a zombie waiting for init to reap it. async function gone(pid, ms = 3000) { const end = Date.now() + ms; for (;;) { let stat = null; try { stat = readFileSync(`/proc/${pid}/stat`, "utf8"); } catch {} if (stat === null || stat.slice(stat.lastIndexOf(")") + 2)[0] === "Z") return true; if (Date.now() > end) return false; await new Promise((r) => setTimeout(r, 50)); } } // SIGKILL to a sleep a test left behind, if it still runs. const kill = (pid) => { try { process.kill(pid, "SIGKILL"); } catch {} }; // The pid a wrapper wrote to `file`, once it is there. async function pidIn(file) { for (let i = 0; i < 100 && !existsSync(file); i++) await new Promise((r) => setTimeout(r, 20)); return Number(readFileSync(file, "utf8").trim()); } test("a clone past its time gets SIGTERM and refuses the launch, with nothing left in the workspace", async (t) => { const w = world(t); const env = slowClone(w, "exec sleep 30"); await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), (e) => e instanceof CheckoutError && /killed by SIGTERM/.test(e.message)); assert.deepEqual(readdirSync(w.workspace), []); }); test("a clone that ignores SIGTERM gets SIGKILL once the grace period is over", { timeout: 20000 }, async (t) => { const w = world(t); const env = slowClone(w, "trap '' TERM; sleep 30"); const started = Date.now(); await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 300, graceMs: 700 } }), (e) => e instanceof CheckoutError && /killed by SIGKILL/.test(e.message)); assert.ok(Date.now() - started >= 1000, "SIGKILL waited out the grace period"); }); // git is the leader of its own process group, and the group gets the signal: // a process git started (here a sleep) doesn't outlive the kill. test("a timeout signals git's process group, so a process git started dies with it", { timeout: 20000 }, async (t) => { const w = world(t); const file = join(w.dir, "child.pid"); const env = slowClone(w, `sleep 30 /dev/null 2>&1 & echo $! > '${file}'; wait`); await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), /killed by SIGTERM/); const pid = await pidIn(file); t.after(() => kill(pid)); assert.equal(await gone(pid), true, "the sleep git started is gone"); }); // git dies of SIGTERM, but a process it started ignores SIGTERM and holds // none of git's output, so git's close comes at once. The group gets // SIGKILL then, not after the grace period. test("once a signalled git has closed, its group gets SIGKILL, so a process that ignored SIGTERM dies too", { timeout: 20000 }, async (t) => { const w = world(t); const file = join(w.dir, "child.pid"); const env = slowClone(w, `(trap '' TERM; exec sleep 30) /dev/null 2>&1 & echo $! > '${file}'; exec sleep 30`); await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), /killed by SIGTERM/); const pid = await pidIn(file); t.after(() => kill(pid)); assert.equal(await gone(pid, 1500), true, "the sleep that ignored SIGTERM is gone well inside the grace period"); });