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; __hookServer?: Promise }; 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((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); }, }; }