crates/cli/git-ents/src/hook.rs
hook.rshistorycomment 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 | |
| 87 | use std::io::{BufRead, Read, Write}; |
| 88 | use std::path::Path; |
| 89 | |
| 90 | use ents_effect::run::run_one; |
| 91 | use ents_model::{Effect, MemberState}; |
| 92 | use ents_query::Query; |
| 93 | use ents_receive::Mode; |
| 94 | use gix::refs::FullName; |
| 95 | use gix_hash::ObjectId; |
| 96 | use gix_object::{CommitRef, Find, Kind}; |
| 97 | use gix_ref_store::RefStoreRead; |
| 98 | use ssh_key::{PublicKey, SshSig}; |
| 99 | |
| 100 | use crate::error::{Error, Result}; |
| 101 | use crate::root::HostedRoot; |
| 102 | use 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)] |
| 107 | pub 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. |
| 121 | pub 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. |
| 161 | pub 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. |
| 201 | fn 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). |
| 243 | fn 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. |
| 262 | fn 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. |
| 296 | pub 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). |
| 364 | fn 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). |
| 372 | fn 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. |
| 406 | pub 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 | } |