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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user