import { createHash, randomBytes } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import type { Actor } from "./actor";
import { refusal, type RefUpdate } from "./branch-rules";
import { DATA_ROOT } from "./paths";
import { pushContext } from "./protection";
import { findRepoById } from "./repos";
/*
* Branch protection for git pushes. Every push runs Vectrum's pre-receive hook (lib/hook-script.ts),
* which tells the server over a local socket which refs the push changes and whether each change
* only adds commits (a fast-forward; the hook can check that, since it sees the pushed objects). The server
* answers with the rules' verdict, and the hook accepts or refuses the whole push.
*
* A push is announced with pushSession() before receive-pack starts; its token travels to the hook in
* the environment, so the hook can't be asked about anyone else's push. Used by the SSH server too.
*/
const ZERO_SHA = /^0+$/;
type Session = { repoId: string; actor: Actor | null };
const g = globalThis as unknown as { __pushSessions?: Map<string, Session>; __hookServer?: Promise<string> };
const sessions = (g.__pushSessions ??= new Map());
/** A local socket address that fits the length limit of Unix socket paths. */
function socketPath() {
if (process.platform === "win32") return `\\\\.\\pipe\\vectrum-hooks-${createHash("sha256").update(DATA_ROOT).digest("hex").slice(0, 16)}`;
const preferred = path.join(DATA_ROOT, "hooks", "rpc.sock");
if (Buffer.byteLength(preferred) < 100) return preferred;
return path.join(os.tmpdir(), `vectrum-hooks-${createHash("sha256").update(DATA_ROOT).digest("hex").slice(0, 16)}.sock`);
}
type HookRequest = { token: string; updates: { old: string; new: string; ref: string; ff: boolean }[] };
async function answer(request: HookRequest) {
const session = sessions.get(request.token);
if (!session) return { ok: false, messages: ["This push isn't known to Vectrum. Try again."] };
const repo = await findRepoById(session.repoId);
if (!repo) return { ok: false, messages: ["The repository was deleted."] };
const ctx = await pushContext(repo, session.actor);
const updates: RefUpdate[] = request.updates.map((u) => ({
ref: u.ref,
old: ZERO_SHA.test(u.old) ? null : u.old,
new: ZERO_SHA.test(u.new) ? null : u.new,
fastForward: u.ff,
}));
const messages = updates.map((u) => refusal(ctx, u)).filter((m) => m !== null);
return { ok: messages.length === 0, messages };
}
function startServer() {
const address = socketPath();
return new Promise<string>((resolve, reject) => {
if (process.platform !== "win32") {
mkdirSync(path.dirname(address), { recursive: true });
// A socket left from a previous run would stop us from listening.
rmSync(address, { force: true });
}
const server = net.createServer((conn) => {
let input = "";
conn.setEncoding("utf8");
conn.setTimeout(30_000, () => conn.destroy());
conn.on("data", (chunk) => {
input += chunk;
if (input.length > 1024 * 1024) return conn.destroy();
const newline = input.indexOf("\n");
if (newline < 0) return;
let request: HookRequest;
try {
request = JSON.parse(input.slice(0, newline));
} catch {
return conn.end(`${JSON.stringify({ ok: false, messages: ["Malformed request."] })}\n`);
}
answer(request)
.catch((e) => {
console.error("[hooks] could not check a push:", e);
return { ok: false, messages: ["Vectrum couldn't check the push against the branch rules. Try again."] };
})
.then((reply) => conn.end(`${JSON.stringify(reply)}\n`));
});
conn.on("error", () => {});
});
server.once("error", reject);
server.listen(address, () => resolve(address));
server.unref();
});
}
/** Starts the socket the hook talks to (once per process) and returns its address. */
function hookServer() {
return (g.__hookServer ??= startServer().catch((e) => {
g.__hookServer = undefined;
throw e;
}));
}
/**
* Announces a push by `actor` (null for job tokens, which can't push anyway). Returns the environment
* receive-pack needs, and a function to call when the push is over.
*/
export async function pushSession(repoId: string, actor: Actor | null) {
const address = await hookServer();
const token = randomBytes(24).toString("base64url");
sessions.set(token, { repoId, actor });
// A push that never finishes (a dropped connection) still lets go of its session.
const timer = setTimeout(() => sessions.delete(token), 6 * 3600e3);
timer.unref();
return {
env: { VECTRUM_HOOK_SOCKET: address, VECTRUM_PUSH_TOKEN: token },
end: () => {
clearTimeout(timer);
sessions.delete(token);
},
};
}