// The working repositories (#1587): one bare git repository per business // and project at `/working//.git`, which only // the bus host writes. A coder's push lands there as // `refs/heads/work/vikunja--`, and a reviewer fetches from // there. It isn't the project's root and it isn't a forge; no credential and // no remote goes into it, and no sandbox binds it. // // Commits move as git bundles. The session's runner makes the bundle in its // own clone, inside its sandbox, and sends the bytes over the host's git // socket (packages/cli/src/launcher.mjs). The host never runs git in a // session's clone and never opens a path in a session's workspace: a live // session can change its clone between any check and any git call, so no // check of the clone would hold. Host git reads only the bundle, as a fetch // from an untrusted remote, with fsck on. // // Every host git call here runs with no system or global config, hooks at // /dev/null, fsmonitor off, only local transports and no submodule // recursion (gitEnv and SAFE), in a repository the host made itself. // // push, under the repository's lock: // 1. the bundle file starts with the v2 header (v3 can carry a filter) // 2. main is fetched again from the project's root, which the host trusts // 3. `bundle verify`: a prerequisite the repository lacks refuses // missing-prerequisite // 4. the bundle has the named branch (branch-not-in-bundle) // 5. that one ref is fetched with transfer.fsckObjects into a temp ref; // a broken object refuses invalid-bundle // 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 // The temp ref is deleted on every path. The caller owns the bundle file and // deletes it. // // 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. import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { gitEnv, GIT_TIMEOUT_MS } from "./checkout.mjs"; // The largest bundle the host takes or sends. export const BUNDLE_MAX = 32 * 1024 * 1024; // The most commits a reviewer may say it has. export const HAVE_MAX = 32; 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}$/; export class WorkingError extends Error { constructor(code, message = code) { super(message); this.code = code; } } const refuse = (code, message) => { throw new WorkingError(code, message); }; // Ahead of every git subcommand. Command-line config wins over any file. const SAFE = [ "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.allow=never", "-c", "protocol.file.allow=always", "-c", "submodule.recurse=false", ]; // 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 = [] } = {}) { return new Promise((resolve) => { const child = spawn("git", [...SAFE, ...pre, ...args], { env: gitEnv(env), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "pipe"] }); let out = ""; let err = ""; 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}` })); }); } // The branch name a task's work goes to. A task ref holds a colon, which a // ref name can't. export function workRef(taskRef) { const m = TASK_REF.exec(taskRef ?? ""); if (!m) refuse("invalid-request", "not a task ref"); return `work/vikunja-${m[1]}-${m[2]}`; } // A branch name the session gives: it only picks a ref inside the bundle. export function validBranch(branch) { return ( typeof branch === "string" && /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,199}$/.test(branch) && !/\.\.|\/\/|\/\.|@\{|\.lock(\/|$)|[./]$/.test(branch) ); } export const workingRepo = (dataRoot, business, project) => join(dataRoot, "working", business, `${project}.git`); // One chain per repository: a push and a fetch never overlap in one repo. const locks = new Map(); function locked(repo, fn) { const run = (locks.get(repo) ?? Promise.resolve()).then(fn); const tail = run.catch(() => {}); locks.set(repo, tail); tail.then(() => { if (locks.get(repo) === tail) locks.delete(repo); }); return run; } // 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) { try { if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid", `${repo} isn't a directory`); return; } catch (e) { if (e instanceof WorkingError) throw e; if (e.code !== "ENOENT") refuse("working-repo-invalid", `${repo} can't be read: ${e.code}`); } 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); if (!r.ok) refuse("working-repo-invalid", `git init failed: ${r.why}`); renameSync(tmp, repo); } finally { rmSync(tmp, { recursive: true, force: true }); } } // 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); if (!r.ok) refuse("root-unreadable", `main couldn't be fetched from ${root}: ${r.why}`); } function header(file) { const fd = openSync(file, "r"); try { const b = Buffer.alloc(V2.length); return readSync(fd, b, 0, b.length, 0) === b.length && b.toString("latin1") === V2; } finally { closeSync(fd); } } const tip = async (repo, ref, env) => { const r = await git([`--git-dir=${repo}`, "rev-parse", "-q", "--verify", `${ref}^{commit}`], env); return r.ok ? r.out.trim() : null; }; // await push({ dataRoot, business, project, root, taskRef, branch, bundle, record, env, fsck }) // 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 }) { 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); return locked(repo, async () => { if (!header(bundle)) refuse("invalid-bundle", "not a v2 git bundle"); await ensure(repo, env); await seed(repo, root, env); 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); 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); 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 }); if (!fetched.ok) refuse("invalid-bundle", fetched.why); const commit = await tip(repo, incoming, env); 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); // 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); 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); throw e; } return { ref: workRef(taskRef), commit, previous }; } finally { await git([...at, "update-ref", "-d", incoming], env); } }); } // await review({ dataRoot, business, project, root, taskRef, commit, have, out, env }) // 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 }) { 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); return locked(repo, async () => { try { if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid"); } catch (e) { if (e instanceof WorkingError) throw e; refuse("unknown-commit", "the working repository doesn't exist"); } await seed(repo, root, env); const at = [`--git-dir=${repo}`]; if (!(await tip(repo, commit, env))) refuse("unknown-commit"); const known = []; for (const h of have) if (await tip(repo, h, env)) 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 (!made.ok) refuse(/empty bundle/.test(made.err) ? "nothing-to-fetch" : "fetch-failed", made.why); } finally { await git([...at, "update-ref", "-d", temp], env); } return { ref: workRef(taskRef), commit }; }); }