Vectrum
Sign In
119 lines · 4.8 KB · TypeScript
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);
    },
  };
}