git-ents.gitmain
⌘K
foforge
hook.rs413 lines · 17.0 KB · rusthistorycomment on this file
1//! The single-node hosted root's git-hook plumbing.
2//!
3//! The development plan's phase-6 row doubles `git-ents` as `git.ents.cloud`:
4//! loose refs and a real odb on a Fly volume, served behind *git's own*
5//! `receive-pack` — "the same stock-git transport Phase 0 bootstraps, now
6//! invoking `receive()` from a hook" — with an in-memory `EventSink`, a
7//! boot-time reconciliation scan, and the Sprite executor.
8//!
9//! # Why the ref write itself is not `ents_receive::receive`'s
10//!
11//! `receive.unit`'s own doc says every mutation frontend, "the CLI, the
12//! local UI, a hosted smart-HTTP hook", must call `receive` in-process,
13//! with only the trait implementations differing. That is true once the
14//! store itself is swapped out from under git (`git-ents-server`, phase 8,
15//! `gix-receive` replacing `receive-pack` entirely because a Postgres
16//! `RefStore` leaves no on-disk repo for stock git to act on). Phase 6 is
17//! explicitly *not* that case: this deployment keeps a real on-disk repo
18//! and lets git's own `receive-pack` perform the actual object unpack and
19//! ref update — "stock git wearing the same gate everything else runs, not
20//! a bespoke protocol" (`docs/development-plan.adoc`).
21//!
22//! Concretely: if `pre_receive` here called `ents_receive::receive`
23//! (writing the ref through *our own* `LooseRefStore::transaction`) and
24//! then exited zero, git's `receive-pack` would still go on to perform its
25//! own internal ref update afterward, expecting the ref to still hold the
26//! *old* value it read before the hook ran — but we would have already
27//! moved it. That double-write is a real race, not a hypothetical one, so
28//! this module deliberately does not use `receive`'s bundled write path
29//! for this deployment shape. Instead:
30//!
31//! - [`pre_receive`] calls the *identical* [`ents_gate::verify`] every
32//! other call site uses (`gate.call-sites`) for each proposed
33//! transition, and lets git's native `pre-receive` whole-push-rejection
34//! semantics implement `gate.mandatory-hosted` for free: refusing any
35//! one transition (nonzero exit, reasons on stderr) aborts the entire
36//! push before git writes anything, exactly what `Mode::Mandatory`
37//! means. On a pass, this hook writes nothing itself — git's own
38//! `receive-pack` performs the actual ref update once the hook exits
39//! zero.
40//! - [`post_receive`] runs after git has already updated every ref: it
41//! opens a fresh [`crate::root::HostedRoot`] (whose `open` itself runs
42//! the boot-time [`ents_receive::reconcile`] scan,
43//! `receive.reconstructible`) and drains whatever is now outstanding,
44//! running each via the Sprite executor and writing results back
45//! through [`ents_effect::run::run_one`] — an ordinary `receive` client
46//! for the *results* ref, which never conflicts with a branch ref git
47//! itself just wrote.
48//!
49//! # Object visibility during `pre-receive` (quarantine)
50//!
51//! `receive.object-access`'s own doc flags "never a git hook's quarantine
52//! directory, until its transaction commits" as the composition root's
53//! responsibility. Git runs `pre-receive` with new objects visible only
54//! through `GIT_OBJECT_DIRECTORY` (the quarantine) plus
55//! `GIT_ALTERNATE_OBJECT_DIRECTORIES` (the real odb) until the push is
56//! accepted; [`crate::root::HostedRoot::open`] honors `GIT_OBJECT_DIRECTORY`
57//! when the environment sets it (which git does for `pre-receive`, and does
58//! not for `post-receive`, whose objects are by then no longer quarantined).
59//! `gix_odb::at` itself only ever follows a physical `info/alternates`
60//! *file*, and git's own quarantine directory never has one — so this
61//! crate's own [`crate::root::QuarantineObjects`] is what actually chains
62//! the two directories, entirely in-process (no alternates file is ever
63//! written to disk; see that type's own doc for why an earlier attempt at
64//! writing one was wrong).
65//!
66//! # No separate daemon
67//!
68//! There is deliberately no long-lived worker process in this phase: each
69//! hook invocation is a fresh, short-lived process that reconciles fresh
70//! from repository state (`receive.reconstructible`'s own guarantee) —
71//! "push-triggered" (the deployment table's own word for hosted execution)
72//! without any inter-process queue at all. The literal "in-memory
73//! `EventSink`" the development plan names lives for exactly one hook
74//! invocation's lifetime; nothing about `receive.reconstructible`'s
75//! contract requires it to live longer, and the phase-6 exit criterion —
76//! obligations regenerate correctly after a `kill -9` of the in-memory
77//! queue — is exactly what happens between every pair of pushes, verified
78//! directly in this crate's tests by dropping a `HostedRoot` and opening a
79//! fresh one against the same on-disk state.
80
81#![expect(
82 clippy::let_underscore_must_use,
83 reason = "rejection reasons written to a hook's stderr are best-effort; a broken pipe here \
84 is not actionable"
85)]
86
87use std::io::{BufRead, Read, Write};
88use std::path::Path;
89
90use ents_effect::run::run_one;
91use ents_model::{Effect, MemberState};
92use ents_query::Query;
93use ents_receive::Mode;
94use gix::refs::FullName;
95use gix_hash::ObjectId;
96use gix_object::{CommitRef, Find, Kind};
97use gix_ref_store::RefStoreRead;
98use ssh_key::{PublicKey, SshSig};
99
100use crate::error::{Error, Result};
101use crate::root::HostedRoot;
102use crate::sign::Signer;
103
104/// One proposed transition, as read from git's `pre-receive` stdin: one
105/// `<old-oid> <new-oid> <refname>` line per ref in the push.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct StdinTransition {
108 /// The refname being updated.
109 pub name: FullName,
110 /// The proposed new tip, or `None` for a deletion.
111 pub new: Option<ObjectId>,
112}
113
114/// Parse git's `pre-receive`/`post-receive` stdin format: one
115/// `<old> <new> <refname>` line per updated ref.
116///
117/// # Errors
118///
119/// [`Error::InvalidArgument`] for a line that does not have exactly three
120/// whitespace-separated fields, an unparsable oid, or an invalid refname.
121pub fn parse_stdin_transitions(input: impl BufRead) -> Result<Vec<StdinTransition>> {
122 let mut out = Vec::new();
123 for line in input.lines() {
124 let line = line.map_err(|source| Error::Io {
125 path: "<stdin>".into(),
126 source,
127 })?;
128 let mut fields = line.split_whitespace();
129 let (Some(_old), Some(new), Some(name)) = (fields.next(), fields.next(), fields.next())
130 else {
131 return Err(Error::InvalidArgument(format!(
132 "malformed pre-receive line: {line:?}"
133 )));
134 };
135 let new: ObjectId = new
136 .parse()
137 .map_err(|_source| Error::InvalidArgument(format!("bad new oid: {new}")))?;
138 let name: FullName = name
139 .to_owned()
140 .try_into()
141 .map_err(|_source| Error::InvalidArgument(format!("bad refname: {name}")))?;
142 let new = (!new.is_null()).then_some(new);
143 out.push(StdinTransition { name, new });
144 }
145 Ok(out)
146}
147
148/// Run as git's own `pre-receive` hook (see this module's own doc for the
149/// design). Reads transitions from `input`, requires a verified
150/// signed-push certificate once any member is enrolled
151/// ([`verify_push_certificate`]), evaluates the gate against each
152/// transition, and refuses the whole push (returns `Err`) if any check
153/// fails under the mandatory gate. Rejection reasons are written to
154/// `report`.
155///
156/// # Errors
157///
158/// [`Error::Refused`] if the push certificate is missing, stale, or
159/// unverifiable, or if any transition's verdict fails; propagates a parse
160/// or gate-evaluation failure otherwise.
161pub fn pre_receive(root: &HostedRoot, input: impl BufRead, mut report: impl Write) -> Result<()> {
162 let transitions = parse_stdin_transitions(input)?;
163 if let Err(error) = verify_push_certificate(root) {
164 let _ = writeln!(report, "refused: {error}");
165 return Err(error);
166 }
167 let mut failures = Vec::new();
168 for transition in &transitions {
169 let verdict = ents_gate::verify(
170 &root.refs,
171 &root.objects,
172 &ents_gate::Update {
173 name: transition.name.clone(),
174 new: transition.new,
175 },
176 )?;
177 if let ents_gate::Verdict::Fail(refusal) = verdict {
178 let _ = writeln!(report, "refused: {refusal}");
179 failures.push(refusal.to_string());
180 }
181 }
182 if failures.is_empty() {
183 Ok(())
184 } else {
185 Err(Error::Refused(failures.join("; ")))
186 }
187}
188
189/// Require the push git is about to apply to carry a valid signed-push
190/// certificate from an enrolled, active member, once any member is
191/// enrolled — the transport-authentication counterpart of
192/// `gate.bootstrap`'s own open window: before any member exists (a fresh
193/// hosted root), every push, including the one that enrolls the first
194/// member, is allowed unsigned, so bootstrapping is possible at all.
195///
196/// A push certificate carries no meta-ref semantics and is never
197/// consulted by `ents_gate::verify` (`gate.signature-artifact`); this is
198/// the one place in the hosted root that reads one, and only to answer
199/// "did an authorized member make this connection", not to decide
200/// anything the gate itself decides from repository state.
201fn verify_push_certificate(root: &HostedRoot) -> Result<()> {
202 let active: Vec<_> = crate::commands::members::list(&root.refs, &root.objects)?
203 .into_iter()
204 .map(|(_, member)| member)
205 .filter(|member| member.state == MemberState::Active)
206 .collect();
207 if active.is_empty() {
208 return Ok(());
209 }
210 let cert_oid = std::env::var("GIT_PUSH_CERT")
211 .ok()
212 .filter(|value| !value.is_empty())
213 .ok_or_else(|| {
214 Error::Refused(
215 "this repository requires a signed push: rerun with `git push --signed`"
216 .to_owned(),
217 )
218 })?;
219 if std::env::var("GIT_PUSH_CERT_NONCE_STATUS").ok().as_deref() != Some("OK") {
220 return Err(Error::Refused(
221 "push certificate nonce was missing or stale".to_owned(),
222 ));
223 }
224 let certificate = cat_blob(&root.path, &cert_oid)?;
225 if active
226 .iter()
227 .any(|member| certificate_verifies(&member.key, &certificate))
228 {
229 Ok(())
230 } else {
231 Err(Error::Refused(
232 "push is not signed by an authorized key".to_owned(),
233 ))
234 }
235}
236
237/// Whether `certificate` (git's raw push-cert text, as recorded in the
238/// blob `GIT_PUSH_CERT` names) carries a valid SSH signature over its own
239/// signed payload, verified against `key` (an OpenSSH public key line) —
240/// the transport-authentication counterpart of `ents_gate::signature`'s
241/// identical commit-signature check, including its "git" SSHSIG
242/// namespace (the same one git signs push certificates under).
243fn certificate_verifies(key: &str, certificate: &str) -> bool {
244 const MARKER: &str = "-----BEGIN SSH SIGNATURE-----";
245 const NAMESPACE: &str = "git";
246 let Some(split) = certificate.find(MARKER) else {
247 return false;
248 };
249 let (payload, signature) = certificate.split_at(split);
250 let Ok(key) = PublicKey::from_openssh(key) else {
251 return false;
252 };
253 let Ok(sig) = SshSig::from_pem(signature) else {
254 return false;
255 };
256 key.verify(NAMESPACE, payload.as_bytes(), &sig).is_ok()
257}
258
259/// Read blob `oid` from `repo_path` as text: `GIT_PUSH_CERT` names the
260/// object holding the raw certificate, not the certificate bytes
261/// directly.
262fn cat_blob(repo_path: &Path, oid: &str) -> Result<String> {
263 let output = std::process::Command::new("git")
264 .arg("-C")
265 .arg(repo_path)
266 .args(["cat-file", "blob", oid])
267 .output()
268 .map_err(|source| Error::Io {
269 path: repo_path.to_owned(),
270 source,
271 })?;
272 if !output.status.success() {
273 return Err(Error::Refused(format!(
274 "could not read push certificate blob {oid}"
275 )));
276 }
277 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
278}
279
280/// Run as git's own `post-receive` hook: reconcile outstanding effect
281/// obligations and run every one of them via `executor`, writing each
282/// result back through the ordinary `receive` path
283/// (`effect.results-writeback`).
284///
285/// `root` must already have run its boot-time reconciliation scan (true of
286/// any [`HostedRoot::open`]); this function additionally re-reconciles once
287/// more before draining, so a push that itself just made new commits
288/// outstanding is caught without waiting for the *next* process's boot.
289///
290/// # Errors
291///
292/// Propagates a reconciliation, toolchain-resolution, checkout, executor,
293/// or write-back failure. A per-commit failure stops the drain at that
294/// commit (mirrors [`ents_effect::run::run_effect`]'s own contract) —
295/// results already written for earlier commits in this pass stay durable.
296pub fn post_receive(
297 root: &HostedRoot,
298 executor: &dyn ents_effect::Executor,
299 scratch: &std::path::Path,
300 toolchain_cache: &std::path::Path,
301 signer: &Signer,
302) -> Result<usize> {
303 ents_receive::reconcile(&root.refs, &root.objects, &root.events)?;
304
305 let author = gix::actor::Signature {
306 name: crate::root::HOSTED_WORKER_NAME.into(),
307 email: "worker@git.ents.cloud".into(),
308 time: gix::date::Time {
309 seconds: std::time::SystemTime::now()
310 .duration_since(std::time::UNIX_EPOCH)
311 .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
312 .unwrap_or_default(),
313 offset: 0,
314 },
315 };
316
317 let mut ran = 0usize;
318 for (effect_name, oid) in root.events.pending() {
319 let result_ref = ents_model::namespace::result_ref(&effect_name, &run_one_short(oid))?;
320 // Skip work already resulted: the sink may re-list an obligation
321 // whose result already landed in an earlier pass within the same
322 // process (`receive.dedup`'s spirit — idempotent re-delivery,
323 // never a duplicate effect run).
324 if root.refs.get(result_ref.as_ref())?.is_some() {
325 continue;
326 }
327 let Some(effect) = read_effect(&root.refs, &root.objects, &effect_name)? else {
328 continue;
329 };
330 // `run_one` no longer resolves toolchain names itself: resolve and
331 // materialize this effect's declared toolchains here, before
332 // handing the run loop an already-materialized slice.
333 let mut toolchains = Vec::with_capacity(effect.toolchains.len());
334 for toolchain_name in &effect.toolchains {
335 let (_, recipe) =
336 ents_kiln::toolchain::resolve(&root.refs, &root.objects, toolchain_name)?;
337 let bin = ents_kiln::toolchain::materialize(&recipe, &root.objects, toolchain_cache)?;
338 toolchains.push((toolchain_name.clone(), bin));
339 }
340
341 run_one(
342 &root.refs,
343 &root.objects,
344 &root.events,
345 executor,
346 scratch,
347 &toolchains,
348 oid,
349 &effect,
350 result_ref,
351 &author,
352 |payload| signer.sign(payload),
353 Mode::Mandatory,
354 )?;
355 ran = ran.saturating_add(1);
356 }
357 Ok(ran)
358}
359
360/// The short-oid segment every results refname uses; mirrors
361/// `ents_effect::run::short_oid` (private to that crate's own module path
362/// from here, so this is a thin duplicate of a two-line slice operation
363/// rather than a reason to change that crate's visibility).
364fn run_one_short(oid: ObjectId) -> String {
365 let hex = oid.to_string();
366 hex.get(..12).unwrap_or(&hex).to_owned()
367}
368
369/// Read and parse the effect definition at `refs/meta/effects/<name>`, or
370/// `None` if it is missing or malformed (mirrors `ents_receive::reconcile`'s
371/// own tolerance for a pre-existing malformed effect).
372fn read_effect(refs: &dyn RefStoreRead, objects: &impl Find, name: &str) -> Result<Option<Effect>> {
373 let effect_ref = ents_model::namespace::effect_ref(name)?;
374 let Some(tip) = refs.get(effect_ref.as_ref())? else {
375 return Ok(None);
376 };
377 let mut buf = Vec::new();
378 let Some(data) = objects
379 .try_find(&tip, &mut buf)
380 .map_err(|source| Error::InvalidArgument(source.to_string()))?
381 else {
382 return Ok(None);
383 };
384 if data.kind != Kind::Commit {
385 return Ok(None);
386 }
387 let Ok(commit) = CommitRef::from_bytes(data.data, tip.kind()) else {
388 return Ok(None);
389 };
390 let tree = commit.tree();
391 let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, objects) else {
392 return Ok(None);
393 };
394 // Confirm the trigger still parses, mirroring `reconcile`'s own
395 // tolerance rule; an effect whose trigger is unparsable is treated as
396 // "nothing to run" — `None`, not a hard failure that would abort the
397 // whole drain over one pre-existing malformed effect.
398 if effect.trigger.parse::<Query>().is_err() {
399 return Ok(None);
400 }
401 Ok(Some(effect))
402}
403
404/// A byte source the hook subcommands read stdin from — split out only so
405/// tests can supply a fixed buffer instead of a real stdin handle.
406pub fn read_all(mut input: impl Read) -> Result<Vec<u8>> {
407 let mut buf = Vec::new();
408 input.read_to_end(&mut buf).map_err(|source| Error::Io {
409 path: "<stdin>".into(),
410 source,
411 })?;
412 Ok(buf)
413}