// The project checkouts in a role workspace (#1585). At every launch the // bus host makes sure each project of the business file has a git clone at // `//`, before it builds the bundle. The host runs // git here, outside the session's sandbox: // // no clone yet `git clone --no-hardlinks` of the project's root, at // the branch the root has checked out, into a temp // directory beside it and then renamed into place; a // clone that fails is removed and refuses the launch // a clean clone on fetched from its origin and fast-forwarded // the root's branch // a clone on another fetched from its origin and not merged: only // branch, or dirty refs/remotes moves, with the reason (#1611) // anything else left exactly as it is, with the reason // // The host never resets, cleans, deletes or force-updates a clone, and // never writes the root. A clone's origin is the root's path; no credential // or forge remote goes into it. // // The session can write its clone, so the host treats every clone as // hostile. A git config key, a hook or a redirected .git would otherwise // run a command or write a file outside the sandbox when the host runs git // there. So every git call gets an environment without the system and // global config, hooks pointed at /dev/null, fsmonitor off and only local // transports. And before git runs in an existing clone, the host checks // that the clone and its .git are real directories, that nothing under // .git is a symlink, a special file or a file with a second hard link, // that no commondir or alternates file sends git elsewhere, that // .git/hooks is empty, and that .git/config is a small regular file // holding only what a clone writes (allowed() below). A clone that fails a // check is kept, and git doesn't run in it. // // Those checks cover the clone's own .git and nothing else. A submodule // has its own config, which can name a filter driver, and git status // recurses into a populated submodule by running a second git in it. So // a clone with a gitlink in its index or anything under .git/modules is // kept too, and no host git recurses into a submodule: status ignores // them and submodule.recurse is off for every call. // // The checks hold only while nothing else writes the clone. The launcher // calls this for an instance only when no session of that instance runs // (its instance-running refusal), and each workspace is one instance's. import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { lstatSync, readdirSync, renameSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; export const GIT_TIMEOUT_MS = 100_000; // How long a git has after SIGTERM before SIGKILL. export const GIT_GRACE_MS = 5000; export const GIT_LIMITS = Object.freeze({ timeoutMs: GIT_TIMEOUT_MS, graceMs: GIT_GRACE_MS }); // A clone's config is a few hundred bytes, a little more per branch. export const CONFIG_MAX_BYTES = 64 * 1024; export class CheckoutError extends Error { constructor(project, message) { super(message); this.project = project; } } // Ahead of every git subcommand. Command-line config wins over any file. const SAFE = [ "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.allow=never", "-c", "protocol.file.allow=always", "-c", "submodule.recurse=false", ]; // The environment git runs with: the host's PATH and nothing else of it. // HOME doesn't exist, so no user file (attributes, ignore) is read either. export function gitEnv(env = process.env) { return { PATH: env.PATH ?? "/usr/bin:/bin", HOME: "/nonexistent", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GIT_MERGE_AUTOEDIT: "no", LC_ALL: "C", }; } // runGit(argv, env, limits) runs git with exactly `argv` and `env`, and // resolves { ok, out, err, why }; working.mjs runs its git through it too. // Async, so a long clone doesn't hold up the rest of the bus host. // // git runs as the leader of its own process group (detached), so a timeout // reaches what git started as well: SIGTERM to the group at // limits.timeoutMs, on which git removes its lockfiles, then SIGKILL to the // group limits.graceMs later. Once a git that was signalled has closed, the // group gets SIGKILL again, for a process that ignored SIGTERM and let go of // git's output. A git that ends on its own isn't signalled at all. One // consequence: a Ctrl-C at the terminal of a host run by hand no longer // reaches its git, though a systemd stop still does (it kills the cgroup). export function runGit(argv, env, limits = GIT_LIMITS) { return new Promise((resolve) => { const child = spawn("git", argv, { env, detached: true, stdio: ["ignore", "pipe", "pipe"] }); let out = ""; let err = ""; let signalled = false; let kill = null; // A git that never started has no pid; an empty group is ESRCH. Neither // leaves anything to signal. const group = (signal) => { try { process.kill(-child.pid, signal); } catch {} }; const term = setTimeout(() => { signalled = true; group("SIGTERM"); kill = setTimeout(() => group("SIGKILL"), limits.graceMs); }, limits.timeoutMs); const settle = (r) => { clearTimeout(term); clearTimeout(kill); if (signalled) group("SIGKILL"); resolve(r); }; child.stdout.setEncoding("utf8").on("data", (d) => (out += d)); child.stderr.setEncoding("utf8").on("data", (d) => (err += d)); // A git that can't start may not emit close, so error settles it too. child.on("error", (e) => settle({ ok: false, out, err, why: e.code ?? e.message })); child.on("close", (code, signal) => settle({ ok: code === 0, out, err, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` })); }); } // git in a clone or a root, with SAFE first. `limits` is for the tests. const git = (args, env, extra = {}, limits = GIT_LIMITS) => runGit([...SAFE, ...args], { ...gitEnv(env), ...extra }, limits); const inClone = (dest) => [`--git-dir=${join(dest, ".git")}`, `--work-tree=${dest}`]; // The keys `git clone` writes, and what each may hold. branch..remote // and .merge are the ones `git checkout -b`, `git branch -u` and the like // add. Every other key keeps the clone. const FETCH = "+refs/heads/*:refs/remotes/origin/*"; function allowed(key, value, root) { const branch = /^branch\..+\.(remote|merge)$/.exec(key); if (branch) return branch[1] === "remote" ? value === "origin" : /^refs\/heads\/./.test(value ?? ""); switch (key) { case "core.repositoryformatversion": return value === "0"; case "core.filemode": return value === "true" || value === "false"; case "core.bare": return value === "false"; case "core.logallrefupdates": return value === "true"; case "remote.origin.url": return value === root; case "remote.origin.fetch": return value === FETCH; default: return false; } } // Why the clone at `dest` isn't safe for the host to run git in, or null. async function unsafe(dest, root, g) { const isDir = (p) => { try { return lstatSync(p).isDirectory(); } catch { return false; } }; if (!isDir(dest) || !isDir(join(dest, ".git"))) return "not-a-clone"; // A second hard link would let a host write through to a file outside. const walk = (dir) => { for (const name of readdirSync(dir)) { const st = lstatSync(join(dir, name)); if (st.isDirectory()) { if (walk(join(dir, name))) return true; } else if (!st.isFile() || st.nlink !== 1) return true; } return false; }; try { if (walk(join(dest, ".git"))) return "not-a-clone"; } catch { return "not-a-clone"; } // A clone never has .git/modules; `git submodule` in the session makes one. try { lstatSync(join(dest, ".git", "modules")); return "submodule"; } catch {} for (const f of ["commondir", join("objects", "info", "alternates")]) { try { lstatSync(join(dest, ".git", f)); return "not-a-clone"; } catch {} } try { if (readdirSync(join(dest, ".git", "hooks")).length > 0) return "hooks-present"; } catch (e) { if (e.code !== "ENOENT") return "not-a-clone"; } try { const st = lstatSync(join(dest, ".git", "config")); if (!st.isFile() || st.size > CONFIG_MAX_BYTES) return "config-changed"; } catch { return "config-changed"; } const list = await g(["config", "--file", join(dest, ".git", "config"), "--list", "--null"]); if (!list.ok) return "config-changed"; const seen = new Set(); for (const entry of list.out.split("\0").filter(Boolean)) { const nl = entry.indexOf("\n"); const [key, value] = nl < 0 ? [entry, null] : [entry.slice(0, nl), entry.slice(nl + 1)]; if (seen.has(key) || !allowed(key, value, root)) return "config-changed"; seen.add(key); } if (!seen.has("remote.origin.url")) return "config-changed"; return null; } // The branch the root has checked out, null when it has none, or an error. // The ceiling stops git finding a repository above a root that isn't one. async function rootBranch(root, g) { const ceiling = { GIT_CEILING_DIRECTORIES: dirname(root) }; const r = await g(["-C", root, "symbolic-ref", "-q", "--short", "HEAD"], ceiling); if (r.ok) return { branch: r.out.trim() }; // symbolic-ref -q exits 1 with no output on a detached HEAD. const head = await g(["-C", root, "rev-parse", "--verify", "-q", "HEAD"], ceiling); return head.ok ? { branch: null } : { error: r.why }; } async function clone(project, root, workspace, dest, g) { const at = await rootBranch(root, g); if (at.error) throw new CheckoutError(project, `the root ${root} can't be read: ${at.error}`); if (!at.branch) throw new CheckoutError(project, `the root ${root} has no branch checked out`); // A project id never starts with a dot, so the temp name is no project's. const tmp = join(workspace, `.${project}.clone-${randomBytes(4).toString("hex")}`); let commit; try { const r = await g(["clone", "-q", "--no-hardlinks", "--template=", "--branch", at.branch, "--", root, tmp]); if (!r.ok) throw new CheckoutError(project, `git clone of ${root} failed: ${r.why}`); const head = await g([...inClone(tmp), "rev-parse", "--verify", "HEAD"]); if (!head.ok) throw new CheckoutError(project, `the new clone of ${root} has no readable HEAD: ${head.why}`); commit = head.out.trim(); renameSync(tmp, dest); } catch (e) { rmSync(tmp, { recursive: true, force: true }); if (e instanceof CheckoutError) throw e; throw new CheckoutError(project, `the clone of ${root} couldn't be put in place: ${e.message}`); } return { outcome: "cloned", branch: at.branch, commit }; } async function update(root, dest, g) { const problem = await unsafe(dest, root, g); if (problem) return { outcome: "kept", reason: problem }; // ls-files reads only the index: no filter, hook or submodule runs. const staged = await g([...inClone(dest), "ls-files", "--stage", "-z"]); if (!staged.ok) return { outcome: "kept", reason: "status-failed" }; if (staged.out.split("\0").some((e) => e.startsWith("160000 "))) return { outcome: "kept", reason: "submodule" }; const at = await rootBranch(root, g); if (at.error) return { outcome: "kept", reason: "root-unreadable" }; if (!at.branch) return { outcome: "kept", reason: "root-detached" }; // Row 117 (#1611): a clone on another branch, or dirty, still gets the // fetch, so origin/ is the root's at every launch. Only the merge // is left out. symbolic-ref -q fails on a detached HEAD: branch null. const head = await g([...inClone(dest), "symbolic-ref", "-q", "--short", "HEAD"]); const branch = head.ok ? head.out.trim() : null; let reason = null; if (branch !== at.branch) reason = "other-branch"; else { const status = await g(["--no-optional-locks", ...inClone(dest), "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"]); if (!status.ok) return { outcome: "kept", reason: "status-failed" }; if (status.out !== "") reason = "dirty"; } const first = await g([...inClone(dest), "rev-parse", "HEAD"]); const before = first.ok ? first.out.trim() : null; if (!(await g([...inClone(dest), "fetch", "-q", "--no-tags", "--no-recurse-submodules", "origin"])).ok) return { outcome: "kept", reason: "fetch-failed" }; if (reason) { const origin = await g([...inClone(dest), "rev-parse", "--verify", "-q", `refs/remotes/origin/${at.branch}`]); return { outcome: "fetched", reason, branch, commit: before, origin: { branch: at.branch, commit: origin.ok ? origin.out.trim() : null } }; } if (!(await g([...inClone(dest), "merge", "-q", "--ff-only", "--no-edit", `refs/remotes/origin/${at.branch}`])).ok) return { outcome: "kept", reason: "not-fast-forward" }; const read = await g([...inClone(dest), "rev-parse", "HEAD"]); const after = read.out.trim(); return { outcome: before === after ? "current" : "updated", branch: at.branch, commit: read.ok ? after : null }; } // await ensureCheckouts({ workspace, projects, env, limits }) for the // business file's projects ({ : { root } }). Returns one { project, // path, outcome, reason?, branch?, commit?, origin? } per project, outcome // cloned, updated, current, fetched or kept. branch and commit are the // clone's after this launch (commit null when git couldn't read it). A // fetched clone also has a reason (other-branch or dirty), branch null when // its HEAD is detached, and origin { branch, commit }: the root's branch and // refs/remotes/origin/ after the fetch, commit null when unreadable. // A kept clone has only a reason, since git may not be safe to run there. // Rejects with CheckoutError when a missing clone can't be made. `limits` // ({ timeoutMs, graceMs }) is for the tests. export async function ensureCheckouts({ workspace, projects, env = process.env, limits = GIT_LIMITS }) { const g = (args, extra) => git(args, env, extra, limits); const out = []; for (const [project, { root }] of Object.entries(projects)) { const dest = join(workspace, project); let exists = true; try { lstatSync(dest); } catch (e) { if (e.code !== "ENOENT") throw new CheckoutError(project, `${dest} can't be read: ${e.code}`); exists = false; } out.push({ project, path: dest, ...(exists ? await update(root, dest, g) : await clone(project, root, workspace, dest, g)) }); } return out; }