cli, seat, harness: git socket hardening and refusal tests, row 100 (#1594)

Row 93 follow-up: SIGTERM then SIGKILL for host git, rollback-failed,
a total body deadline, fetch-aborted for a reviewer gone mid-fetch,
working refs first in have, and tests for five untested refusals.
Dewey approved in 28226 and Sage in 28225; Sage's full gate passed
16:57:32-17:01:32Z. The host side is live at the next bus restart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 12:15:52 -05:00
co-authored by Claude Opus 5.5
parent c8302a0675
commit 664199f794
8 changed files with 619 additions and 62 deletions
+15 -1
View File
@@ -323,11 +323,25 @@ A push runs under the repository's lock, in this order:
fast-forward (`non-fast-forward`).
8. The broker records the push, an `action.allowed` for
`git.push.working` naming the commit. When it
refuses, the work ref goes back to where it was.
refuses, the work ref goes back to where it was. A work ref that can't
go back refuses `rollback-failed` in place of the broker's code, with a
message naming the ref and the commit it still holds. The host logs it,
since the operator has to move that ref by hand.
The temp ref is deleted on every path, and so is the bundle file in
`bus-host/`. No other ref moves, and the root is only read.
The repository's lock is a promise chain inside the host's process, so it
assumes one launcher per dataRoot. `startHost` refuses a second bus host
while `bus-host/host.json` names a live one, but that check isn't atomic:
two launchers started at the same moment could both pass it, and their
pushes to one repository would no longer be serialized.
A host git call that runs past 100 s (`GIT_TIMEOUT_MS`) gets SIGTERM. git
removes its lockfiles on SIGTERM. Only a git still running 5 s later
(`GIT_GRACE_MS`) gets SIGKILL, which can leave a ref's `.lock` behind; that
lock refuses every later push to the ref until someone removes it.
For a review, under the same lock, the host fetches `main` again and
writes a bundle of the requested commit with one ref,
`refs/mosaic/review`. It leaves out the commits the reviewer says its
+63 -33
View File
@@ -28,10 +28,19 @@
// 6. the commit shares history with main (unrelated-history)
// 7. the temp ref moves to the work ref with no `+`, so git refuses
// anything but a fast-forward (non-fast-forward)
// 8. record() runs; when it throws, the work ref goes back
// 8. record() runs; when it throws, the work ref goes back. A work ref
// that can't go back refuses rollback-failed in place of record's error.
// The temp ref is deleted on every path. The caller owns the bundle file and
// deletes it.
//
// A git past GIT_TIMEOUT_MS gets SIGTERM, on which git removes its lockfiles,
// and SIGKILL only if it still runs GIT_GRACE_MS later. A SIGKILL leaves a
// ref's .lock behind, and that lock refuses every later push to the ref.
//
// The lock is a promise chain in this process, so it holds only while one
// launcher serves a dataRoot. startHost refuses a second bus host while
// bus-host/host.json names a live one; that check isn't atomic.
//
// review, under the same lock: main again from the root, then a bundle of
// the requested commit that leaves out the commits the reviewer said it has
// (those the repository knows), written to the file the caller names.
@@ -50,6 +59,9 @@ const V2 = "# v2 git bundle\n";
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
const TASK_REF = /^vikunja:([1-9][0-9]*)\/([1-9][0-9]*)$/;
const PROJECT = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// How long a git that got SIGTERM has to exit before SIGKILL.
export const GIT_GRACE_MS = 5000;
const LIMITS = { timeoutMs: GIT_TIMEOUT_MS, graceMs: GIT_GRACE_MS };
export class WorkingError extends Error {
constructor(code, message = code) {
@@ -72,17 +84,30 @@ const SAFE = [
// Ahead of the fetch from a bundle. Tests replace it to show it matters.
export const FSCK = ["-c", "transfer.fsckObjects=true"];
function git(args, env, { pre = [] } = {}) {
// `limits` is { timeoutMs, graceMs }; the tests shorten them.
function git(args, env, { pre = [], limits = LIMITS } = {}) {
return new Promise((resolve) => {
const child = spawn("git", [...SAFE, ...pre, ...args], { env: gitEnv(env), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "pipe"] });
const child = spawn("git", [...SAFE, ...pre, ...args], { env: gitEnv(env), stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
let kill = null;
const term = setTimeout(() => {
child.kill("SIGTERM");
kill = setTimeout(() => child.kill("SIGKILL"), limits.graceMs);
}, limits.timeoutMs);
const settle = (r) => {
clearTimeout(term);
clearTimeout(kill);
resolve(r);
};
child.stdout.setEncoding("utf8").on("data", (d) => (out += d));
child.stderr.setEncoding("utf8").on("data", (d) => (err += d));
child.on("error", (e) => resolve({ ok: false, out, err, why: e.code ?? e.message }));
child.on("close", (code, signal) => resolve({ ok: code === 0, out, err, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` }));
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 with this call's env and limits, for the helpers below.
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
// The branch name a task's work goes to. A task ref holds a colon, which a
// ref name can't.
@@ -117,7 +142,7 @@ function locked(repo, fn) {
// The bare repository, made on first use in a temp directory beside it and
// renamed into place. One that exists must be a real directory.
async function ensure(repo, env) {
async function ensure(repo, g) {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid", `${repo} isn't a directory`);
return;
@@ -128,7 +153,7 @@ async function ensure(repo, env) {
mkdirSync(dirname(repo), { recursive: true, mode: 0o700 });
const tmp = join(dirname(repo), `.init-${randomBytes(4).toString("hex")}`);
try {
const r = await git(["init", "-q", "--bare", "--template=", tmp], env);
const r = await g(["init", "-q", "--bare", "--template=", tmp]);
if (!r.ok) refuse("working-repo-invalid", `git init failed: ${r.why}`);
renameSync(tmp, repo);
} finally {
@@ -138,8 +163,8 @@ async function ensure(repo, env) {
// main from what the root has checked out. The root is the owner's; it isn't
// a session's.
async function seed(repo, root, env) {
const r = await git([`--git-dir=${repo}`, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/main"], env);
async function seed(repo, root, g) {
const r = await g([`--git-dir=${repo}`, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/main"]);
if (!r.ok) refuse("root-unreadable", `main couldn't be fetched from ${root}: ${r.why}`);
}
@@ -153,68 +178,73 @@ function header(file) {
}
}
const tip = async (repo, ref, env) => {
const r = await git([`--git-dir=${repo}`, "rev-parse", "-q", "--verify", `${ref}^{commit}`], env);
const tip = async (repo, ref, g) => {
const r = await g([`--git-dir=${repo}`, "rev-parse", "-q", "--verify", `${ref}^{commit}`]);
return r.ok ? r.out.trim() : null;
};
// await push({ dataRoot, business, project, root, taskRef, branch, bundle, record, env, fsck })
// await push({ dataRoot, business, project, root, taskRef, branch, bundle, record, env, fsck, limits })
// Resolves { ref, commit, previous }; rejects with WorkingError. `record`
// runs under the lock once the ref moved, and a throw moves it back. `fsck`
// is for the tests.
export function push({ dataRoot, business, project, root, taskRef, branch, bundle, record = async () => {}, env = process.env, fsck = FSCK }) {
// and `limits` are for the tests.
export function push({ dataRoot, business, project, root, taskRef, branch, bundle, record = async () => {}, env = process.env, fsck = FSCK, limits = LIMITS }) {
if (!PROJECT.test(project ?? "")) refuse("invalid-request", "not a project id");
if (!validBranch(branch)) refuse("invalid-request", "not a branch name");
const ref = `refs/heads/${workRef(taskRef)}`;
const repo = workingRepo(dataRoot, business, project);
const g = bound(env, limits);
return locked(repo, async () => {
if (!header(bundle)) refuse("invalid-bundle", "not a v2 git bundle");
await ensure(repo, env);
await seed(repo, root, env);
await ensure(repo, g);
await seed(repo, root, g);
const at = [`--git-dir=${repo}`];
// Not -q: with it, git 2.55 drops the list of missing prerequisites.
const verify = await git([...at, "bundle", "verify", bundle], env);
const verify = await g([...at, "bundle", "verify", bundle]);
if (!verify.ok) {
if (/lacks these prerequisite/.test(verify.err)) refuse("missing-prerequisite", "the bundle needs commits the working repository doesn't have");
refuse("invalid-bundle", verify.why);
}
const heads = await git([...at, "bundle", "list-heads", bundle], env);
const heads = await g([...at, "bundle", "list-heads", bundle]);
if (!heads.ok) refuse("invalid-bundle", heads.why);
if (!heads.out.split("\n").some((l) => l.split(" ")[1] === `refs/heads/${branch}`)) refuse("branch-not-in-bundle");
const incoming = `refs/mosaic/incoming/${randomBytes(8).toString("hex")}`;
try {
const fetched = await git([...at, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", bundle, `refs/heads/${branch}:${incoming}`], env, { pre: fsck });
const fetched = await g([...at, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", bundle, `refs/heads/${branch}:${incoming}`], { pre: fsck });
if (!fetched.ok) refuse("invalid-bundle", fetched.why);
const commit = await tip(repo, incoming, env);
const commit = await tip(repo, incoming, g);
if (!commit) refuse("invalid-bundle", "the branch isn't a commit");
if (!(await git([...at, "merge-base", commit, "refs/heads/main"], env)).ok) refuse("unrelated-history", "the commit shares no history with main");
const previous = await tip(repo, ref, env);
if (!(await g([...at, "merge-base", commit, "refs/heads/main"])).ok) refuse("unrelated-history", "the commit shares no history with main");
const previous = await tip(repo, ref, g);
// From the repository into itself, by its path (`.` would be the cwd), and
// not -q, which drops the "(non-fast-forward)" reason.
const moved = await git([...at, "fetch", "--no-tags", "--no-write-fetch-head", "--no-auto-maintenance", repo, `${incoming}:${ref}`], env);
const moved = await g([...at, "fetch", "--no-tags", "--no-write-fetch-head", "--no-auto-maintenance", repo, `${incoming}:${ref}`]);
if (!moved.ok) refuse(/\(non-fast-forward\)/.test(moved.err) ? "non-fast-forward" : "push-failed", moved.why);
try {
await record({ ref: workRef(taskRef), commit, previous });
} catch (e) {
const back = previous ? ["update-ref", ref, previous, commit] : ["update-ref", "-d", ref, commit];
await git([...at, ...back], env);
const undone = await g([...at, ...back]);
// The ref still names a commit the broker didn't record. That is the
// operator's to fix, so it outranks record's own error.
if (!undone.ok) refuse("rollback-failed", `${ref} still names ${commit}, which wasn't recorded (${e.code ?? e.message}); moving it back failed: ${undone.why}`);
throw e;
}
return { ref: workRef(taskRef), commit, previous };
} finally {
await git([...at, "update-ref", "-d", incoming], env);
await g([...at, "update-ref", "-d", incoming]);
}
});
}
// await review({ dataRoot, business, project, root, taskRef, commit, have, out, env })
// await review({ dataRoot, business, project, root, taskRef, commit, have, out, env, limits })
// Writes a bundle of `commit` to `out`, with one ref, refs/mosaic/review.
// `have` lists commits the reviewer's clone holds; those the repository
// doesn't know are skipped. Resolves { ref, commit }.
export function review({ dataRoot, business, project, root, taskRef, commit, have = [], out, env = process.env }) {
export function review({ dataRoot, business, project, root, taskRef, commit, have = [], out, env = process.env, limits = LIMITS }) {
if (!PROJECT.test(project ?? "") || !OID.test(commit ?? "")) refuse("invalid-request");
if (!Array.isArray(have) || have.length > HAVE_MAX || have.some((h) => !OID.test(h ?? ""))) refuse("invalid-request", "have must list commit ids");
const repo = workingRepo(dataRoot, business, project);
const g = bound(env, limits);
return locked(repo, async () => {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
@@ -222,18 +252,18 @@ export function review({ dataRoot, business, project, root, taskRef, commit, hav
if (e instanceof WorkingError) throw e;
refuse("unknown-commit", "the working repository doesn't exist");
}
await seed(repo, root, env);
await seed(repo, root, g);
const at = [`--git-dir=${repo}`];
if (!(await tip(repo, commit, env))) refuse("unknown-commit");
if (!(await tip(repo, commit, g))) refuse("unknown-commit");
const known = [];
for (const h of have) if (await tip(repo, h, env)) known.push(h);
for (const h of have) if (await tip(repo, h, g)) known.push(h);
const temp = "refs/mosaic/review";
try {
if (!(await git([...at, "update-ref", temp, commit], env)).ok) refuse("fetch-failed");
const made = await git([...at, "bundle", "create", "-q", out, temp, ...(known.length ? ["--not", ...known] : [])], env);
if (!(await g([...at, "update-ref", temp, commit])).ok) refuse("fetch-failed");
const made = await g([...at, "bundle", "create", "-q", out, temp, ...(known.length ? ["--not", ...known] : [])]);
if (!made.ok) refuse(/empty bundle/.test(made.err) ? "nothing-to-fetch" : "fetch-failed", made.why);
} finally {
await git([...at, "update-ref", "-d", temp], env);
await g([...at, "update-ref", "-d", temp]);
}
return { ref: workRef(taskRef), commit };
});
+108 -1
View File
@@ -8,7 +8,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gitEnv } from "../src/checkout.mjs";
@@ -87,6 +87,34 @@ async function refuses(w, fn, code) {
assert.doesNotMatch(w.refs(), /refs\/mosaic\//, "no temp ref is left");
}
// The .lock files under a repository's refs.
function locks(repo) {
const found = [];
const walk = (d) => {
for (const name of readdirSync(d)) {
const p = join(d, name);
if (lstatSync(p).isDirectory()) walk(p);
else if (name.endsWith(".lock")) found.push(p.slice(repo.length + 1));
}
};
walk(repo);
return found;
}
// A `git` first in PATH that runs `body` (bash) with REAL as the real git,
// and the env that puts it there.
const REAL_GIT = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
function wrapGit(w, body) {
const bin = join(w.dir, "bin");
mkdirSync(bin);
writeFileSync(join(bin, "git"), `#!/bin/bash
REAL='${REAL_GIT}'
${body}
`);
chmodSync(join(bin, "git"), 0o755);
return { ...process.env, PATH: `${bin}:${process.env.PATH}` };
}
async function lands(w, fn) {
const root = fingerprint(w.root);
const r = await fn();
@@ -236,6 +264,85 @@ test("a record that throws moves the work ref back, or removes a new one", async
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
});
test("a work ref that can't go back refuses rollback-failed, naming the ref and the commit", async (t) => {
const w = world(t);
git(w.clone, "checkout", "-q", "-b", "feat");
const c1 = commit(w.clone, "two");
// record() fails after taking the ref's lock, so moving the ref back fails.
const lock = join(w.repo, `${REF}.lock`);
const stuck = async () => {
writeFileSync(lock, "");
throw new Error("not recorded");
};
const message = (c) => (e) => e instanceof WorkingError && e.code === "rollback-failed" && e.message.includes(`${REF} still names ${c}`) && /not recorded/.test(e.message);
// A new ref that can't be removed.
await assert.rejects(w.go(w.bundle(), { record: stuck }), message(c1));
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
rmSync(lock);
// A ref that can't go back to the one before.
git(w.clone, "update-ref", "refs/remotes/working/work/vikunja-1-2", c1);
const c2 = commit(w.clone, "three");
await assert.rejects(w.go(w.bundle(), { record: stuck }), message(c2));
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c2}`);
rmSync(lock);
assert.deepEqual(locks(w.repo), []);
});
test("a root that can't be read refuses root-unreadable", async (t) => {
const w = world(t);
git(w.clone, "checkout", "-q", "-b", "feat");
commit(w.clone, "two");
await refuses(w, () => w.go(w.bundle(), { root: join(w.dir, "no-root") }), "root-unreadable");
});
test("a git past its time gets SIGTERM, leaves no ref lock, and the next push lands", async (t) => {
const w = world(t);
git(w.clone, "checkout", "-q", "-b", "feat");
const c1 = commit(w.clone, "two");
await w.go(w.bundle());
git(w.clone, "update-ref", "refs/remotes/working/work/vikunja-1-2", c1);
// The move of the work ref holds the ref's lock in a reference-transaction
// hook until the lock is gone, so the git is still holding it when its time
// is up. Only that one fetch gets the hook.
const hooks = join(w.dir, "hooks");
mkdirSync(hooks);
const lock = join(w.repo, `${REF}.lock`);
writeFileSync(join(hooks, "reference-transaction"), `#!/bin/bash\n[ "$1" = prepared ] || exit 0\nfor i in $(seq 200); do [ -e '${lock}' ] || exit 0; sleep 0.05; done\n`);
chmodSync(join(hooks, "reference-transaction"), 0o755);
const env = wrapGit(
w,
`self=0
for a in "$@"; do case "$a" in refs/mosaic/incoming/*:refs/heads/work/*) self=1;; esac; done
args=()
for a in "$@"; do
[ $self = 1 ] && [ "$a" = core.hooksPath=/dev/null ] && a='core.hooksPath=${hooks}'
args+=("$a")
done
exec "$REAL" "\${args[@]}"`,
);
commit(w.clone, "three");
const limits = { timeoutMs: 2000, graceMs: 5000 };
await assert.rejects(w.go(w.bundle(), { env, limits }), (e) => e instanceof WorkingError && e.code === "push-failed" && /killed by SIGTERM/.test(e.message));
assert.deepEqual(locks(w.repo), []);
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
const c3 = commit(w.clone, "four");
const r = await lands(w, () => w.go(w.bundle()));
assert.deepEqual(r, { ref: "work/vikunja-1-2", commit: c3, previous: c1 });
});
test("a git that ignores SIGTERM gets SIGKILL once the grace period is over", { timeout: 20000 }, async (t) => {
const w = world(t);
git(w.clone, "checkout", "-q", "-b", "feat");
commit(w.clone, "two");
// The fetch of main from the root never ends and ignores SIGTERM.
const env = wrapGit(
w,
`for a in "$@"; do [ "$a" = +HEAD:refs/heads/main ] && { trap '' TERM; while :; do sleep 0.05; done; }; done
exec "$REAL" "$@"`,
);
await assert.rejects(w.go(w.bundle(), { env, limits: { timeoutMs: 500, graceMs: 500 } }), (e) => e instanceof WorkingError && e.code === "root-unreadable" && /killed by SIGKILL/.test(e.message));
});
test("a working repository path that isn't a directory refuses", async (t) => {
const w = world(t);
git(w.clone, "checkout", "-q", "-b", "feat");