Skip to main content

gunnar_server/
transport.rs

1//! **The join.** `gunnar-ssh`'s [`GitTransport`] seam, implemented over
2//! `gunnar-wire`'s state machines, with `gunnar-wire`'s [`PushGate`] seam
3//! implemented over `gunnar-policy`'s engine.
4//!
5//! Until this file existed, those three crates had each been proven alone:
6//! `gunnar-wire` against stock `git` over pipes, `gunnar-ssh` against a
7//! `BannerTransport`, `gunnar-policy` against crafted push fixtures. Nothing
8//! had joined them, so nothing had proved a real `git clone` over a real SSH
9//! connection. `tests/ssh_end_to_end.rs` does.
10//!
11//! # Async on one side, blocking on the other
12//!
13//! russh hands over a `tokio` duplex stream; `gunnar-wire` wants
14//! `&mut dyn BufRead` and `&mut dyn Write`, deliberately, so it can be driven
15//! by a pipe or a subprocess in tests (the git wire protocol). The stream is therefore
16//! [`tokio::io::split`] into halves — one object cannot be borrowed mutably as
17//! both reader and writer — each wrapped in a [`SyncIoBridge`], and the whole
18//! transfer runs inside [`tokio::task::spawn_blocking`]. `SyncIoBridge` blocks
19//! on the runtime, so running it on a runtime worker would deadlock the reactor
20//! that has to feed it.
21//!
22//! # The protocol version is the client's to choose
23//!
24//! A v2 client announces itself by sending `GIT_PROTOCOL=version=2` as an SSH
25//! **environment request**. `gunnar-ssh` accepts that one variable and no
26//! other (`gunnar_ssh::server::ENV_ALLOWLIST`), and carries the raw value
27//! on [`ServiceRequest::git_protocol`]. [`protocol_version`] turns it
28//! into a [`ProtocolVersion`] with `gunnar-wire`'s own parser, so there is one
29//! implementation of the grammar rather than two.
30//!
31//! A client that sends nothing gets **v0**, which is what `ProtocolVersion`'s
32//! default already means and what `gunnar-wire`'s v0 fallback exists for. That
33//! path is not vestigial: it is what an old client, or one behind a transport
34//! that drops the variable, actually gets, and `tests/ssh_end_to_end.rs`
35//! exercises it explicitly rather than assuming it.
36//!
37//! **What this unblocked, measured 2026-08-03.** While every session was v0,
38//! `gunnar clone` could not clone from `gunnar serve` at all:
39//!
40//! ```text
41//! gunnar clone ssh://git@127.0.0.1:2299/team/project.git
42//!   → Could not decode server reply: Currently we require feature
43//!     "multi_ack_detailed", which is not supported by the server
44//! ```
45//!
46//! `gunnar-client` fetches through `nornir-git` → `gix`'s blocking transport,
47//! which demands `multi_ack_detailed` in v0. `gunnar_wire::caps` used not to
48//! advertise it, on the grounds that gunnar negotiated in single-ACK mode and
49//! advertising more than is implemented is a bug. **It advertises and
50//! implements it now** (smart HTTP): single-ACK cannot complete a stateless-rpc
51//! negotiation, so smart HTTP forced the mode to be built properly rather than
52//! declined. Delivering `GIT_PROTOCOL` still matters — v2 is the better
53//! protocol and `gix` asks for it by itself — but the v0 fallback is no longer
54//! a capability gunnar refuses `gix` on paper. That fallback is not exercised
55//! here, so treat this paragraph as a statement about the advertisement, which
56//! `gunnar-wire/tests/advertisement.rs` asserts, and not as a measurement of
57//! `gix`'s v0 client.
58//!
59//! `receive-pack` is **not** given the negotiated version, and that is not an
60//! oversight: protocol v2 has no push side. `git` sends `GIT_PROTOCOL` on a
61//! push too and then speaks v0 anyway, so passing it on would be inventing a
62//! protocol neither end implements.
63//!
64//! # This module is the event-stream producer, and the attestation one
65//!
66//! `Events.Watch` had a bus, a filter, a gap marker and its own tests. For
67//! `PushAttestation` it had no producer at all. A stream with nothing behind it
68//! connects, delivers nothing and is indistinguishable from a quiet server.
69//! [`serve_receive_pack`] is where a push becomes events:
70//!
71//! ```text
72//!   refs applied ── status reported ──┬── RefUpdated / RefDeleted / PushRejected
73//!                                     └── PushAttestation ──┬── the event bus
74//!                                                           └── attestations.jsonl
75//! ```
76//!
77//! Everything is published **after** the report is on the wire. A push must
78//! never fail because nobody was watching, and it must never be *reported* as
79//! having done something the client has not yet been told about.
80//!
81//! ## Why there is no publish callback in `gunnar-wire`
82//!
83//! There does not need to be one. `ReceivePack::serve` already returns a
84//! [`PushOutcome`] carrying every command, its status and the indexed pack,
85//! which is the input an event needs. A `Publisher` trait threaded
86//! through `gunnar-wire` would be a second path to the same facts, live at a
87//! point where the report has *not* yet been written, and a second thing to
88//! keep in step (one implementation). The coupling `gunnar-wire` must not grow is a
89//! dependency on `gunnar-grpc`, and returning a value cannot create one.
90//!
91//! ## The dependency edge push attestation forbids
92//!
93//! gunnar does not link the Build Thing, nornir, skade, arrow or parquet for
94//! any of this. The event is a proto message and the log is a file of JSON
95//! lines; anything wanting a warehouse row subscribes and writes it itself.
96
97use std::collections::HashMap;
98use std::future::Future;
99use std::io::{BufRead, BufReader, BufWriter, Write as _};
100use std::path::{Path, PathBuf};
101use std::pin::Pin;
102use std::sync::atomic::{AtomicU64, Ordering};
103use std::sync::{Arc, Mutex};
104
105use gix_hash::ObjectId;
106use gunnar_core::observe::{ServerMetrics, TARGET_GIT, TARGET_POLICY, TARGET_STORE};
107use gunnar_grpc::EventBus;
108use gunnar_policy::{
109    KeyDirectory, PolicyConfig, PolicyEngine, PushContext, RefUpdate, RepoId, StaticKeyDirectory,
110};
111use gunnar_proto::{event, PushAttestation, PushRejected, RefDeleted, RefUpdated, RepoPath};
112use gunnar_ssh::seam::{Delivery, DuplexStream, GitTransport, ServiceRequest, TransportError};
113use gunnar_ssh::GitService;
114use gunnar_store::git_storage_trait::GitServe;
115use gunnar_wire::{
116    PackReceipt, ProtocolVersion, PushFacts, PushGate, PushOutcome, ReceivePack, RefCommand,
117    RefStatus, Session, UploadPack,
118};
119use tokio_util::io::SyncIoBridge;
120
121use crate::attest::AttestationLog;
122use crate::storage::OpenRepo;
123use crate::stores::ServerStores;
124
125/// The per-repository policy document, read from the repository's git dir.
126///
127/// `gunnar-core` has no storage for branch protection or ref-name policy (see
128/// the report in the crate docs), so the gate configuration comes from a file
129/// beside the repository until it does. When the catalog grows a policy
130/// document, this constant is the only thing that changes.
131pub const POLICY_FILE: &str = "gunnar-policy.json";
132
133/// Which protocol version to answer a request in.
134///
135/// The client chooses, by sending `GIT_PROTOCOL` as an SSH environment request;
136/// `gunnar-ssh` allowlists that one name and carries the raw value here. The
137/// grammar is parsed **once**, by `gunnar-wire`, so a v1 announcement cannot
138/// mean one thing to the SSH layer and another to the wire (one implementation).
139///
140/// A request that carries nothing is v0, and not by a default that happens to
141/// land on v0: that is what `from_git_protocol(None)` says.
142fn protocol_version(request: &ServiceRequest) -> ProtocolVersion {
143    ProtocolVersion::from_git_protocol(request.git_protocol.as_deref())
144}
145
146/// **The push's standing in its namespace's byte ledger, as the receive loop
147/// asks about it.**
148///
149/// Four lines, and they are the whole of the third seam this file holds. The
150/// other two are [`protocol_version`] and [`wire_session`], and this one exists
151/// for the same reason they do: `gunnar-ssh` and `gunnar-wire` deliberately do
152/// not depend on each other, and this crate is the only one that holds both.
153///
154/// The bug it closes is a time-of-check/time-of-use on a quota. Every push was
155/// handed `PlanGate::pack_ceiling`'s number — the namespace's remaining room
156/// plus slack — and held to the whole of it, so N pushes that arrived together
157/// were each admitted against the same measurement and the room was spent N
158/// times. `gunnar_lfs::Mark` makes the number a budget the namespace shares:
159/// the receive loop charges every chunk before it takes it, against everything
160/// admitted since this push measured.
161///
162/// # What happened to the slack
163///
164/// Nothing, and that is deliberate. [`crate::entitlement`] still computes
165/// `remaining + max(cap / 16, 64 MiB)` and this file still passes that number
166/// through untouched; the ledger adds no arithmetic of its own and never
167/// re-adds the slack per connection. What changed is only WHO spends it: the
168/// 6.25 % (floor 64 MiB) that covers the difference between a thin pack's wire
169/// bytes and its stored bytes is now one allowance for the namespace, charged
170/// once, instead of one allowance per simultaneous push. That is the direction
171/// the slack was argued for in the first place — it exists so the push that is
172/// landing is not cut by arithmetic nobody can do in advance, not so that eight
173/// pushes may each have one.
174///
175/// # Why an adapter rather than the trait on `Mark` itself
176///
177/// Because `gunnar-lfs` would then have to name `gunnar_wire::PackLedger`, and
178/// `gunnar-lfs` is a leaf (44 packages) that `gunnar-lfs-serve` ships on its
179/// own; `gunnar-wire` cannot name `gunnar_lfs::Mark` either, being bound by the
180/// no-OS rule and the dependency budget to plumbing. An `Arc<dyn PackLedger>`
181/// built here costs one allocation per push, adds no edge to either crate's
182/// graph, and puts the conversion where every other conversion between those two
183/// already is.
184#[derive(Debug)]
185pub(crate) struct PackMark {
186    pub(crate) mark: gunnar_lfs::Mark,
187}
188
189impl gunnar_wire::PackLedger for PackMark {
190    /// Pure forwarding, on purpose: `room` is the receive loop's number (the
191    /// ceiling plus the one byte that makes it reachable) and the arithmetic is
192    /// the ledger's. Anything computed here would be a third place where a byte
193    /// ceiling is decided.
194    fn admit_upto(&self, want: u64, room: u64) -> u64 {
195        self.mark.admit_upto(want, room)
196    }
197}
198
199/// How much of a conversation this call carries, as `gunnar-wire` names it.
200///
201/// [`Delivery`] is the seam's word and [`Session`] is the wire's, and they are
202/// two enums rather than one because `gunnar-ssh` deliberately does not depend
203/// on `gunnar-wire` (see `ServiceRequest::git_protocol`). This is the whole of
204/// the translation, in one place, so the mapping cannot be made differently in
205/// two.
206///
207/// [`Delivery::Advertisement`] has no `Session` at all: it is not a session,
208/// it is a `GET` that writes the advertisement and reads nothing, and it is
209/// handled before this is consulted.
210fn wire_session(request: &ServiceRequest) -> Session {
211    match request.delivery {
212        Delivery::Session | Delivery::Advertisement => Session::Connected,
213        Delivery::StatelessRpc => Session::StatelessRpc,
214    }
215}
216
217/// **What `gunnar-wire` is configured with, for both directions.**
218///
219/// `gunnar-wire` has had these knobs since it was written, and a
220/// `with_config` on both state machines to receive them. Nothing ever called
221/// either: this module said `UploadPack::new(…)` and `ReceivePack::new(…)`, so
222/// every deployment ran on `Config::default()` and **an operator could not
223/// decline `filter`** — the one capability the type's own doc comment says is
224/// "the one capability an operator plausibly wants to decline".
225///
226/// A configuration surface that cannot affect behaviour is worse than none.
227/// It reads as a control that works: an appliance that had to state it does
228/// not serve partial clones had `Offered { filter: false }` sitting right
229/// there, fully implemented in both protocol versions, and no way at all to
230/// reach it. So the knobs are wired rather than deleted, and this struct is
231/// the one place the two `gunnar_wire::…::Config` types are held (one implementation) — a
232/// second copy of `agent` or `budget` on `ServeConfig` is how a value ends up
233/// honoured on one direction and stubbed on the other.
234#[derive(Debug, Clone)]
235pub struct WireConfig {
236    /// What `git-upload-pack` serves under: `offered`, `want_policy`, `budget`
237    /// and `limits`.
238    pub upload: gunnar_wire::upload_pack::Config,
239    /// What `git-receive-pack` serves under: the connectivity budget.
240    pub receive: gunnar_wire::receive_pack::Config,
241    /// What `git-lfs-transfer` serves under.
242    ///
243    /// Held here and not beside the store for the same reason the two above
244    /// are: the LFS session is a third service on this one listener, and a
245    /// second home for its quota is how 8 GiB comes to be enforced by the SSH
246    /// path and not by the next caller. `gunnar_lfs::Session::with_limits` and
247    /// the three builders behind it had **zero** call sites outside
248    /// `tests/quota.rs` — every deployment ran on `Limits::default()` and the
249    /// numbers in the doc comments were not reachable from any command line.
250    pub lfs: gunnar_lfs::Limits,
251    /// Enumerate the objects each push introduced, so the push attestation can
252    /// name them. **On by default.**
253    ///
254    /// The ids are not a by-product of storing a pack — a pack entry does not
255    /// carry its oid — so answering this waits for the engine's indexer to
256    /// drain, which is real latency on the push path
257    /// (`storage::znippy_engine::ingest` has the measured multiples). Before
258    /// this flag existed the enumeration happened only when a policy gate
259    /// needed it, and an attestation on a repository with no gates therefore
260    /// carried an empty object list that was indistinguishable from a push
261    /// that introduced nothing.
262    ///
263    /// Turning it **off** does not restore that silence: the attestation then
264    /// records the reason the set is absent (`new_object_oids_unknown`), because an
265    /// absence that renders as a value is the defect this flag was added to
266    /// remove, not the behaviour it restores.
267    pub attest_objects: bool,
268}
269
270impl Default for WireConfig {
271    /// Hand-written for one field. `#[derive(Default)]` would make
272    /// [`WireConfig::attest_objects`] `false`, and the safe default here is the
273    /// one that produces a complete attestation — the cheap default is the one
274    /// an operator opts into, never the one they get by forgetting.
275    fn default() -> Self {
276        WireConfig {
277            upload: Default::default(),
278            receive: Default::default(),
279            lfs: Default::default(),
280            attest_objects: true,
281        }
282    }
283}
284
285impl WireConfig {
286    /// The knobs an operator set on `gunnar serve`, translated once.
287    ///
288    /// `None` on every flag means "the operator did not say", which takes
289    /// `gunnar-wire`'s default — the same convention `--max-concurrent-transfers`
290    /// already uses. `0` is a *stated* value and is treated as one:
291    ///
292    /// * on the two byte ceilings it is the documented way to remove them,
293    ///   because `Limits`' fields are `Option` and `None` means unbounded;
294    /// * on `--fetch-budget` it is **refused**, because the walk stops when it
295    ///   reaches the budget, so `0` would mean a server that answers no fetch
296    ///   at all while reading as "no limit". An unbounded-looking flag that
297    ///   silently bricks the server is the failure this whole item is about.
298    pub fn from_serve_args(args: &gunnar_common::cli::ServeArgs) -> anyhow::Result<Self> {
299        let mut wire = WireConfig::default();
300        if args.no_attest_objects {
301            wire.attest_objects = false;
302        }
303        if args.no_filter {
304            wire.upload.offered.filter = false;
305        }
306        if let Some(policy) = args.want_policy {
307            wire.upload.want_policy = policy.to_wire();
308        }
309        if let Some(budget) = args.fetch_budget {
310            anyhow::ensure!(
311                budget > 0,
312                "--fetch-budget 0 would refuse every fetch rather than allow every fetch: the \
313                 walk stops when it REACHES the budget. Leave the flag off for the default of \
314                 {}, or give the number of objects a fetch may visit.",
315                gunnar_wire::DEFAULT_BUDGET
316            );
317            wire.upload.budget = Some(budget);
318        }
319        if let Some(bytes) = args.max_fetch_request_bytes {
320            wire.upload.limits.request_bytes = (bytes > 0).then_some(bytes);
321        }
322        if let Some(bytes) = args.max_fetch_object_bytes {
323            wire.upload.limits.object_bytes = (bytes > 0).then_some(bytes);
324        }
325        if let Some(bytes) = args.max_fetch_walk_bytes {
326            wire.upload.limits.walk_bytes = (bytes > 0).then_some(bytes);
327        }
328        // ── the ceiling that bounds the BOX, not the request ─────────────────
329        //
330        // Built here rather than defaulted in `gunnar-wire`, because a pool is
331        // only a pool if every connection gets the same `Arc`. A default inside
332        // `Config` would give each session a private purse of its own, which
333        // reads exactly like a server-wide ceiling and is not one.
334        //
335        // `for_this_machine` reads `MemTotal`. The alternative — a constant —
336        // would be a memory ceiling chosen without knowing how much memory
337        // there is, which is the mistake `DEFAULT_BUDGET` made in objects.
338        wire.upload.walk_pool = match args.max_walk_bytes_total {
339            Some(0) => None,
340            Some(bytes) => Some(gunnar_wire::WalkPool::new(bytes)),
341            None => Some(gunnar_wire::WalkPool::for_this_machine()),
342        };
343        // The LFS three are `u64`/`usize` and not `Option`, so `0` is a stated
344        // "refuse everything" rather than the "unbounded" the two flags above
345        // mean by it. Same call as `--fetch-budget`: refuse it at startup,
346        // because a flag that reads as "no limit" and bricks the service is the
347        // failure this whole item is about.
348        if let Some(bytes) = args.max_lfs_object_bytes {
349            anyhow::ensure!(
350                bytes > 0,
351                "--max-lfs-object-bytes 0 would refuse every upload, not allow every upload. \
352                 Leave the flag off for the default of {} bytes.",
353                gunnar_lfs::DEFAULT_MAX_OBJECT_BYTES
354            );
355            wire.lfs = wire.lfs.max_object_bytes(bytes);
356        }
357        if let Some(objects) = args.max_lfs_batch_objects {
358            anyhow::ensure!(
359                objects > 0,
360                "--max-lfs-batch-objects 0 would refuse every batch. Leave the flag off for the \
361                 default of {}.",
362                gunnar_lfs::DEFAULT_MAX_BATCH_OBJECTS
363            );
364            wire.lfs = wire.lfs.max_batch_objects(objects);
365        }
366        if let Some(bytes) = args.max_lfs_drain_bytes {
367            anyhow::ensure!(
368                bytes > 0,
369                "--max-lfs-drain-bytes 0 would drop the channel on the first refusal instead of \
370                 draining to the client's flush-pkt, so the refusal itself would not be \
371                 delivered. Leave the flag off for the default."
372            );
373            wire.lfs = wire.lfs.max_drain_bytes(bytes);
374        }
375        Ok(wire)
376    }
377
378    /// **The one place an LFS session is built.**
379    ///
380    /// By construction rather than by a guard that watches two call sites
381    /// agree (one implementation). The defect this closes was exactly a second construction
382    /// path: `Session::new(…).serve(…)` in the SSH transport, with
383    /// `with_limits` reachable only from `tests/quota.rs`. A test that built
384    /// its own session could be green with the server on `Limits::default()`
385    /// forever, so the test drives *this* and there is nothing else to drive.
386    pub fn lfs_session<'a>(
387        &self,
388        store: &'a dyn gunnar_lfs::LfsStore,
389        operation: gunnar_lfs::Operation,
390    ) -> gunnar_lfs::Session<'a> {
391        gunnar_lfs::Session::new(store, operation).with_limits(self.lfs)
392    }
393}
394
395/// `gunnar-wire`, wired to the SSH listener.
396pub struct WireTransport {
397    stores: Arc<ServerStores>,
398    bus: Option<Arc<EventBus>>,
399    attestations: AttestationLog,
400    metrics: Arc<ServerMetrics>,
401    wire: Arc<WireConfig>,
402}
403
404impl WireTransport {
405    /// Serve repositories out of `stores`.
406    pub fn new(stores: Arc<ServerStores>) -> Self {
407        let attestations = AttestationLog::under(stores.root());
408        WireTransport {
409            stores,
410            bus: None,
411            attestations,
412            metrics: Arc::new(ServerMetrics::new()),
413            wire: Arc::new(WireConfig::default()),
414        }
415    }
416
417    /// Serve under `wire` rather than `gunnar-wire`'s defaults.
418    ///
419    /// The whole of what makes [`WireConfig`] reachable. Without a call to
420    /// this, every knob on it is a comment.
421    pub fn with_wire_config(mut self, wire: WireConfig) -> Self {
422        self.wire = Arc::new(wire);
423        self
424    }
425
426    /// Count into the **server's** counters rather than into a private set.
427    ///
428    /// `gunnar serve` mints one [`ServerMetrics`] and hands the same `Arc` here
429    /// and to the SSH listener, so `connections_refused` and `pushes_rejected`
430    /// are two readings of one server. A transport built without this still
431    /// counts, into a set only it can see, which is what a unit test wants.
432    pub fn with_metrics(mut self, metrics: Arc<ServerMetrics>) -> Self {
433        self.metrics = metrics;
434        self
435    }
436
437    /// Publish `RefUpdated` / `RefDeleted` / `PushRejected` / `PushAttestation`
438    /// to the event bus.
439    ///
440    /// **The bus is optional; the attestation log is not.** A server started
441    /// with `--no-grpc` has nobody to notify, but the attestation's durable record is not a
442    /// notification. It is the thing the event stream explicitly does not
443    /// promise to keep (see `EventBus::publish`), so the log is written either
444    /// way and only the publish is conditional.
445    pub fn with_bus(mut self, bus: Arc<EventBus>) -> Self {
446        self.bus = Some(bus);
447        self
448    }
449
450    /// Where the attestation's JSON lines land.
451    pub fn attestation_log(&self) -> &AttestationLog {
452        &self.attestations
453    }
454}
455
456/// **The LFS object store, opened the one way** — for the SSH session and for
457/// the HTTP batch API alike (one implementation).
458///
459/// The two surfaces used to be one call apart from having two answers to *where
460/// does this repository keep its LFS objects*: `serve_blocking` reached for
461/// `stores.repo_store(...).git_dir()` and anything else would have had to
462/// re-derive it. It is `<git_dir>/lfs/`, the symlink check in
463/// `ServerStores::repo_dir` is not optional on either path, and the handle the
464/// store lock lives on is the same handle, so a repack and an LFS transfer
465/// exclude each other the same way on both transports.
466impl gunnar_ssh::seam::LfsTransport for WireTransport {
467    fn open_lfs(
468        &self,
469        request: &ServiceRequest,
470    ) -> Result<gunnar_ssh::seam::LfsAccess, TransportError> {
471        // An upload into a repository that does not exist yet (the
472        // first-repository door, 2026-09-28): the repository is made by its
473        // first object, see `ProspectiveLfs`.
474        if request.prospective {
475            return Ok(gunnar_ssh::seam::LfsAccess {
476                store: Arc::new(ProspectiveLfs::new(
477                    Arc::clone(&self.stores),
478                    request.clone(),
479                )),
480                limits: self.wire.lfs,
481                allowance: request.lfs_allowance.clone(),
482            });
483        }
484        let store = open_repo_store(&self.stores, request)?;
485        let lfs = open_lfs_store(&self.stores, &store, request)?;
486        Ok(gunnar_ssh::seam::LfsAccess {
487            store: Arc::new(lfs),
488            // **`self.wire.lfs`, not `Limits::default()`.** The one place an
489            // operator's `--max-lfs-object-bytes` is held. A separate default
490            // here is exactly how a quota comes to be enforced on SSH and not
491            // on HTTP; see `WireConfig::lfs`.
492            limits: self.wire.lfs,
493            // **The tenant's plan**, decided by the gate chain in
494            // `authorise_service` and carried on the request beside the pack
495            // ceiling. Cloned rather than moved: `open_lfs` takes the request by
496            // reference, and one `Allowance` is two small fields.
497            allowance: request.lfs_allowance.clone(),
498        })
499    }
500}
501
502/// Open the repository behind an **already authorised** request, or say why not
503/// in the sentence the client is owed.
504///
505/// Shared by `serve_blocking` and [`LfsTransport::open_lfs`]. The distinction
506/// between the two failures is the load-bearing part and is why this is one
507/// function: a held store lock is a running `gunnar gc` (or a second gunnar)
508/// and clears on its own, so the answer is "retry"; every other open failure is
509/// a fault an operator has to look at. Answering both with one sentence is how
510/// a two-minute wait becomes a support ticket.
511///
512/// `TransportError::Refused` and not `Protocol`: both call sites reach this
513/// before a single byte has been written, which is what that variant *means*.
514fn open_repo_store(
515    stores: &ServerStores,
516    request: &ServiceRequest,
517) -> Result<Arc<OpenRepo>, TransportError> {
518    stores.repo_store(&request.repo).map_err(|err| {
519        // Opening the repository failed *after* the grant check said yes, so
520        // this is a storage fault and not a decision. It used to be returned
521        // silently: the client saw a dead channel and the server said nothing
522        // at all.
523        tracing::error!(
524            target: TARGET_GIT,
525            error = format!("{err:#}"),
526            "the repository could not be opened for a transfer that was already authorised"
527        );
528        TransportError::Refused(match locked_path(&err) {
529            Some(_) => format!(
530                "the repository {} is locked by maintenance on the server (a repack or a \
531                 second gunnar holds its store lock); the transfer was not started, so retry",
532                request.repo.key()
533            ),
534            None => format!(
535                "the repository {} could not be opened on the server; the transfer was not \
536                 started. The reason is in the server log",
537                request.repo.key()
538            ),
539        })
540    })
541}
542
543/// The LFS objects under a repository's own git dir. See
544/// [`gunnar_lfs::fs_store`].
545fn open_lfs_store(
546    stores: &ServerStores,
547    store: &OpenRepo,
548    request: &ServiceRequest,
549) -> Result<crate::txlog_lfs::LoggedLfs<gunnar_lfs::FsLfsStore>, TransportError> {
550    let repo = format!("{}/{}", request.repo.namespace, request.repo.name);
551    gunnar_lfs::FsLfsStore::open_or_init(store.git_dir())
552        .map(|fs| {
553            crate::txlog_lfs::LoggedLfs::new(fs, Arc::clone(stores.txlog()), repo)
554                // Held to the data set's room, whole object at a time (cov-13).
555                .with_space(stores.space_ledger())
556        })
557        .map_err(|err| {
558            tracing::error!(
559                target: TARGET_GIT,
560                error = format!("{err}"),
561                "the lfs object store could not be opened for an authorised transfer"
562            );
563            // The same `Refused`, for the same reason, at the twin of the seam
564            // above: this is still before the first byte, and the LFS grammar has
565            // no per-object error line to carry it later.
566            TransportError::Refused(format!(
567                "the lfs object store for {} could not be opened on the server; the \
568             transfer was not started. The reason is in the server log",
569                request.repo.key()
570            ))
571        })
572}
573
574/// **An LFS upload into a repository that does not exist yet** (2026-09-28):
575/// the first-repository door for git-lfs's pre-push hook, which runs after
576/// git took receive-pack's advertisement and BEFORE it sends a command.
577/// MEASURED 2026-09-28: the first `git push` with an LFS object into a new
578/// repository died in that hook — `batch request: exec request failed on
579/// channel 0: exit status 255` — and neither the repository nor the ref was
580/// created, while a precreated repository worked.
581///
582/// `Dispatch::authorise_service` admitted the upload under the SAME
583/// push-to-create rules as the push (`Control::push_create_spec`: namespace
584/// Admin, and server Admin for a namespace that does not exist; everyone
585/// else the fused not-found). Nothing is written until an OBJECT arrives:
586/// until then every object is absent (so the batch asks for all of them),
587/// and a client that only batches, or hangs up, leaves nothing. The first
588/// `begin_write` makes the repository through [`create_prospect`] — the
589/// same writes a push-create makes, the grant asked again — or takes the one
590/// a concurrent upload or push made first. After that it is the ordinary
591/// store.
592///
593/// The repository is the push-create prospect, sha1, for the reason
594/// `push_create_spec` gives; LFS objects are sha256 by their own spec
595/// whatever the repository's hash is.
596pub(crate) struct ProspectiveLfs<S> {
597    stores: S,
598    request: ServiceRequest,
599    made: std::sync::OnceLock<(
600        Arc<OpenRepo>,
601        crate::txlog_lfs::LoggedLfs<gunnar_lfs::FsLfsStore>,
602    )>,
603    making: Mutex<()>,
604}
605
606impl<S: std::ops::Deref<Target = ServerStores> + Send + Sync> ProspectiveLfs<S> {
607    pub(crate) fn new(stores: S, request: ServiceRequest) -> Self {
608        ProspectiveLfs {
609            stores,
610            request,
611            made: std::sync::OnceLock::new(),
612            making: Mutex::new(()),
613        }
614    }
615
616    fn made(&self) -> Option<&crate::txlog_lfs::LoggedLfs<gunnar_lfs::FsLfsStore>> {
617        self.made.get().map(|(_, lfs)| lfs)
618    }
619
620    /// The store, made on first use.
621    fn made_or_create(
622        &self,
623    ) -> gunnar_lfs::Result<&crate::txlog_lfs::LoggedLfs<gunnar_lfs::FsLfsStore>> {
624        if let Some(lfs) = self.made() {
625            return Ok(lfs);
626        }
627        let _one = self.making.lock().unwrap_or_else(|p| p.into_inner());
628        if let Some(lfs) = self.made() {
629            return Ok(lfs);
630        }
631        let stores: &ServerStores = &self.stores;
632        let path = self.request.repo.path();
633        let (repo, store) = create_prospect(stores, &self.request).map_err(|sentence| {
634            if sentence.starts_with("repository not found") {
635                gunnar_lfs::Error::NotFound(sentence)
636            } else {
637                gunnar_lfs::Error::Store(sentence)
638            }
639        })?;
640        let mut admitted = self.request.clone();
641        admitted.repo = repo;
642        admitted.prospective = false;
643        let lfs = open_lfs_store(stores, &store, &admitted)
644            .map_err(|err| gunnar_lfs::Error::Store(err.to_string()))?;
645        tracing::info!(
646            target: TARGET_GIT,
647            event = "git.lfs_transfer.created_repo",
648            repo = %path,
649            principal = %self.request.principal.id,
650            "an LFS upload's first object made the repository (the push-create door)"
651        );
652        Ok(&self.made.get_or_init(|| (store, lfs)).1)
653    }
654}
655
656impl<S: std::ops::Deref<Target = ServerStores> + Send + Sync> gunnar_lfs::LfsStore
657    for ProspectiveLfs<S>
658{
659    fn size_of(&self, oid: &gunnar_lfs::Oid) -> gunnar_lfs::Result<Option<u64>> {
660        match self.made() {
661            Some(lfs) => lfs.size_of(oid),
662            None => Ok(None),
663        }
664    }
665    fn open(&self, oid: &gunnar_lfs::Oid) -> gunnar_lfs::Result<Option<gunnar_lfs::LfsObject>> {
666        match self.made() {
667            Some(lfs) => lfs.open(oid),
668            None => Ok(None),
669        }
670    }
671    fn begin_write(
672        &self,
673        oid: &gunnar_lfs::Oid,
674        declared_size: u64,
675    ) -> gunnar_lfs::Result<Box<dyn gunnar_lfs::PendingWrite + '_>> {
676        self.made_or_create()?.begin_write(oid, declared_size)
677    }
678    fn delete(&self, oid: &gunnar_lfs::Oid) -> gunnar_lfs::Result<bool> {
679        match self.made() {
680            Some(lfs) => lfs.delete(oid),
681            None => Ok(false),
682        }
683    }
684}
685
686impl GitTransport for WireTransport {
687    fn serve<'a>(
688        &'a self,
689        request: ServiceRequest,
690        stream: Box<dyn DuplexStream>,
691    ) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + 'a>> {
692        let stores = Arc::clone(&self.stores);
693        let bus = self.bus.clone();
694        let attestations = self.attestations.clone();
695        let metrics = Arc::clone(&self.metrics);
696        let wire = Arc::clone(&self.wire);
697        Box::pin(async move {
698            // `spawn_blocking` does NOT carry the current span across (it is a
699            // thread hand-off, not a task spawn), so without this the whole
700            // transfer — every line `gunnar-wire`, the gate and this module
701            // emit — would fall out of `git.transfer` and lose the connection
702            // id, the principal and the repository. Captured here, where the
703            // future is polled inside `gunnar-ssh`'s instrumented task.
704            let parent = tracing::Span::current();
705            // One object cannot be borrowed mutably as both `BufRead` and
706            // `Write`, so the duplex is split before it is bridged.
707            let (read_half, write_half) = tokio::io::split(stream);
708            // ★ stinky row 1 (2026-10-10): the git work hears that its client
709            // is gone. `gunnar-ssh` sets the flag when the session's grace runs
710            // out and then WAITS for this future, so the fan worker is held
711            // until the blocking task below has actually ended; the guard sets
712            // it too if this future is dropped some other way, because a
713            // dropped `JoinHandle` stops nothing.
714            let cancel = request.cancel.clone();
715            let _stop_if_dropped = CancelOnDrop(cancel.clone());
716            let mut reader = Cancellable {
717                inner: SyncIoBridge::new(read_half),
718                cancel: cancel.clone(),
719            };
720            let mut writer = Cancellable {
721                inner: SyncIoBridge::new(write_half),
722                cancel,
723            };
724            // The halves are dropped here, at the end of the blocking task, and
725            // that is now safe: `gunnar-ssh` splits the russh channel instead of
726            // consuming it, so no part of this stream closes the channel when it
727            // goes. A 250 ms `linger` used to hold them open to win a race
728            // against `ChannelCloseOnDrop`; the race is gone and so is the
729            // timer.
730            tokio::task::spawn_blocking(move || {
731                // The blocking thread is this transfer's alone for the whole
732                // duration, so entering the span here cannot leak it into
733                // another task's lines.
734                let _entered = parent.enter();
735                serve_blocking(
736                    &Serving {
737                        stores: &stores,
738                        bus: bus.as_deref(),
739                        attestations: &attestations,
740                        metrics: &metrics,
741                        wire: &wire,
742                    },
743                    request,
744                    &mut reader,
745                    &mut writer,
746                )
747            })
748            .await
749            .map_err(|err| {
750                TransportError::Protocol(format!("the git transport task did not finish: {err}"))
751            })?
752        })
753    }
754}
755
756/// Sets the transfer's [`Cancel`](gunnar_ssh::channel::Cancel) when dropped:
757/// a transport future dropped before its blocking task ended tells the task.
758struct CancelOnDrop(gunnar_ssh::channel::Cancel);
759
760impl Drop for CancelOnDrop {
761    fn drop(&mut self) {
762        self.0.cancel();
763    }
764}
765
766/// **The git work's safe points**: every read from and write to the client.
767///
768/// Once the transfer is told to stop, the next read or write fails with
769/// `ConnectionAborted` — not `Interrupted`, which `read_exact`/`write_all`
770/// retry — and the work unwinds from there. The ref updates of a receive-pack
771/// transaction do no stream I/O between them, so a stop never lands inside
772/// one: the transaction lands whole, or (stopped while the pack was still
773/// arriving) not at all.
774struct Cancellable<T> {
775    inner: T,
776    cancel: gunnar_ssh::channel::Cancel,
777}
778
779impl<T> Cancellable<T> {
780    fn check(&self) -> std::io::Result<()> {
781        if self.cancel.is_cancelled() {
782            return Err(told_to_stop());
783        }
784        Ok(())
785    }
786}
787
788fn told_to_stop() -> std::io::Error {
789    std::io::Error::new(
790        std::io::ErrorKind::ConnectionAborted,
791        "the transfer was told to stop: its client's session ended and the transfer outlived the grace",
792    )
793}
794
795impl<T: std::io::Read> std::io::Read for Cancellable<T> {
796    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
797        self.check()?;
798        self.inner.read(buf)
799    }
800}
801
802impl<T: std::io::Write> std::io::Write for Cancellable<T> {
803    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
804        self.check()?;
805        self.inner.write(buf)
806    }
807
808    fn flush(&mut self) -> std::io::Result<()> {
809        self.check()?;
810        self.inner.flush()
811    }
812}
813
814fn wire_error(err: gunnar_wire::Error) -> TransportError {
815    TransportError::Protocol(err.to_string())
816}
817
818/// Counts the bytes that actually cross the wire.
819///
820/// **Measured, not inferred.** "Objects sent" comes out of the negotiation and
821/// says nothing about what a client paid for: a fetch that sends 12 objects can
822/// move 4 KiB or 40 MiB. `bytes_out` is the number an operator answering
823/// *"why was that clone slow"* reaches for first, and the only place it can be
824/// counted honestly is at the stream.
825///
826/// One relaxed atomic add per `read`/`write` call, and both are already
827/// buffered (`BufReader`/`BufWriter` sit on top), so this is a handful of adds
828/// per transfer rather than one per byte.
829struct Metered<T> {
830    inner: T,
831    total: Arc<AtomicU64>,
832}
833
834impl<T> Metered<T> {
835    fn new(inner: T) -> (Metered<T>, Arc<AtomicU64>) {
836        let total = Arc::new(AtomicU64::new(0));
837        (
838            Metered {
839                inner,
840                total: Arc::clone(&total),
841            },
842            total,
843        )
844    }
845}
846
847impl<T: std::io::Read> std::io::Read for Metered<T> {
848    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
849        let n = self.inner.read(buf)?;
850        self.total.fetch_add(n as u64, Ordering::Relaxed);
851        Ok(n)
852    }
853}
854
855impl<T: std::io::Write> std::io::Write for Metered<T> {
856    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
857        let n = self.inner.write(buf)?;
858        self.total.fetch_add(n as u64, Ordering::Relaxed);
859        Ok(n)
860    }
861
862    fn flush(&mut self) -> std::io::Result<()> {
863        self.inner.flush()
864    }
865}
866
867/// **THE counting point for per-namespace flow, both directions.**
868///
869/// It is a guard that records when it is DROPPED rather than a call at the end
870/// of [`serve_blocking`], and that is the whole design. `serve_blocking` has
871/// three service arms, an early return for the abandoned prospect, an early
872/// return for the advertisement, and a `?` on every fallible step; a call at
873/// "the end" would be five calls, and the sixth exit somebody adds next year
874/// would silently stop counting a whole transport. A guard cannot be
875/// forgotten, and it counts the failed transfers too — the bytes a client sent
876/// before it hung up were still received, and a tenant who is failing is
877/// exactly the tenant an operator wants to see.
878///
879/// It reads the SAME two counters [`Metered`] already keeps for
880/// `ServerMetrics`, so a byte is counted once for the box and once for the
881/// tenant off one measurement, and the two can never disagree about how much
882/// moved. Nothing here counts bytes itself.
883///
884/// The three `Cell` fields are what the arm that ran decides:
885///
886/// * `fetched` — an upload-pack SESSION, set after the advertisement's early
887///   return, so `git ls-remote` and a monitoring probe are bytes and not a
888///   customer's fetch;
889/// * `pushed` — a receive-pack session that came back `Ok`;
890/// * the LFS pair — the session's own applied figures, which are a different
891///   number from the wire bytes about the same transfer.
892struct FlowRecording<'a> {
893    flow: &'a gunnar_core::FlowLedger,
894    namespace: String,
895    principal: String,
896    bytes_in: Arc<AtomicU64>,
897    bytes_out: Arc<AtomicU64>,
898    fetched: std::cell::Cell<bool>,
899    pushed: std::cell::Cell<bool>,
900    lfs_bytes_in: std::cell::Cell<u64>,
901    lfs_bytes_out: std::cell::Cell<u64>,
902}
903
904impl Drop for FlowRecording<'_> {
905    fn drop(&mut self) {
906        self.flow.record(
907            &self.namespace,
908            gunnar_core::now_ms(),
909            &gunnar_core::Transfer {
910                bytes_in: self.bytes_in.load(Ordering::Relaxed),
911                bytes_out: self.bytes_out.load(Ordering::Relaxed),
912                pushed: self.pushed.get(),
913                fetched: self.fetched.get(),
914                lfs_bytes_in: self.lfs_bytes_in.get(),
915                lfs_bytes_out: self.lfs_bytes_out.get(),
916                principal: self.principal.clone(),
917            },
918        );
919    }
920}
921
922/// The lock file, when `err` is a store whose exclusive lock is already held.
923///
924/// `gunnar gc` opens the repository the same way a transfer does, and the
925/// `O_EXCL` lock the Arrow log takes is what makes "one writer" true across
926/// processes — so a repack and a transfer on the same repository are mutually
927/// exclusive **by that lock and nothing else**. Whichever loses gets
928/// [`gunnar_store::Error::StoreLocked`] out of `open_or_init`, wrapped in
929/// `anyhow` context by `ServerStores::repo_store`, which is why this walks the
930/// chain rather than matching the top-level error.
931///
932/// Recognising it is not cosmetic: "retry in a moment" and "an operator has to
933/// look at this" are opposite instructions, and the caller cannot tell them
934/// apart from a formatted string without matching on prose.
935fn locked_path(err: &anyhow::Error) -> Option<&std::path::Path> {
936    match err.downcast_ref::<gunnar_store::Error>() {
937        Some(gunnar_store::Error::StoreLocked { path }) => Some(path.as_path()),
938        _ => None,
939    }
940}
941
942/// Everything a transfer needs that belongs to the **server** rather than to
943/// the request.
944///
945/// One borrow instead of five. `serve_blocking` grew an eighth argument when
946/// `WireConfig` was wired through and clippy's `too_many_arguments` caught it,
947/// which is the right moment to notice that five of those arguments are the
948/// same five fields `WireTransport` holds and are passed together everywhere
949/// they are passed at all. Bundling them is what the lint is for; an
950/// `#[allow]` would have kept the shape that made a sixth easy to add.
951struct Serving<'a> {
952    stores: &'a ServerStores,
953    bus: Option<&'a EventBus>,
954    attestations: &'a AttestationLog,
955    metrics: &'a Arc<ServerMetrics>,
956    wire: &'a WireConfig,
957}
958
959fn serve_blocking(
960    serving: &Serving<'_>,
961    request: ServiceRequest,
962    reader: &mut dyn std::io::Read,
963    writer: &mut dyn std::io::Write,
964) -> Result<(), TransportError> {
965    // `bus` and `attestations` are not destructured here: they are used only by
966    // `serve_receive_pack`, which takes the whole `Serving`.
967    let Serving {
968        stores,
969        metrics,
970        wire,
971        ..
972    } = *serving;
973    // `Refused`, not `Protocol`: this is the one point in `serve_blocking` at
974    // which **nothing has been written** — `Metered` is constructed on the next
975    // lines and the state machine has not been entered — so the client can still
976    // be handed a pkt-line, and `TransportError::Refused` is exactly the claim
977    // "no byte has gone out yet". `open_repo_store` is shared with the HTTP LFS
978    // seam so the two transports refuse a locked repository in one sentence and
979    // not two (one implementation).
980    //
981    // A PROSPECTIVE request — the first-repository door — has no store to
982    // open and must not get one here: `repo_store` would create the directory
983    // for a push the client may abandon at the advertisement. That path goes
984    // through `admit_prospective_push` below, once the buffers exist.
985    if request.cancel.is_cancelled() {
986        return Err(TransportError::Io(told_to_stop()));
987    }
988    let store = if request.prospective {
989        None
990    } else {
991        Some(open_repo_store(stores, &request)?)
992    };
993    let (metered_in, bytes_in) = Metered::new(reader);
994    let (metered_out, bytes_out) = Metered::new(writer);
995    // **The per-namespace flow counting point.** Created here, beside the two
996    // counters it reads, and recorded when it is dropped — on every exit from
997    // this function, including the early returns and the failures. See
998    // `FlowRecording`. It is created BEFORE the first-repository door below, so
999    // that a client which took an advertisement of a repository it never
1000    // created still has its bytes charged to the namespace it asked about.
1001    let flow = FlowRecording {
1002        flow: stores.flow().as_ref(),
1003        namespace: request.repo.namespace.clone(),
1004        principal: request
1005            .key_fingerprint
1006            .as_ref()
1007            .map(|f| f.to_string())
1008            .unwrap_or_default(),
1009        bytes_in: Arc::clone(&bytes_in),
1010        bytes_out: Arc::clone(&bytes_out),
1011        fetched: std::cell::Cell::new(false),
1012        pushed: std::cell::Cell::new(false),
1013        lfs_bytes_in: std::cell::Cell::new(0),
1014        lfs_bytes_out: std::cell::Cell::new(0),
1015    };
1016    // **`BufWriter::new` is 8 KiB, and a sideband packet is 65 520.** So the
1017    // default capacity did not buffer the pack at all: every packet flushed the
1018    // 5-byte header as a write of its own and then passed 65 515 bytes straight
1019    // through. Two writes per packet, one of them five bytes — and under this
1020    // transport a write is not a syscall, it is a hand-off across a
1021    // `tokio::io::duplex` from a `spawn_blocking` thread, which is a `block_on`,
1022    // a waker and (whenever the reader is parked) a futex pair.
1023    // `gunnar_wire::TRANSPORT_BUFFER_BYTES` carries the arithmetic and the
1024    // write-counting guard.
1025    let mut input = BufReader::with_capacity(gunnar_wire::TRANSPORT_BUFFER_BYTES, metered_in);
1026    let mut output = BufWriter::with_capacity(gunnar_wire::TRANSPORT_BUFFER_BYTES, metered_out);
1027    let started = std::time::Instant::now();
1028
1029    // ── the first-repository door: advertise, read, check, THEN create ──────
1030    //
1031    // Nothing has been written to the catalog or the disk for a prospective
1032    // request. The client is shown the empty repository it would be pushing
1033    // into; if it hangs up (a sha1 tree that read `object-format=sha256`,
1034    // `git push` with nothing to do) this returns and nothing exists. Only a
1035    // command list that passes the hash check turns into a repository — and
1036    // that create is `ServerStores::create_repo`, the same transactional verb
1037    // every other create uses.
1038    //
1039    // A prospective LFS UPLOAD (git-lfs's pre-push, before receive-pack's
1040    // commands) goes on with no store at all: `ProspectiveLfs` makes the
1041    // repository through the same writes when the first object arrives.
1042    let (request, store, preamble) = match store {
1043        Some(store) => (request, Some(store), None),
1044        None if matches!(request.service, GitService::LfsTransfer(_)) => (request, None, None),
1045        None => match admit_prospective_push(serving, &request, &mut input, &mut output)? {
1046            Some((request, store, preamble)) => (request, Some(store), Some(preamble)),
1047            None => {
1048                output.flush()?;
1049                metrics.add_bytes_out(bytes_out.load(Ordering::Relaxed));
1050                metrics.add_bytes_in(bytes_in.load(Ordering::Relaxed));
1051                tracing::info!(
1052                    target: TARGET_GIT,
1053                    event = "git.receive_pack.prospect_abandoned",
1054                    repo = %request.repo.key(),
1055                    elapsed_ms = started.elapsed().as_millis() as u64,
1056                    "the client took the advertisement of a repository that does not exist and \
1057                     sent no command; nothing was created"
1058                );
1059                return Ok(());
1060            }
1061        },
1062    };
1063
1064    // Only a prospective LFS upload reaches here without a store.
1065    let git_store = || {
1066        store.clone().ok_or_else(|| {
1067            TransportError::Refused(format!(
1068                "repository not found, or you do not have access: {}",
1069                request.repo.path()
1070            ))
1071        })
1072    };
1073    match request.service {
1074        GitService::UploadPack => {
1075            let span = tracing::info_span!(target: TARGET_GIT, "git.upload_pack");
1076            let _entered = span.enter();
1077            let store = git_store()?;
1078            let version = protocol_version(&request);
1079            // ── the znippy engine answers reads ─────────────────────────────────
1080            //
1081            // When `GUNNAR_COLD_TIER=znippy` and the repository has an archive,
1082            // upload-pack serves pack ENTRIES out of that archive rather than
1083            // out of `objects/pack` — no unfold, no file. `packed()` is `Some`
1084            // on both arms, so the entry-copy path is the same code; only where
1085            // the bytes come from changes.
1086            //
1087            // receive-pack is deliberately NOT switched: a push lands on `fs`
1088            // and reaches the archive through the append hook. The archive is
1089            // immutable, and `ZnippyObjects::write` says so rather than
1090            // pretending.
1091            //
1092            // There is no second read side to choose between any more. The
1093            // engine *is* the archive on the znippy arm, so `GUNNAR_COLD_TIER`,
1094            // `cold_objects()` and the `Box<dyn ObjectStore>` that carried a
1095            // `None` for "this binary has no znippy engine" have all gone with
1096            // the old znippy engine crate: one store answers reads and writes, and which
1097            // engine it is was decided at startup.
1098            //
1099            // **One handle, not two.** `UploadPack` used to take an object
1100            // store and a ref store, and an advertisement built from one
1101            // snapshot could promise a pack built from another. `GitServe`
1102            // carries both halves, so the ref list and the pack it promises are
1103            // the same store.
1104            let git = store.serve();
1105            // `hash_kind` is a required argument, never a default: it comes
1106            // from the catalog entry through `OpenRepo`, and answering `sha1`
1107            // for a sha256 repository does not error — it decodes garbage.
1108            //
1109            // `with_config`, not `new`: this call is the whole of what makes
1110            // `--no-filter`, `--want-policy`, `--fetch-budget` and the byte
1111            // ceilings anything other than fields nobody reads.
1112            let upload = UploadPack::new(git, store.hash_kind()).with_config(wire.upload.clone());
1113            // `Delivery::Advertisement` is `GET /info/refs`: write the ref list
1114            // (v0) or the capability list (v2) and stop, reading nothing. The
1115            // same call the connected path makes inline, so the two transports
1116            // cannot advertise differently.
1117            if request.delivery == Delivery::Advertisement {
1118                upload.advertise(version, &mut output).map_err(wire_error)?;
1119                output.flush()?;
1120                let out = bytes_out.load(Ordering::Relaxed);
1121                metrics.add_bytes_out(out);
1122                tracing::info!(
1123                    target: TARGET_GIT,
1124                    event = "git.upload_pack.advertised",
1125                    protocol = version.number(),
1126                    bytes_out = out,
1127                    elapsed_ms = started.elapsed().as_millis() as u64,
1128                    "served the upload-pack advertisement"
1129                );
1130                return Ok(());
1131            }
1132            // Past the advertisement's early return above: this is a SESSION,
1133            // which is what `fetches` counts. A bare `git ls-remote` never
1134            // reaches here.
1135            flow.fetched.set(true);
1136            let summary = upload
1137                .serve_session(version, wire_session(&request), &mut input, &mut output)
1138                .map_err(|err| {
1139                    tracing::warn!(
1140                        target: TARGET_GIT,
1141                        event = "git.upload_pack.failed",
1142                        elapsed_ms = started.elapsed().as_millis() as u64,
1143                        bytes_out = bytes_out.load(Ordering::Relaxed),
1144                        %err,
1145                        "git-upload-pack failed"
1146                    );
1147                    wire_error(err)
1148                })?;
1149            output.flush()?;
1150            let out = bytes_out.load(Ordering::Relaxed);
1151            if summary.objects_sent > 0 {
1152                metrics.add_packs_served(1);
1153            }
1154            metrics.add_objects_sent(summary.objects_sent as u64);
1155            metrics.add_bytes_out(out);
1156            metrics.add_bytes_in(bytes_in.load(Ordering::Relaxed));
1157            // The applied outputs, and the duration. `repo` and `principal` are
1158            // NOT repeated: they are fields of `ssh.connection`, which this span
1159            // descends from.
1160            tracing::info!(
1161                target: TARGET_GIT,
1162                event = "git.upload_pack.served",
1163                // What was actually spoken, taken from the session's own
1164                // summary rather than from the request that asked for it; a v2
1165                // request served as v0 would otherwise log as v2.
1166                protocol = summary.protocol.number(),
1167                ls_refs = summary.ls_refs,
1168                rounds = summary.fetch_rounds,
1169                common = summary.common,
1170                objects = summary.objects_sent,
1171                // **WHICH PATH ANSWERED.** `Selection::answered_from_bitmap`
1172                // exists so a guard can tell a fetch served from the store's
1173                // reachability index from one served by walking, and until now
1174                // nothing anywhere read it: not a log line, not a metric, not a
1175                // test. Its only reader in the constellation was an example
1176                // binary.
1177                //
1178                // That blind spot has already cost something concrete. The
1179                // `--fetch-budget` control charged the ids a WALKER holds, and
1180                // on the store the appliance actually runs there is no walker —
1181                // the index answers whole — so the budget was compared against
1182                // nothing while a red test sat in front of three separate call
1183                // sites and each read it as a policy question. A field nobody can
1184                // observe is a behaviour nobody can assert.
1185                from_index = summary.answered_from_bitmap,
1186                bytes_out = out,
1187                elapsed_ms = started.elapsed().as_millis() as u64,
1188                "served git-upload-pack"
1189            );
1190        }
1191        GitService::ReceivePack => {
1192            let span = tracing::info_span!(target: TARGET_GIT, "git.receive_pack");
1193            let _entered = span.enter();
1194            let store = git_store()?;
1195            let outcome =
1196                serve_receive_pack(serving, &request, &store, preamble, &mut input, &mut output);
1197            let out = bytes_out.load(Ordering::Relaxed);
1198            let inn = bytes_in.load(Ordering::Relaxed);
1199            metrics.add_bytes_out(out);
1200            metrics.add_bytes_in(inn);
1201            // A push is a receive-pack session that CAME BACK. The bytes are
1202            // charged either way — they arrived — but a session that failed is
1203            // not a push, and counting it as one would make a tenant whose every
1204            // push is refused look like a tenant who is working.
1205            flow.pushed.set(outcome.is_ok());
1206            if let Err(err) = &outcome {
1207                tracing::warn!(
1208                    target: TARGET_GIT,
1209                    event = "git.receive_pack.failed",
1210                    elapsed_ms = started.elapsed().as_millis() as u64,
1211                    bytes_in = inn,
1212                    %err,
1213                    "git-receive-pack failed"
1214                );
1215            }
1216            outcome?;
1217        }
1218        // LFS over SSH, git-lfs 3.0's pure-SSH transfer. The grant was already decided
1219        // by `gunnar_ssh::command::parse`, from the same key table and the same
1220        // path sandbox the two verbs above went through: `operation` here is the
1221        // one that demanded Read or Write, not a second decision. It is passed
1222        // straight to the session so there is no second authorisation path.
1223        //
1224        // The objects live under the repository's own git dir, in `lfs/`. See
1225        // `gunnar_lfs::fs_store`.
1226        GitService::LfsTransfer(operation) => {
1227            let span = tracing::info_span!(target: TARGET_GIT, "git.lfs_transfer");
1228            let _entered = span.enter();
1229            let lfs: Box<dyn gunnar_lfs::LfsStore + '_> = match &store {
1230                Some(store) => Box::new(open_lfs_store(stores, store, &request)?),
1231                None => Box::new(ProspectiveLfs::new(stores, request.clone())),
1232            };
1233            let mut session = wire.lfs_session(lfs.as_ref(), operation);
1234            // **The tenant's remaining LFS room**, from the gate chain that ran
1235            // in `authorise_service`. `None` on a community build, on an
1236            // uncapped namespace and on every download; see
1237            // `gunnar_ssh::PlanGate::lfs_allowance`.
1238            if let Some(allowance) = request.lfs_allowance.clone() {
1239                session = session.with_allowance(allowance);
1240            }
1241            // `limits()` and `allowance()` read back off the session, not off
1242            // `wire` and not off the request: what is logged is what the session
1243            // will enforce, so a builder that stopped taking would show here
1244            // rather than in a support ticket.
1245            let effective = session.limits();
1246            tracing::debug!(
1247                target: TARGET_GIT,
1248                max_object_bytes = effective.max_object_bytes,
1249                max_batch_objects = effective.max_batch_objects,
1250                max_drain_bytes = effective.max_drain_bytes,
1251                lfs_allowance_bytes = session.allowance().map(|a| a.bytes),
1252                "lfs session limits"
1253            );
1254            let outcome = session.serve(&mut input, &mut output);
1255            output.flush()?;
1256            let out = bytes_out.load(Ordering::Relaxed);
1257            let inn = bytes_in.load(Ordering::Relaxed);
1258            metrics.add_bytes_out(out);
1259            metrics.add_bytes_in(inn);
1260            let summary = outcome.map_err(|err| {
1261                tracing::warn!(
1262                    target: TARGET_GIT,
1263                    event = "git.lfs_transfer.failed",
1264                    operation = %operation,
1265                    elapsed_ms = started.elapsed().as_millis() as u64,
1266                    %err,
1267                    "git-lfs-transfer failed"
1268                );
1269                TransportError::Protocol(err.to_string())
1270            })?;
1271            // The APPLIED figures, from the session's own count. They are a
1272            // different number from the wire bytes above about the same
1273            // transfer — the wire carries framing and the batch conversation —
1274            // so the flow ledger keeps both rather than one as a subset of the
1275            // other.
1276            flow.lfs_bytes_in.set(summary.bytes_received);
1277            flow.lfs_bytes_out.set(summary.bytes_sent);
1278            // Applied output: objects and bytes that really moved, taken from
1279            // the session's own count rather than from what was asked for.
1280            tracing::info!(
1281                target: TARGET_GIT,
1282                event = "git.lfs_transfer.served",
1283                operation = %operation,
1284                commands = summary.commands,
1285                batches = summary.batches,
1286                batch_objects = summary.batch_objects,
1287                objects_sent = summary.objects_sent,
1288                objects_received = summary.objects_received,
1289                lfs_bytes_out = summary.bytes_sent,
1290                lfs_bytes_in = summary.bytes_received,
1291                clean_quit = summary.clean_quit,
1292                bytes_out = out,
1293                bytes_in = inn,
1294                elapsed_ms = started.elapsed().as_millis() as u64,
1295                "served git-lfs-transfer"
1296            );
1297        }
1298    }
1299    Ok(())
1300}
1301
1302// Eight, because the counters joined the six things a push already needed.
1303// Bundling them into a context struct would be one more type whose
1304// only job is to be unpacked on the first line of this function.
1305#[allow(clippy::too_many_arguments)]
1306/// The door's commit. Advertise the prospect, read the client's commands,
1307/// and — only if there are any and they pass the hash check — write the
1308/// catalog row (the namespace with it, if the admin's push names a new one)
1309/// and make the store. `None` means the client sent nothing and nothing was
1310/// made.
1311///
1312/// A refusal after the preamble (a stale store at the name, a grant revoked
1313/// since the exec, a store the engine cannot make) is answered in git's own
1314/// protocol — an `ERR` packet, which `git push` renders as `remote error:` —
1315/// and leaves nothing: the row is retracted, the namespace too if this call
1316/// conjured it, the directory removed.
1317fn admit_prospective_push(
1318    serving: &Serving<'_>,
1319    request: &ServiceRequest,
1320    input: &mut dyn BufRead,
1321    output: &mut dyn std::io::Write,
1322) -> Result<Option<(ServiceRequest, Arc<OpenRepo>, gunnar_wire::Preamble)>, TransportError> {
1323    let Serving { stores, wire, .. } = *serving;
1324    if request.service != GitService::ReceivePack {
1325        // An LFS upload's door is `ProspectiveLfs`, never this one; anything
1326        // else here is a programming error, refused rather than
1327        // served against a repository that does not exist.
1328        return Err(TransportError::Refused(format!(
1329            "repository not found, or you do not have access: {}",
1330            request.repo.path()
1331        )));
1332    }
1333    let hash_kind: gix_hash::Kind = request.repo.hash_kind.into();
1334    if wire_session(request).advertises_inline() || request.delivery == Delivery::Advertisement {
1335        ReceivePack::write_empty_advertisement(hash_kind, &wire.receive, output)
1336            .map_err(wire_error)?;
1337        if request.delivery == Delivery::Advertisement {
1338            return Ok(None);
1339        }
1340    }
1341    let Some(preamble) = ReceivePack::read_preamble(hash_kind, input).map_err(wire_error)? else {
1342        return Ok(None);
1343    };
1344
1345    // The client means it. Now, and only now, the writes — in the order that
1346    // leaves nothing behind if any of them fails.
1347    match create_prospect(stores, request) {
1348        Ok((repo, store)) => {
1349            let mut admitted = request.clone();
1350            admitted.repo = repo;
1351            admitted.prospective = false;
1352            Ok(Some((admitted, store, preamble)))
1353        }
1354        Err(sentence) => {
1355            let _ = gunnar_wire::err_packet(output, &sentence);
1356            let _ = output.flush();
1357            Err(TransportError::Protocol(sentence))
1358        }
1359    }
1360}
1361
1362/// **The first-repository door's writes**, for a prospective request whose
1363/// client has shown it means it — a receive-pack's commands past the hash
1364/// check, or an LFS upload's first object ([`ProspectiveLfs`]). The stale
1365/// store check, the catalog row (and the namespace, if the admin's push names
1366/// a new one) under [`gunnar_ssh::Control::admit_push_create`]'s re-asked
1367/// grant, then the store — and if the store cannot be made, the row and the
1368/// namespace are taken back and the directory removed. `Err` is the sentence
1369/// the client is owed; nothing is left behind by it.
1370fn create_prospect(
1371    stores: &ServerStores,
1372    request: &ServiceRequest,
1373) -> Result<(gunnar_core::Repo, Arc<OpenRepo>), String> {
1374    let path = request.repo.path();
1375    let control = stores.ssh_control();
1376    // **Made meanwhile, by this same push** (2026-09-28): git takes
1377    // receive-pack's advertisement (the prospect) BEFORE it runs the pre-push
1378    // hook, and git-lfs's upload in that hook makes the repository in
1379    // between. The stale-store check below would answer the push's own
1380    // commands with an ERR. A repository that now EXISTS is served, as the
1381    // lost race in `admit_push_create` is, after the same re-asked grant,
1382    // and only in the hash the client was shown.
1383    match control.resolve(&path) {
1384        Ok(Some(existing)) => {
1385            if existing.hash_kind != request.repo.hash_kind {
1386                return Err(format!(
1387                    "{path} was created meanwhile as a {:?} repository, and this push was shown {:?}; nothing was written — push again",
1388                    existing.hash_kind, request.repo.hash_kind
1389                ));
1390            }
1391            return match control.push_create_spec(&request.principal, &path) {
1392                Ok(Some(_)) => stores
1393                    .repo_store(&existing)
1394                    .map(|store| (existing, store))
1395                    .map_err(|err| {
1396                        format!("the repository {path} could not be opened on the server: {err:#}")
1397                    }),
1398                Ok(None) => Err(format!(
1399                    "repository not found, or you do not have access: {path}"
1400                )),
1401                Err(err) => Err(format!(
1402                    "the gunnar control plane could not read {path}: {err}"
1403                )),
1404            };
1405        }
1406        Ok(None) => {}
1407        Err(err) => {
1408            return Err(format!(
1409                "the gunnar control plane could not read {path}: {err}"
1410            ))
1411        }
1412    }
1413    if let Err(err) = stores.refuse_stale_store(&path, false, request.repo.hash_kind) {
1414        return Err(format!("{err:#}"));
1415    }
1416    let (repo, conjured) = match control.admit_push_create(&request.principal, &path) {
1417        Ok(Some(pair)) => pair,
1418        Ok(None) => {
1419            return Err(format!(
1420                "repository not found, or you do not have access: {path}"
1421            ))
1422        }
1423        Err(err) => {
1424            return Err(format!(
1425                "the gunnar control plane could not create {path}: {err}"
1426            ))
1427        }
1428    };
1429    match stores.repo_store(&repo) {
1430        Ok(store) => Ok((repo, store)),
1431        Err(err) => {
1432            stores.close_repo(&path);
1433            if let Err(undo) = control.retract_push_create(&path, conjured) {
1434                tracing::error!(
1435                    target: TARGET_GIT,
1436                    repo = %path,
1437                    error = %undo,
1438                    "retracting the catalog row of a push-create whose store failed"
1439                );
1440            }
1441            if let Ok(dir) = stores.repo_dir(&path) {
1442                let _ = std::fs::remove_dir_all(dir);
1443            }
1444            tracing::error!(
1445                target: TARGET_GIT,
1446                repo = %path,
1447                error = format!("{err:#}"),
1448                "the push-created repository's store could not be made; nothing was kept"
1449            );
1450            Err(format!("the repository {path} could not be created on the server; nothing was kept. The reason is in the server log"))
1451        }
1452    }
1453}
1454
1455/// **A push into an encrypted repository on a build that cannot check it in quarantine**
1456/// (wiring tracer B 2026-10-08): the quarantine is the znippy engine's
1457/// (`encrypted_quarantine`), and without it a refused plaintext pack would be stored and framed
1458/// for the twin before the ref gate refused it. `Some(the line)`: refuse the push by name,
1459/// before a byte is read.
1460pub(crate) fn unchecked_encrypted_push(encrypted: bool, quarantine: bool) -> Option<String> {
1461    (encrypted && !quarantine).then(|| {
1462        "this repository is encrypted, and this Gunnar was built without the check that keeps \
1463         plaintext out of it, so it takes no push into it: push to a Gunnar built with its \
1464         standard storage engine. Nothing was received"
1465            .to_string()
1466    })
1467}
1468
1469fn serve_receive_pack(
1470    serving: &Serving<'_>,
1471    request: &ServiceRequest,
1472    store: &OpenRepo,
1473    preamble: Option<gunnar_wire::Preamble>,
1474    input: &mut dyn BufRead,
1475    output: &mut dyn std::io::Write,
1476) -> Result<(), TransportError> {
1477    let Serving {
1478        stores,
1479        bus,
1480        attestations,
1481        metrics,
1482        wire,
1483    } = *serving;
1484    let started = std::time::Instant::now();
1485    // A build that cannot check an encrypted repository's pack in quarantine takes none.
1486    if let Some(line) =
1487        unchecked_encrypted_push(request.repo.encrypted, cfg!(feature = "engine-znippy"))
1488    {
1489        return Err(TransportError::Refused(line));
1490    }
1491    // **Everything below this line is a validation read.** A receive-pack
1492    // ingests, indexes and then walks every commit and tree it was sent to
1493    // prove the push closes — once each — and serves nobody. Those reads are
1494    // indistinguishable from a client's inside the store, so the caller is the
1495    // only thing that can say which they are, and this is the caller.
1496    //
1497    // Without it the push fills the engine's lazy read-cache for a reader that
1498    // does not exist: MEASURED 6 778 047 872 B on `torvalds/linux`, 31 % of the
1499    // finished store (measured 2026-09-03).
1500    //
1501    // Thread-scoped and dropped at the end of this function, so a concurrent
1502    // clone on another connection still warms the cache normally.
1503    #[cfg(feature = "engine-znippy")]
1504    let _validation_reads = crate::storage::znippy_engine::validation_reads();
1505    // **One handle.** `GitServe` carries the reading contract and, through its
1506    // `GitOps` supertrait, the eleven — so receive-pack advertises, checks
1507    // connectivity and writes references against a single store rather than
1508    // against an object handle and a ref handle that could disagree.
1509    let git = store.serve();
1510    let hash_kind = store.hash_kind();
1511
1512    let gate = PolicyGate::open(store.git_dir(), request, stores, Arc::clone(metrics));
1513
1514    // Opened before the ingest, and that ordering is load-bearing: whether the
1515    // policy has any gate at all is what decides if the ingest pays for an
1516    // object-set enumeration. It used to be built after, when nothing needed
1517    // to know.
1518
1519    // No `objects/pack` is created. An empty one is what a later reader uses
1520    // to decide a repository is packfile-shaped, and this server never writes
1521    // a packfile into it.
1522
1523    // ── who asks for the object set, and why it is not only the gates ──────
1524    //
1525    // `want_objects: !gate.engine.is_empty()` stood here, and it had **one**
1526    // consumer in mind. The push attestation is a second one, and it was not
1527    // counted: on a repository with no policy gates the ingest skipped the
1528    // enumeration and `attestation_for` then wrote `new_object_count: 0` with
1529    // an empty `new_object_oids` — a push of nothing and a push nobody
1530    // enumerated rendering as the same record. The log line thirty lines below
1531    // this already got that distinction right (`objects_unknown`); the
1532    // attestation, which is the durable artefact and the one a verifier reads,
1533    // did not. `tests/push_events.rs` saw it as *"the pushed commit is missing
1534    // from the attestation's object list: []"*.
1535    //
1536    // So the attestation asks too, and by default it gets an answer. That is
1537    // not free — see `znippy_engine::ingest`'s note on why the flag exists at
1538    // all: the ids are not a by-product of storing a pack, and asking for them
1539    // waits for the indexer to drain. An operator who would rather have the
1540    // latency than the object list turns it off with
1541    // `--no-attest-objects`, and the attestation then records **why** the set
1542    // is absent instead of implying it was empty.
1543    let objects = if gate.engine.reads_new_objects() {
1544        // A gate reads the set. (Not `!is_empty()`: the attribution-trailer
1545        // gate is live on every repository by default and decides from the
1546        // references and the object graph, so it must not make every push
1547        // demand an enumeration — the gix engine refuses `Required` outright.) An engine that cannot produce one must refuse
1548        // rather than let the gate decide on nothing.
1549        crate::storage::ObjectSet::Required
1550    } else if wire.attest_objects {
1551        // Only the attestation wants it, and the attestation can say it did not
1552        // get it. An engine that cannot enumerate still serves the push.
1553        crate::storage::ObjectSet::Wanted
1554    } else {
1555        crate::storage::ObjectSet::NotNeeded
1556    };
1557    let ingest = PushIngest {
1558        git_dir: store.git_dir().to_path_buf(),
1559        hash_kind,
1560        engine: stores.engine(),
1561        objects,
1562        // **A push has nobody watching an object count.** `git push` renders the
1563        // remote's progress band, and what it wants there is bytes; there is no
1564        // job record and no console poll on this path. The mirror is the caller
1565        // that has both, so the mirror is the caller that passes a sink.
1566        watching: None,
1567        // The pushed pack is a transaction (the twin): framed before the engine stores it. For a
1568        // first push `request.repo` is exactly the row `admit_push_create` writes first.
1569        frame: Some(PackFrame::of(stores, &request.repo)),
1570        // An encrypted repository's pack is checked before it is kept.
1571        encrypted: request.repo.encrypted.then_some(git),
1572        // Held to the data set's room, chunk by chunk (cov-13).
1573        space: stores.space_ledger(),
1574    };
1575    if gate.engine.is_empty() {
1576        tracing::info!(
1577            target: TARGET_POLICY,
1578            event = "policy.gates.none",
1579            policy = %store.git_dir().join(POLICY_FILE).display(),
1580            "no policy gates are configured for this repository; every well-formed push is \
1581             accepted. This is an empty policy, not a passing one."
1582        );
1583    } else {
1584        tracing::info!(
1585            target: TARGET_POLICY,
1586            event = "policy.gates.live",
1587            gates = ?gate.engine.gate_names(),
1588            "policy gates live for this push"
1589        );
1590    }
1591
1592    // **receive-pack reads from the store it writes into.**
1593    //
1594    // This used to be `&objects` unconditionally, under a comment saying
1595    // receive-pack was "deliberately NOT switched" because a push landed on
1596    // `fs` and reached the tier through the append hook. That is no longer the
1597    // route: the pack goes straight into the tier, so checking connectivity
1598    // against the filesystem would ask an empty repository whether the objects
1599    // it just stored are present — and the answer is a refused push, which is
1600    // exactly what it did the first time this ran.
1601    //
1602    // A pack the ingest stored but the indexer has not absorbed yet is **not**
1603    // a stale answer here: the per-pack `indexed` bit makes an index miss fall back to the journal
1604    // rather than report absent, so the read side sees a push the moment
1605    // `put_pack` returns, which is before anything asks.
1606    //
1607    // `write_objects` and not `cold_objects`: the latter asks whether there is
1608    // anything to *serve*, and answers `None` for an empty repository — which
1609    // is every repository's first push.
1610    // The engine is the read side; there is nothing else to open.
1611
1612    // No `#[allow(unused_mut)]` any more: it was here because the only
1613    // assignment below was `cfg(metered)`, so a community build had a `mut`
1614    // binding it never wrote. The ceiling is assigned on both builds, so the
1615    // `mut` is now load-bearing on both — and an allow that no longer suppresses
1616    // anything is a suppression nobody can see expire.
1617    let mut receive_config = wire.receive.clone();
1618    // The plan's warning (Grace), decided in `authorise_service` and carried
1619    // on the request: the wire prints it on the progress band beside the
1620    // report, so `git push` shows `remote: plan expired …; renew at …`.
1621    #[cfg(feature = "metered")]
1622    {
1623        receive_config.notice = request.plan_notice.clone();
1624    }
1625    // **The mid-upload ceiling**, decided by the same gate chain in
1626    // `authorise_service` and carried on the request beside the notice. Not
1627    // `cfg(metered)`: the field is on `ServiceRequest` unconditionally and a
1628    // community build simply carries `None`, so there is one path here rather
1629    // than a second one that only the paid build compiles (one implementation). The two
1630    // `PackCeiling`s are the deliberate twin `gunnar_ssh::PackCeiling`
1631    // documents — `gunnar-ssh` does not depend on `gunnar-wire` — and this is
1632    // the one seam that holds both and converts.
1633    //
1634    // The third field is the one this seam could not have been given by either
1635    // side alone: `gunnar-ssh` measured the namespace and marked its ledger,
1636    // `gunnar-wire` counts the bytes, and neither may name the other's type.
1637    // See `PackMark` below.
1638    receive_config.pack_ceiling =
1639        request
1640            .pack_ceiling
1641            .as_ref()
1642            .map(|ceiling| gunnar_wire::PackCeiling {
1643                bytes: ceiling.bytes,
1644                refusal: ceiling.refusal.clone(),
1645                ledger: request
1646                    .pack_mark
1647                    .clone()
1648                    .map(|mark| Arc::new(PackMark { mark }) as Arc<dyn gunnar_wire::PackLedger>),
1649            });
1650    let receive = ReceivePack::new(git, hash_kind).with_config(receive_config);
1651    // `GET /info/refs?service=git-receive-pack`: the ref list and its
1652    // capabilities, then stop. No command list, no pack, no gate — the push
1653    // itself arrives as a separate POST.
1654    if request.delivery == Delivery::Advertisement && preamble.is_none() {
1655        receive
1656            .write_ref_advertisement(output)
1657            .map_err(wire_error)?;
1658        output.flush()?;
1659        tracing::info!(
1660            target: TARGET_GIT,
1661            event = "git.receive_pack.advertised",
1662            elapsed_ms = started.elapsed().as_millis() as u64,
1663            "served the receive-pack advertisement"
1664        );
1665        return Ok(());
1666    }
1667    // A preamble already read (the first-repository door) skips the
1668    // advertisement and the command read: that half happened before the
1669    // repository existed.
1670    let outcome = match preamble {
1671        Some(preamble) => receive
1672            .serve_after_preamble(preamble, input, output, &ingest, &gate)
1673            .map(Some),
1674        None => receive.serve_session(wire_session(request), input, output, &ingest, &gate),
1675    }
1676    .map_err(wire_error)?;
1677    output.flush()?;
1678
1679    let Some(outcome) = outcome else {
1680        tracing::info!(
1681            target: TARGET_GIT,
1682            event = "git.receive_pack.advertisement_only",
1683            elapsed_ms = started.elapsed().as_millis() as u64,
1684            "git-receive-pack: the client took the advertisement and had nothing to push"
1685        );
1686        return Ok(());
1687    };
1688
1689    // The key this session authenticated with. Carried on the request since
1690    // The authenticating key itself, not recovered from the identity table.
1691    let fingerprint = session_key_fingerprint(request);
1692    // The answerable one. See `session_credential` and GUN-014.
1693    let credential = session_credential(request);
1694    let gates = gate.gate_that_refused();
1695    // **THE counting point for a policy refusal**, per namespace and per push.
1696    //
1697    // Once per PUSH and not once per refused reference, deliberately: a push
1698    // that names four protected branches met one door four times, and a console
1699    // that reported four refusals would make one mistake look like a campaign.
1700    // The refs themselves are already in the `git.ref.rejected` lines below and
1701    // in the `PushRejected` events, which is where per-reference detail belongs.
1702    if !gates.is_empty() {
1703        serving.stores.flow().refuse(
1704            &request.repo.namespace,
1705            gunnar_core::now_ms(),
1706            gunnar_core::Refusal::Policy,
1707        );
1708    }
1709
1710    let mut refs_ok = 0usize;
1711    let mut refs_rejected = 0usize;
1712    for (command, status) in outcome.commands.iter().zip(&outcome.statuses) {
1713        match status {
1714            RefStatus::Ok => {
1715                refs_ok += 1;
1716                if command.is_delete() {
1717                    metrics.add_refs_deleted(1);
1718                } else {
1719                    metrics.add_refs_updated(1);
1720                }
1721                tracing::info!(
1722                    target: TARGET_GIT,
1723                    event = if command.is_delete() { "git.ref.deleted" } else { "git.ref.updated" },
1724                    ref_name = %command.name,
1725                    old = %command.old,
1726                    new = %command.new,
1727                    "ref updated"
1728                )
1729            }
1730            RefStatus::Rejected(reason) => {
1731                refs_rejected += 1;
1732                metrics.add_refs_rejected(1);
1733                tracing::warn!(
1734                    target: TARGET_GIT,
1735                    event = "git.ref.rejected",
1736                    ref_name = %command.name,
1737                    old = %command.old,
1738                    new = %command.new,
1739                    // The gate that said no, when one did. Empty means nothing
1740                    // named a gate: a connectivity failure, a lost
1741                    // compare-and-swap, or the collateral refusal every other
1742                    // reference gets in an `--atomic` push. The event stream publishes the
1743                    // same distinction; the log must not blur it.
1744                    gate = gates.get(&command.name.to_string()).map(String::as_str).unwrap_or(""),
1745                    %reason,
1746                    "ref rejected"
1747                )
1748            }
1749        }
1750        if let Some(bus) = bus {
1751            // Only a reference that actually moved has a descent question to
1752            // answer, so the walk is skipped for everything else.
1753            let forced = status.is_ok() && was_forced(git, command);
1754            publish(
1755                bus,
1756                request,
1757                command,
1758                status,
1759                outcome.atomic,
1760                &fingerprint,
1761                &credential,
1762                &gates,
1763                forced,
1764            );
1765        }
1766    }
1767
1768    // A push that moved a reference is the pusher's operation (2026-10-21,
1769    // the admin's user list). In memory, at most once a minute per person.
1770    if refs_ok > 0 {
1771        stores
1772            .activity()
1773            .operated(&request.principal.id, gunnar_core::now_ms());
1774    }
1775
1776    // ── a repository nobody could clone, made clonable ─────────────────────
1777    //
1778    // After the references have moved, and only for a repository whose `HEAD`
1779    // still resolves to nothing. See
1780    // [`ServerStores::adopt_default_branch_if_unborn`] for why a push is
1781    // allowed to decide this and for how narrow the condition is. A failure
1782    // here is logged and not fatal: the push itself succeeded and its
1783    // references are durable, and refusing it afterwards would be a lie about
1784    // what happened.
1785    let created: Vec<String> = outcome
1786        .commands
1787        .iter()
1788        .zip(&outcome.statuses)
1789        .filter(|(command, status)| status.is_ok() && !command.is_delete())
1790        .map(|(command, _)| command.name.to_string())
1791        .collect();
1792    if !created.is_empty() {
1793        if let Err(err) = stores.adopt_default_branch_if_unborn(&request.repo.path(), &created) {
1794            tracing::warn!(
1795                target: TARGET_GIT,
1796                event = "git.default_branch.adoption_failed",
1797                repo = %request.repo.key(),
1798                error = format!("{err:#}"),
1799                "the push landed, but this repository's HEAD names a branch that does not \
1800                 exist and adopting a pushed one failed; it cannot be cloned until \
1801                 `set-default-branch` is run against it"
1802            );
1803        }
1804    }
1805
1806    // ── the one line per push an operator reads ────────────────────────────
1807    //
1808    // Applied outputs: how many references actually moved, how many were
1809    // refused, what the pack cost and how long the whole thing took. A push
1810    // summary that said only "done" would be indistinguishable from one that
1811    // moved nothing (measured, not asserted).
1812    //
1813    // `objects` is what the push INTRODUCED, and an ingest that could not work
1814    // that out logs the reason rather than a plausible zero, because a push of
1815    // nothing and a push whose contents could not be enumerated are different
1816    // facts.
1817    let objects_pushed: &[ObjectId] = outcome.new_objects.as_deref().unwrap_or(&[]);
1818    let objects_unknown = outcome.new_objects.as_ref().err().map(String::as_str);
1819    // Two different numbers, both worth having: `wire_bytes` is what the client
1820    // sent and is what a `max_pack_bytes` limit is enforced against;
1821    // `pack_bytes` is what landed, which is larger for a thin push because
1822    // completing it writes bases the client did not send.
1823    //
1824    // Neither may fall back to `0`. `PushFacts::pack_bytes` is `Option` on
1825    // purpose — "`Some(0)` for a deletions-only push … `None` only if the count
1826    // is genuinely unavailable" — and `unwrap_or(0)` threw away exactly the
1827    // distinction the type exists to carry. `stat` failing on a pack whose
1828    // bytes are then reported as `0` is the same disease: an absence that
1829    // renders as a value (and the `objects_unknown` field above).
1830    let (wire_bytes, wire_bytes_unknown) = measured(
1831        outcome.pack_bytes,
1832        "the ingest did not report how many packfile bytes were read off the wire",
1833    );
1834    let (pack_bytes, pack_bytes_unknown) = measured_pack_bytes(&outcome);
1835    if refs_rejected == 0 && outcome.unpack.is_ok() {
1836        metrics.add_pushes_accepted(1);
1837    } else {
1838        metrics.add_pushes_rejected(1);
1839    }
1840    tracing::info!(
1841        target: TARGET_GIT,
1842        event = "git.receive_pack.served",
1843        refs_ok,
1844        refs_rejected,
1845        atomic = outcome.atomic,
1846        unpack_ok = outcome.unpack.is_ok(),
1847        objects = objects_pushed.len(),
1848        objects_unknown,
1849        wire_bytes,
1850        wire_bytes_unknown,
1851        pack_bytes,
1852        pack_bytes_unknown,
1853        push_options = outcome.push_options.len(),
1854        elapsed_ms = started.elapsed().as_millis() as u64,
1855        "served git-receive-pack"
1856    );
1857
1858    // ── the znippy engine took the objects at ingest ───────────────────────────
1859    //
1860    // There is no second step here any more, and its absence is the change.
1861    //
1862    // What used to sit at this line read the `.pack` and `.idx` that
1863    // `index_thin_pack` had written and appended them to an archive — so the
1864    // tier was a COPY of a filesystem write that had already happened, and the
1865    // packfile it copied from stayed in `objects/pack` for ever. That is what
1866    // made every push pay for git's on-disk format even on a server whose
1867    // storage is Arrow. `PushIngest::ingest_cold` now stores the bytes as they
1868    // arrive and no packfile is written at all.
1869    //
1870    // The `refs_ok > 0` guard that used to wrap this is gone with it, and its
1871    // reasoning does not transfer: it existed because archiving a
1872    // fully-rejected push would make the tier the only place that push
1873    // survived. Storage is no longer a place a push can survive *separately* —
1874    // it is where the push went — so a rejected ref list now leaves exactly
1875    // what a rejected ref list leaves on any git server: objects nothing names,
1876    // for `gunnar gc` to collect.
1877
1878    // ── push attestation ───────────────────────────────────────────────────
1879    //
1880    // Built from what the push actually did, not from what it asked for: a
1881    // refused reference is not in `ref_updates`, so an attestation can never
1882    // claim a reference moved that did not.
1883    //
1884    // The `Result` goes in whole. Handing `objects_pushed` — which is the `Err`
1885    // arm already flattened to `&[]` for the log line above — is what made the
1886    // record claim an empty object list for every push into a repository with
1887    // no policy gates.
1888    let attested_objects = outcome.new_objects.as_deref().map_err(String::as_str);
1889    if let Some(attestation) = attestation_for(
1890        request,
1891        &outcome,
1892        attested_objects,
1893        &fingerprint,
1894        &credential,
1895    ) {
1896        tracing::info!(
1897            target: TARGET_GIT,
1898            event = "git.attestation",
1899            refs = attestation.ref_updates.len(),
1900            objects = attestation.new_object_count,
1901            // Absent from the line when the set WAS enumerated (`Option`
1902            // records nothing for `None`), so the field's presence is the fact.
1903            oids_unknown = (!attestation.new_object_oids_unknown.is_empty())
1904                .then_some(attestation.new_object_oids_unknown.as_str()),
1905            pack = %attestation.pack_hash,
1906            key_fingerprint = %attestation.key_fingerprint,
1907            // The field an HTTP push can be attributed by. `key_fingerprint`
1908            // above is empty on that transport (GUN-014).
1909            credential = %attestation.credential,
1910            "push attestation"
1911        );
1912        if let Err(err) = attestations.append(&attestation) {
1913            // the attestation's durable record failed, and the push has already been
1914            // reported to the client as done. Saying so loudly is the only
1915            // correct action; failing here would make gunnar and the client
1916            // disagree about what is in the repository.
1917            tracing::error!(
1918                target: TARGET_GIT,
1919                path = %attestations.path().display(),
1920                %err,
1921                "the push attestation could not be appended to the log; the push itself stands"
1922            );
1923        }
1924        if let Some(bus) = bus {
1925            bus.publish(event::Payload::PushAttestation(attestation));
1926        }
1927    }
1928    Ok(())
1929}
1930
1931// ───────────────────────────────────────────────────────────────────────────
1932// Measurements, and the absence of one
1933// ───────────────────────────────────────────────────────────────────────────
1934
1935/// A measurement split into **the number** and **why there isn't one**.
1936///
1937/// `tracing`'s `Value` impl for `Option<T>` records nothing at all for `None`,
1938/// so the numeric field is *absent* from the line when the measurement is,
1939/// rather than present and zero. That is the whole point. A reader who sees
1940/// `pack_bytes=0` has to be able to conclude the push carried no bytes, and
1941/// they cannot if `0` is also what a failed `stat` prints.
1942///
1943/// The same shape as `objects` / `objects_unknown` on the line above it:
1944/// one field for the fact, one for the reason it is missing, never
1945/// a plausible default standing in for either.
1946fn measured(value: Option<u64>, why: &str) -> (Option<u64>, Option<String>) {
1947    match value {
1948        Some(bytes) => (Some(bytes), None),
1949        None => (None, Some(why.to_owned())),
1950    }
1951}
1952
1953/// How many bytes the pack that landed occupies, or why that is unknown.
1954///
1955/// Three different absences used to fold into one `0`:
1956///
1957/// * the push carried no pack at all, because it was deletions only;
1958/// * the pack was indexed without a directory, so nothing was written;
1959/// * `stat` failed on a path that *was* written.
1960///
1961/// Only the first is a zero, and it is a **measured** one. The other two are
1962/// unknowns and now say so, with the path in the reason: an operator chasing a
1963/// pack that is not where the server thinks it is needs the path more than they
1964/// need a number. This is a server log, not a client-facing status line, so the
1965/// path belongs here (the reverse mistake — a server path on the wire to
1966/// `git push` — was fixed separately).
1967fn measured_pack_bytes(outcome: &PushOutcome) -> (Option<u64>, Option<String>) {
1968    let Some(pack) = outcome.pack.as_ref() else {
1969        // Deletions only: nothing was written because nothing was sent.
1970        return (Some(0), None);
1971    };
1972    let Some(path) = pack.data_path.as_deref() else {
1973        // No file — which is the ordinary case, not a degraded one: the znippy
1974        // engine appends the pack verbatim into its archive and writes no
1975        // `.pack`. The store counted what it appended, so the measurement is
1976        // right here and only an engine that reported neither is an unknown.
1977        return match pack.stored_bytes {
1978            Some(bytes) => (Some(bytes), None),
1979            None => (
1980                None,
1981                Some(format!(
1982                    "pack {} was indexed without a directory and the store reported no size, so \
1983                     there is nothing to measure",
1984                    pack.pack_id
1985                )),
1986            ),
1987        };
1988    };
1989    match std::fs::metadata(path) {
1990        Ok(meta) => (Some(meta.len()), None),
1991        Err(err) => (
1992            None,
1993            Some(format!("{} could not be stat'ed: {err}", path.display())),
1994        ),
1995    }
1996}
1997
1998/// How many objects a push introduced, for the attestation record.
1999///
2000/// **The count never depends on the list.** `Ok(ids)` is an enumerated set and
2001/// its length is the count, empty included — a push that introduced nothing is
2002/// a measured zero. `Err(_)` is "the set was never worked out", and the count
2003/// then comes off the stored pack's own 12-byte header, which is known either
2004/// way. Zero only for a push that carried no pack at all.
2005fn attested_object_count(
2006    new_objects: std::result::Result<&[ObjectId], &str>,
2007    outcome: &PushOutcome,
2008) -> u64 {
2009    match new_objects {
2010        Ok(ids) => ids.len() as u64,
2011        Err(_) => outcome
2012            .pack
2013            .as_ref()
2014            .map(|pack| u64::from(pack.num_objects))
2015            .unwrap_or(0),
2016    }
2017}
2018
2019/// The push attestation record for one push, or `None` when nothing moved.
2020///
2021/// A push in which every reference was refused produces `PushRejected` events
2022/// and no attestation: there is no provenance to attest to, and a record with
2023/// an empty `ref_updates` would be indistinguishable from a bug in this
2024/// function.
2025fn attestation_for(
2026    request: &ServiceRequest,
2027    outcome: &PushOutcome,
2028    new_objects: std::result::Result<&[ObjectId], &str>,
2029    fingerprint: &str,
2030    credential: &str,
2031) -> Option<PushAttestation> {
2032    let ref_updates: Vec<gunnar_proto::RefUpdate> = outcome
2033        .commands
2034        .iter()
2035        .zip(&outcome.statuses)
2036        .filter(|(_, status)| status.is_ok())
2037        .map(|(command, _)| gunnar_proto::RefUpdate {
2038            ref_name: command.name.to_string(),
2039            old_oid: command.old.to_string(),
2040            new_oid: command.new.to_string(),
2041        })
2042        .collect();
2043    if ref_updates.is_empty() {
2044        return None;
2045    }
2046
2047    // The pack as gunnar stored it. `pack_hash` is that pack's own trailing
2048    // checksum — the `<id>` in `pack-<id>.pack` — in the repository's hash
2049    // kind, so it is verifiable against the file on disk. It is deliberately
2050    // NOT a hash of the bytes the client sent: `index_thin_pack` completes a
2051    // thin pack from the store before writing it, so the received bytes and
2052    // the stored pack are different objects and only one of them still exists.
2053    //
2054    // **Through `measured_pack_bytes`, not through a `stat` of its own.** This
2055    // used to `std::fs::metadata(data_path).ok()…unwrap_or(0)`, which on an
2056    // engine that writes no packfile is `None.unwrap_or(0)` — so every
2057    // attestation this server produced said the push stored zero bytes. Same
2058    // disease as the empty object list below, in the same record, one field
2059    // along. The znippy store writes no pack FILE (`data_path: None`) but it
2060    // counts the bytes it appends, and that count is exactly what
2061    // `measured_pack_bytes` was taught to prefer over an absent file — one
2062    // function, so the log line and the durable record can never disagree
2063    // about how big a push was.
2064    //
2065    // The last resort is the wire count (`PushOutcome::pack_bytes`), for an
2066    // engine that reports neither a file nor a size: a byte count that could
2067    // not be established at all stays `0` here because the proto has no third
2068    // state for it, and `new_object_oids_unknown` is the only field that can
2069    // say the record is partial. What is gone is the case where the count was
2070    // *available* and thrown away for want of a file.
2071    let (pack_hash, pack_bytes) = match &outcome.pack {
2072        Some(pack) => {
2073            let bytes = measured_pack_bytes(outcome)
2074                .0
2075                .or(outcome.pack_bytes)
2076                .unwrap_or(0);
2077            (pack.pack_id.to_string(), bytes)
2078        }
2079        // A deletions-only push carries no pack at all, and that is a fact
2080        // worth attesting rather than a reason to skip the record.
2081        None => (String::new(), 0),
2082    };
2083    // ── the object set, and the reason there isn't one ──────────────────────
2084    //
2085    // The set is not free: on the znippy engine `oids_in_extent` WAITS for the
2086    // indexer to absorb the pack, and that wait is what the deferred indexer
2087    // exists to avoid paying on the push path (`znippy_engine::ingest`'s "why
2088    // this is a flag" note). So `PushIngest` says how badly it wants the ids
2089    // rather than always asking — `ObjectSet::Required` when a policy gate
2090    // reads them (`gate.engine` non-empty), `ObjectSet::Wanted` when only this
2091    // record does, and `ObjectSet::NotNeeded` under `--no-attest-objects`.
2092    // Where the set is not produced — an operator who bought the latency back,
2093    // or an engine that cannot enumerate for a `Wanted` — the ingest says so
2094    // with `Err(reason)` rather than an empty set.
2095    //
2096    // That `Err` used to be flattened to `&[]` by the caller. The record then
2097    // carried an empty list, and an empty list is what a push of zero objects
2098    // carries too, so the two were indistinguishable — and the fix before this
2099    // one made it worse in the honest direction only halfway: the COUNT became
2100    // the pack's, so a reader saw "3 objects" beside a list of none and no word
2101    // about why. The reason travels now, in its own field, and the record says
2102    // what it knows and no more (measured, and never a plausible value where an absence belongs).
2103    let new_object_count = attested_object_count(new_objects, outcome);
2104    let (new_object_oids, new_object_oids_unknown) = match new_objects {
2105        Ok(ids) => (ids.iter().map(ObjectId::to_string).collect(), String::new()),
2106        Err(reason) => (Vec::new(), reason.to_owned()),
2107    };
2108
2109    let received_at_unix_ms = gunnar_grpc::bus::now_unix_ms();
2110    Some(PushAttestation {
2111        // Unique per push: the stored pack's identity is unique to the bytes,
2112        // and the millisecond disambiguates the same pack pushed twice (and a
2113        // deletions-only push, which has no pack at all).
2114        attestation_id: format!(
2115            "{}@{received_at_unix_ms}/{}",
2116            request.repo.key(),
2117            if pack_hash.is_empty() {
2118                "no-pack"
2119            } else {
2120                &pack_hash
2121            }
2122        ),
2123        repo: Some(RepoPath::new(&request.repo.namespace, &request.repo.name)),
2124        principal_id: request.principal.id.clone(),
2125        principal_name: request.principal.name.clone(),
2126        key_fingerprint: fingerprint.to_owned(),
2127        // Answerable on both transports, unlike the fingerprint above
2128        // (GUN-014). A verifier reading an older record sees an empty string,
2129        // which is what it saw before this field existed.
2130        credential: credential.to_owned(),
2131        hash_kind: crate::control_plane::hash_kind(request.repo.hash_kind) as i32,
2132        ref_updates,
2133        // The list is never a partial one: it is either the set the store
2134        // enumerated or empty beside a reason that says why nobody asked.
2135        // `new_object_oids_unknown` is what a reader honours; a reader that
2136        // ignores it sees a count that still came from the pack's own header
2137        // rather than from a list that was never built.
2138        new_object_count,
2139        new_object_oids,
2140        new_object_oids_unknown,
2141        new_object_bytes: pack_bytes,
2142        pack_hash,
2143        received_at_unix_ms,
2144        server_agent: gunnar_proto::AGENT.to_owned(),
2145        // The no-OS rule: git traffic has exactly one transport, and this module is the
2146        // SSH one. Not read from the request, because there is nothing else it
2147        // could be, and a field that can only ever hold one value should say
2148        // which one rather than sit empty.
2149        transport: "ssh".to_owned(),
2150    })
2151}
2152
2153/// The SHA-256 fingerprint of the key this push authenticated with, when that
2154/// can be known without guessing.
2155///
2156/// The key that authenticated this session, for the attestation and the events.
2157///
2158/// # This used to be a guess, and the guess was usually wrong
2159///
2160/// The cross-crate gap is closed: `gunnar-ssh` computes the fingerprint in
2161/// `auth_publickey`, looks the principal up by it, and now **carries it** on
2162/// [`ServiceRequest`] instead of dropping it.
2163///
2164/// What this replaced read the identity table back and answered only when the
2165/// principal held **exactly one key** — so a machine account with a rotation
2166/// key, or any human with a laptop key and a desktop key, published an empty
2167/// fingerprint on every `RefUpdated`, every `RefDeleted` and every attestation.
2168/// "Which key pushed this" was therefore unanswerable for precisely the
2169/// accounts an audit trail exists for. Reading it back could never have been
2170/// made correct: the table cannot say which of two keys made a connection.
2171///
2172/// # An empty string here does NOT mean "nobody" (GUN-014)
2173///
2174/// It used to say so: *"an empty string means no key, and now means it for one
2175/// reason only — the anonymous, read-only path, which authenticates with no key
2176/// at all. That path cannot push, so it cannot reach an attestation."* That was
2177/// false for smart HTTP. HTTP authenticates with a **token**, so there is no
2178/// key and `ServiceRequest::key_fingerprint` is `None` on every HTTP request
2179/// including `git-receive-pack` — and the empty string went into `RefUpdated`,
2180/// `RefDeleted` and the attestation with nothing else on the record naming who
2181/// pushed. Two HTTP pushes by two different people were the same audit line.
2182///
2183/// The field is still exactly what it says: the SSH key, when there was one.
2184/// The question "which credential" is [`session_credential`]'s.
2185fn session_key_fingerprint(request: &ServiceRequest) -> String {
2186    match &request.key_fingerprint {
2187        Some(fingerprint) => fingerprint.as_str().to_owned(),
2188        None => {
2189            tracing::debug!(
2190                target: TARGET_GIT,
2191                principal = %request.principal.id,
2192                "no key fingerprint on this request: the session authenticated anonymously"
2193            );
2194            String::new()
2195        }
2196    }
2197}
2198
2199/// **Which credential made this request**, as the audit trail names it:
2200/// `key:SHA256:…`, `token:<hash prefix>`, or empty for a caller that presented
2201/// none.
2202///
2203/// The field [`session_key_fingerprint`] could not be (GUN-014). It is derived
2204/// here rather than spelled by each front door so the two transports cannot
2205/// come to write the prefix differently: SSH already carries the fingerprint
2206/// and gets `key:` added to it; HTTP has no key and carries
2207/// [`ServiceRequest::credential`] instead.
2208///
2209/// Empty now genuinely has one meaning — no credential — and the anonymous read
2210/// path is the only thing that produces it, which is what the old note claimed
2211/// and this makes true.
2212fn session_credential(request: &ServiceRequest) -> String {
2213    match (&request.key_fingerprint, &request.credential) {
2214        (Some(fingerprint), _) => format!("key:{}", fingerprint.as_str()),
2215        (None, Some(credential)) => credential.clone(),
2216        (None, None) => String::new(),
2217    }
2218}
2219
2220// `pack_object_ids` stood here: it opened a written `.idx` with
2221// `gix_pack::index::File::at` and collected every oid in it. It went with
2222// `index_thin_pack`, which is the only thing that ever wrote an `.idx` for it
2223// to read — a push is a verbatim `put_pack` into the engine now, and the ids a
2224// push introduced come from `PushReceipt::new_oids` off the engine's own index.
2225//
2226// It carried the last two live bumps of `gunnar_pack::oidprobe`'s
2227// `GIX_PACK_INDEX_OPEN` / `GIX_PACK_INDEX_ITER`, which `bench/examples/
2228// oidlookup-probe.rs` reports. Deleting it changes **no measurement**: the
2229// function had no caller, so those counters were already zero in every run that
2230// could be taken. A counter that reads 0 because the path is gone is the truth;
2231// keeping an uncalled function alive to hold a probe open would be the other
2232// way round — an instrument attached to code that cannot execute.
2233
2234/// Taking a pushed pack into `objects/pack`, and saying which objects it
2235/// **introduced**.
2236///
2237/// # The subtraction, and why it is the only honest source
2238///
2239/// A gate cannot enforce a quota against a set that includes objects the
2240/// repository already held. This used to hand the gate every id in the written
2241/// `.idx`, which for a thin push is the client's objects *plus* the bases
2242/// `index_thin_pack` fetched from the store to complete it — so a
2243/// `max_objects` gate refused pushes it need not have, and no amount of
2244/// re-reading the index afterwards could tell the two apart, because by then
2245/// they are the same file.
2246///
2247/// The distinction exists in exactly one place: the `gix_object::Find` that
2248/// resolution asks for a missing base. Every id it is asked for and finds is,
2249/// by definition, an object the repository already had. So the lookup is
2250/// wrapped, the ids it served are recorded, and
2251///
2252/// ```text
2253///     introduced  =  everything in the .idx  −  the bases we handed back
2254/// ```
2255///
2256/// is exact rather than conservative, and needs no second enumeration of
2257/// anything (one implementation).
2258/// **Where a pushed or fetched pack is framed**: the box's transaction log, and the catalog
2259/// key and hash kind the twin's apply opens the repository by (`gunnar_core::txlog`).
2260#[derive(Clone)]
2261pub(crate) struct PackFrame {
2262    log: Arc<gunnar_core::txlog::Log>,
2263    repo: String,
2264    hash: String,
2265}
2266
2267impl PackFrame {
2268    /// The frame for `repo` on `stores`.
2269    pub(crate) fn of(stores: &crate::stores::ServerStores, repo: &gunnar_core::Repo) -> PackFrame {
2270        PackFrame {
2271            log: Arc::clone(stores.txlog()),
2272            repo: repo.key(),
2273            hash: repo.hash_kind.to_string(),
2274        }
2275    }
2276
2277    /// These exact bytes, as a `Pack` frame, durable before this returns.
2278    fn append(&self, bytes: &[u8]) -> anyhow::Result<()> {
2279        let meta = gunnar_core::txlog::Meta::Pack {
2280            repo: self.repo.clone(),
2281            hash: self.hash.clone(),
2282        };
2283        self.log
2284            .append(gunnar_core::txlog::Kind::Pack, &meta, bytes)?;
2285        Ok(())
2286    }
2287}
2288
2289pub(crate) struct PushIngest<'a> {
2290    git_dir: PathBuf,
2291    hash_kind: gix_hash::Kind,
2292    /// Which engine stores this push. Carried on the value rather than read
2293    /// from a global, because the process's engine is a field on
2294    /// [`ServerStores`](crate::stores::ServerStores) now and a push is served
2295    /// out of one of those.
2296    engine: crate::storage::Engine,
2297    /// How badly this push needs the object set it introduced: a policy gate
2298    /// that reads the objects cannot decide without it, the push attestation
2299    /// would rather have it than apologise for it, and an operator can decline
2300    /// it altogether. Asking for it waits on index work the ack path defers on
2301    /// purpose, which is why this is three answers and not a `bool`.
2302    objects: crate::storage::ObjectSet,
2303    /// **Where the running object count goes, or `None`.**
2304    ///
2305    /// The numerator of a transfer's progress, and the reason it is on the
2306    /// *ingest* rather than on `gunnar-wire`'s `FetchProgress` beside the
2307    /// denominator: the count exists only inside the engine's pack walk, and
2308    /// there is exactly one of those (one implementation). `fetch_pack` reads the header and
2309    /// can publish `objects_total` because twelve bytes are all that costs;
2310    /// counting the objects themselves would be a second decoder, which its own
2311    /// module doc refuses. So the two halves of one progress pair are published
2312    /// from two places, and this is the half storage owns.
2313    watching: Option<&'a dyn git_storage_trait::PackProgress>,
2314    /// **The transaction log this pack is a frame in**, or `None` (a test, a tool). Handed
2315    /// to `Engine::ingest_push` as `before_store`: the pack's exact bytes are a `Pack` frame
2316    /// before the engine stores them — this path never passes the `OpenRepo::logged` tee.
2317    frame: Option<PackFrame>,
2318    /// **An encrypted repository's objects, or `None`** (crypt-level2 item 4,
2319    /// 2026-10-06): when set, the pushed pack is checked in quarantine
2320    /// ([`crate::encrypted_quarantine`]) BEFORE it is framed or stored — these
2321    /// are the bases a thin pack deltas against. A refusal keeps nothing.
2322    encrypted: Option<&'a dyn git_storage_trait::GitServe>,
2323    /// **The data set's space ledger**, or `None` (a test, a tool, a server
2324    /// whose doors were never wired): every chunk of the pack is admitted
2325    /// against it BEFORE it is read, and a pack that does not fit beside what
2326    /// is already arriving is refused by name — never failed by a full disk
2327    /// mid-receive (cov-13, [`crate::push_space`]).
2328    space: Option<Arc<gunnar_ssh::SpaceLedger>>,
2329}
2330
2331impl<'a> PushIngest<'a> {
2332    /// **The ingest a MIRROR lands its fetched pack through** — this one, the
2333    /// push's own, and not a second sink.
2334    ///
2335    /// `fetch_pack`'s module doc states the principle it is built on: *a
2336    /// fetched pack and a pushed pack are the same bytes*, so both land through
2337    /// the identical [`gunnar_wire::PackIngest`] and znippy implements it once.
2338    /// A private constructor rather than a public struct because the fields are
2339    /// this module's business; what a mirror needs to say is the four facts
2340    /// below.
2341    pub(crate) fn new(
2342        git_dir: PathBuf,
2343        hash_kind: gix_hash::Kind,
2344        engine: crate::storage::Engine,
2345        objects: crate::storage::ObjectSet,
2346        watching: Option<&'a dyn git_storage_trait::PackProgress>,
2347        frame: Option<PackFrame>,
2348    ) -> Self {
2349        PushIngest {
2350            git_dir,
2351            hash_kind,
2352            engine,
2353            objects,
2354            watching,
2355            frame,
2356            // A mirror's fetched pack is not checked here: the repository it
2357            // lands in is the mirror's own (an encrypted repository is not a
2358            // mirror target in Level 1).
2359            encrypted: None,
2360            space: None,
2361        }
2362    }
2363
2364    /// Hold the pack to the data set's room — `stores.space_ledger()`.
2365    pub(crate) fn with_space(mut self, space: Option<Arc<gunnar_ssh::SpaceLedger>>) -> Self {
2366        self.space = space;
2367        self
2368    }
2369
2370    /// **The push, stored without a packfile.**
2371    ///
2372    /// The bytes go from the wire into the znippy engine's own storage and nothing
2373    /// is written to `objects/pack` — no `.pack`, no `.idx`, no `.keep`. That is
2374    /// the whole point of it, and it is why this does not call
2375    /// [`index_thin_pack`]: `gix_pack::Bundle::write_to_directory` takes a
2376    /// directory, so every route through it puts a git packfile on disk before
2377    /// the tier ever sees the bytes, and the tier's copy is then a second one.
2378    ///
2379    /// # What is given up, said out loud
2380    ///
2381    /// `index_thin_pack` **resolved** the pack: it completed thin deltas against
2382    /// the store and produced a real `.idx`. This does not. The closure check
2383    /// inside `put_pack` refuses a pack whose bases are missing, which is the
2384    /// correctness half, but the pack stays thin and stays verbatim — the whole
2385    /// claim of *verbatim is the truth*. A reader resolves chains on the way
2386    /// out instead.
2387    ///
2388    /// # The `.keep` that is not there
2389    ///
2390    /// `index_thin_pack` wrote one and the caller dropped it once the refs
2391    /// landed, guarding the new pack from a concurrent `gc` in the window
2392    /// between. There is no window here: `put_pack` returns only after the
2393    /// journal row is fsynced, and `gc` reads that journal. The marker's job is
2394    /// done by the ordering rather than by a file.
2395    fn ingest_engine(&self, pack: &mut dyn BufRead) -> gunnar_wire::Result<PackReceipt> {
2396        // ── space admission (cov-13) ─────────────────────────────────────
2397        //
2398        // The promise this pack holds on /data: spool + store, + the log's
2399        // frame when it is framed. Given back copy by copy as each lands (the
2400        // spool at the reader's `consume`, the frame in the hook below, the
2401        // store when this function returns and the hold drops).
2402        let hold = self.space.as_ref().map(|ledger| {
2403            ledger.hold(if self.frame.is_some() {
2404                gunnar_ssh::PUSH_COPIES
2405            } else {
2406                gunnar_ssh::PUSH_COPIES - 1
2407            })
2408        });
2409        let mut gated = None;
2410        let reader: &mut dyn BufRead = match &hold {
2411            Some(hold) => gated.insert(crate::push_space::Gated::new(
2412                pack,
2413                hold,
2414                crate::storage::SPOOL_ABOVE as u64,
2415            )),
2416            None => pack,
2417        };
2418        let hook = |bytes: &[u8]| -> anyhow::Result<()> {
2419            // ★ Before the frame and before the store: an encrypted
2420            // repository's pack is checked in quarantine, and a refusal keeps
2421            // nothing — not on this disk, not in the log the twin replays.
2422            #[cfg(feature = "engine-znippy")]
2423            if let Some(repo) = self.encrypted {
2424                let checked = crate::encrypted_quarantine::check(bytes, self.hash_kind, repo)?;
2425                tracing::info!(
2426                    target: TARGET_STORE,
2427                    event = "store.encrypted.quarantine_passed",
2428                    objects = checked,
2429                    "an encrypted repository's pushed pack passed its check in quarantine; it is framed and stored now"
2430                );
2431            }
2432            match &self.frame {
2433                Some(frame) => {
2434                    frame.append(bytes)?;
2435                    // On the disk now: the frame's promise is `statvfs`'s.
2436                    if let Some(hold) = &hold {
2437                        hold.realize(bytes.len() as u64);
2438                    }
2439                    Ok(())
2440                }
2441                None => Ok(()),
2442            }
2443        };
2444        let hooked = self.frame.is_some() || self.encrypted.is_some();
2445        // The mutation gate spans frame through store (`Log::mutation`): a snapshot's freeze
2446        // waits for this pack to be in the engine before it reads the head.
2447        let _framed = self.frame.as_ref().map(|f| f.log.mutation());
2448        let stored = self.engine.ingest_push(
2449            self.git_dir.as_path(),
2450            self.hash_kind,
2451            reader,
2452            self.objects,
2453            self.watching,
2454            hooked.then_some(&hook as &dyn Fn(&[u8]) -> anyhow::Result<()>),
2455        );
2456        // ★ Cut for room: the ledger's line, verbatim, whatever the ingest made
2457        // of the cut stream. Its spool is already gone (the failed read dropped
2458        // it), nothing was framed, nothing stored, and its promise is given back.
2459        let consumed_by_cut = gated
2460            .as_ref()
2461            .map(|g| (g.cut().map(str::to_owned), g.consumed()));
2462        drop(gated);
2463        if let Some((Some(line), sent)) = consumed_by_cut.clone() {
2464            tracing::warn!(
2465                target: TARGET_STORE,
2466                event = "store.push.no_room",
2467                pack_bytes = sent,
2468                refusal = %line,
2469                "a pushed pack was refused for room mid-receive: it did not fit beside the \
2470                 pushes already arriving; its spool is deleted and nothing of it was kept"
2471            );
2472            return Err(gunnar_wire::Error::Refused(line));
2473        }
2474        let received = stored
2475            .map_err(|err| {
2476                // ★ The disk filled ANYWAY (a writer outside the ledger): said in
2477                // the ledger's words, never `uring: op failed: Operation canceled`.
2478                if let Some(hold) = &hold {
2479                    let freed = consumed_by_cut.as_ref().map_or(0, |(_, sent)| *sent);
2480                    if crate::push_space::is_space_failure(&err, || hold.at_the_floor(freed)) {
2481                        let line = hold.full_now();
2482                        tracing::warn!(
2483                            target: TARGET_STORE,
2484                            event = "store.push.disk_full",
2485                            error = %format!("{err:#}"),
2486                            refusal = %line,
2487                            "the store failed a pushed pack because the data set filled; told by name"
2488                        );
2489                        return gunnar_wire::Error::Refused(line);
2490                    }
2491                }
2492                #[cfg(feature = "engine-znippy")]
2493                if let Some(refused) = crate::encrypted_quarantine::refusal_in(&err) {
2494                    tracing::info!(
2495                        target: TARGET_STORE,
2496                        event = "store.encrypted.quarantine_refused",
2497                        reason = %refused,
2498                        "an encrypted repository's pushed pack was refused in quarantine: nothing was framed or stored"
2499                    );
2500                    return gunnar_wire::Error::Refused(refused);
2501                }
2502                // `{err:#}`, not `{err}`. `anyhow`'s plain `Display` prints the
2503                // OUTERMOST context and nothing under it, so a failure three
2504                // `.context()` calls deep arrived as "storing the pushed pack:
2505                // enumerating the objects this push introduced" — the two
2506                // labels this code added, and never the cause the store gave.
2507                // The alternate form joins the whole chain, which is the only
2508                // version of this line an operator can act on.
2509                gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
2510                    "storing the pushed pack: {err:#}"
2511                )))
2512            })?;
2513
2514        let pack_id = gix_hash::ObjectId::try_from(received.pack_id.as_slice()).map_err(|_| {
2515            gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
2516                "the stored pack's trailer is {} bytes, which is not a {} object id",
2517                received.pack_id.len(),
2518                self.hash_kind
2519            )))
2520        })?;
2521
2522        tracing::info!(
2523            target: TARGET_STORE,
2524            // Renamed from `store.cold_tier.ingested` 2026-08-14. The cold tier
2525            // was purged and this event outlived it, so every push in the log
2526            // still announced a tier that does not exist. Nothing asserted on
2527            // the old name — only a doc-comment example, corrected with it.
2528            event = "store.znippy.ingested",
2529            objects = received.num_objects,
2530            bytes = received.pack_bytes,
2531            elapsed_ms = received.elapsed_ms,
2532            object_set = received.new_oids.is_some(),
2533            "the push was stored in the znippy-git store; no packfile was written"
2534        );
2535
2536        let new_objects = match received.new_oids {
2537            Some(oids) => oids
2538                .iter()
2539                .map(|raw| {
2540                    gix_hash::ObjectId::try_from(raw.as_slice())
2541                        .map_err(|_| format!("the store returned a {}-byte object id", raw.len()))
2542                })
2543                .collect::<std::result::Result<Vec<_>, _>>(),
2544            // Not "no objects" — see `ColdReceipt::new_oids`. A gate reading
2545            // this refuses, which is the correct answer to "I was never asked
2546            // to find out".
2547            None => Err(
2548                "the policy has no gate over objects, so the object set was not \
2549                         enumerated"
2550                    .to_owned(),
2551            ),
2552        };
2553
2554        Ok(PackReceipt {
2555            indexed: gunnar_wire::pack::IndexedPack {
2556                num_objects: received.num_objects,
2557                pack_id,
2558                // There is no index file, and `ObjectId::null` is this tree's
2559                // existing spelling for that — the same value the git-store
2560                // bench server and the store's own end-to-end test use.
2561                index_id: gix_hash::ObjectId::null(self.hash_kind),
2562                pack_version: gunnar_wire::pack::PackVersion::from_u32(received.version),
2563                hash_kind: self.hash_kind,
2564                // The three paths that no longer exist. `None` is not a
2565                // degraded answer here: it is the fact, and `cold_tier_append`
2566                // used to read exactly these two to build its second copy.
2567                index_path: None,
2568                data_path: None,
2569                keep_path: None,
2570                // …but the SIZE is not missing with them. The store counted the
2571                // bytes it appended, and until this line nobody carried the
2572                // number out, so `measured_pack_bytes` fell through to "there
2573                // is nothing on disk to measure" on every push into the engine
2574                // the appliance ships. There is no file; there is a
2575                // measurement. Nothing on disk has to be stat'ed to learn it,
2576                // and on this engine nothing on disk could be.
2577                stored_bytes: Some(received.pack_bytes),
2578            },
2579            new_objects,
2580        })
2581    }
2582}
2583
2584impl gunnar_wire::PackIngest for PushIngest<'_> {
2585    /// Straight into the engine. There is no second arm.
2586    ///
2587    /// What stood beside this ran `index_thin_pack` — `gix_pack::Bundle::
2588    /// write_to_directory` — writing a real `.pack`, `.idx` and `.keep` into
2589    /// `objects/pack`, reading the `.idx` back to learn which objects arrived,
2590    /// and dropping the `.keep` afterwards. Every line of it was the packfile
2591    /// engine's, and the engine is amputated.
2592    fn ingest(&self, pack: &mut dyn BufRead) -> gunnar_wire::Result<PackReceipt> {
2593        self.ingest_engine(pack)
2594    }
2595}
2596
2597/// Did this reference move to a tip that does **not** descend from the one it
2598/// held? The event stream's `RefUpdated.forced`.
2599///
2600/// # This is one ancestry walk, not a second one
2601///
2602/// [`gunnar_policy::ancestry::is_descendant_of`] is the same function
2603/// `BranchProtectionGate` decides a non-fast-forward with, and it is the one
2604/// cross-checked against `git merge-base --is-ancestor` in that crate's tests.
2605/// Re-deriving the answer here — `old != new`, a hex compare, a first-parent
2606/// walk — is the mistake that gate's module docs enumerate, and two
2607/// implementations that disagree would put a UI and a policy on different sides
2608/// of the same push (one implementation).
2609///
2610/// The gate cannot simply be asked, because it only runs where a protection
2611/// rule matches. The audit trail has to answer for **every** reference on every
2612/// repository, including the ones nothing protects, which is the case where
2613/// nobody is watching.
2614///
2615/// # An undecidable walk reports `true`
2616///
2617/// A missing object or an exhausted budget is not evidence of a fast-forward.
2618/// `BranchProtectionGate` refuses on undecided rather than allowing; the same
2619/// judgement here means flagging rather than clearing, with the reason logged at
2620/// `warn`. Reporting `false` for a walk that never finished is exactly the
2621/// wrong-answer this field is being fixed for.
2622fn was_forced(git: &dyn GitServe, command: &RefCommand) -> bool {
2623    // A create has no previous tip, and a delete has no new one. Neither is a
2624    // rewrite of anything.
2625    if command.is_create() || command.is_delete() {
2626        return false;
2627    }
2628    match gunnar_policy::ancestry::is_descendant_of(
2629        git,
2630        &command.new,
2631        &command.old,
2632        gunnar_policy::ancestry::DEFAULT_ANCESTRY_BUDGET,
2633    ) {
2634        Ok(descends) => !descends,
2635        Err(err) => {
2636            tracing::warn!(
2637                target: TARGET_POLICY,
2638                ref_name = %command.name,
2639                old = %command.old,
2640                new = %command.new,
2641                %err,
2642                "the ancestry walk behind RefUpdated.forced could not be completed; the update \
2643                 is published as forced, because `not forced` is a claim this could not make"
2644            );
2645            true
2646        }
2647    }
2648}
2649
2650#[allow(clippy::too_many_arguments)]
2651fn publish(
2652    bus: &EventBus,
2653    request: &ServiceRequest,
2654    command: &RefCommand,
2655    status: &RefStatus,
2656    atomic: bool,
2657    fingerprint: &str,
2658    credential: &str,
2659    gates: &HashMap<String, String>,
2660    forced: bool,
2661) {
2662    let repo = Some(RepoPath::new(&request.repo.namespace, &request.repo.name));
2663    let payload = match status {
2664        RefStatus::Ok if command.is_delete() => event::Payload::RefDeleted(RefDeleted {
2665            repo,
2666            ref_name: command.name.to_string(),
2667            old_oid: command.old.to_string(),
2668            principal_id: request.principal.id.clone(),
2669            key_fingerprint: fingerprint.to_owned(),
2670            credential: credential.to_owned(),
2671        }),
2672        RefStatus::Ok => event::Payload::RefUpdated(RefUpdated {
2673            repo,
2674            ref_name: command.name.to_string(),
2675            old_oid: command.old.to_string(),
2676            new_oid: command.new.to_string(),
2677            principal_id: request.principal.id.clone(),
2678            key_fingerprint: fingerprint.to_owned(),
2679            credential: credential.to_owned(),
2680            // Answered by [`was_forced`], not assumed. This was a hardcoded
2681            // `false`, which made every force-push on the server
2682            // indistinguishable from an ordinary one on `Events.Watch`.
2683            forced,
2684        }),
2685        RefStatus::Rejected(reason) => event::Payload::PushRejected(PushRejected {
2686            repo,
2687            ref_name: command.name.to_string(),
2688            // Empty when nothing named a gate: a connectivity failure, a
2689            // compare-and-swap that lost, or the collateral refusal every other
2690            // reference gets in an `atomic` push. Those are real refusals and
2691            // they belong on the stream, but attributing them to a gate that
2692            // never ran would be a lie a UI would repeat.
2693            gate: gates
2694                .get(&command.name.to_string())
2695                .cloned()
2696                .unwrap_or_default(),
2697            reason: reason.clone(),
2698            principal_id: request.principal.id.clone(),
2699            whole_push_rejected: atomic,
2700        }),
2701    };
2702    bus.publish(payload);
2703}
2704
2705// ───────────────────────────────────────────────────────────────────────────
2706// the policy seam
2707// ───────────────────────────────────────────────────────────────────────────
2708
2709/// `gunnar-wire`'s [`PushGate`] over `gunnar-policy`'s [`PolicyEngine`].
2710struct PolicyGate {
2711    engine: PolicyEngine,
2712    /// Why this repository's policy could not be read, if it could not be.
2713    ///
2714    /// `Some` makes [`PolicyGate::check`] refuse **every** reference (GUN-007).
2715    /// An unreadable policy used to become `PolicyConfig::default()`, and an
2716    /// empty engine is let through by the `is_empty()` branch below — so a
2717    /// document with a typo, or a storage fault, quietly turned branch
2718    /// protection and `require_signature` off for the duration. This is the
2719    /// field that keeps "no gates are configured" and "the gates could not be
2720    /// read" from being the same value.
2721    unreadable: Option<String>,
2722    principal: gunnar_policy::Principal,
2723    repo: RepoId,
2724    keys: Box<dyn KeyDirectory>,
2725    metrics: Arc<ServerMetrics>,
2726    policy_path: PathBuf,
2727    /// Reference name → the `Gate::name()` that refused it.
2728    ///
2729    /// `PushGate::check` may only answer `Err(String)`, so the *name* of the
2730    /// gate that said no is lost at that boundary; it survives only inside the
2731    /// `Decision`. The event stream's `PushRejected.gate` needs it, so it is recorded here
2732    /// on the way past rather than parsed back out of the reason string; a
2733    /// reason is written for a human and its shape is free to change.
2734    refused_by: Mutex<HashMap<String, String>>,
2735}
2736
2737/// `gunnar-core`'s principal kind as `gunnar-policy` names it.
2738///
2739/// # The fourth arm, which must never be taken
2740///
2741/// A [`PolicyGate`] is built only for a **push**, and a push needs
2742/// [`Level::Write`](gunnar_core::Level::Write), which
2743/// `gunnar_core::AuthTable::may` refuses to the anonymous principal
2744/// unconditionally, before `Dispatch::authorise` returns and long before this
2745/// runs. So the anonymous arm is unreachable through every route that exists.
2746///
2747/// It is loud rather than plausible. `gunnar-policy` has three kinds and none of
2748/// them means "nobody"; quietly answering `User` would put an anonymous push
2749/// through gates written about humans, and do it silently. If this line is ever
2750/// reached, the ACL has stopped being consulted before the gate. That is a
2751/// security incident, and it reads like one in the log.
2752pub(crate) fn policy_principal_kind(
2753    kind: gunnar_core::PrincipalKind,
2754) -> gunnar_policy::PrincipalKind {
2755    match kind {
2756        gunnar_core::PrincipalKind::User => gunnar_policy::PrincipalKind::User,
2757        gunnar_core::PrincipalKind::Machine => gunnar_policy::PrincipalKind::Machine,
2758        gunnar_core::PrincipalKind::Deploy => gunnar_policy::PrincipalKind::Deploy,
2759        // A group holds no keys and is never authenticated as (punkt 9), so a
2760        // push can never be attributed to one — the same unreachable-by-design
2761        // shape as the anonymous arm below, and loud for the same reason: a
2762        // group at the pre-receive gate means a keyless identity was handed a
2763        // push, which is the ACL being bypassed.
2764        gunnar_core::PrincipalKind::Group => {
2765            tracing::error!(
2766                target: gunnar_core::TARGET_AUDIT,
2767                event = "policy.gate.group_push",
2768                "a GROUP principal reached the pre-receive gate. A group holds no keys and \
2769                 cannot authenticate, so this cannot happen unless the grant check has been \
2770                 bypassed. Treating it as a machine identity for the gate; investigate \
2771                 immediately."
2772            );
2773            debug_assert!(
2774                false,
2775                "a group principal reached the pre-receive gate (punkt 9)"
2776            );
2777            gunnar_policy::PrincipalKind::Machine
2778        }
2779        gunnar_core::PrincipalKind::Anonymous => {
2780            tracing::error!(
2781                target: gunnar_core::TARGET_AUDIT,
2782                event = "policy.gate.anonymous_push",
2783                "an ANONYMOUS principal reached the pre-receive gate. Anonymous access is \
2784                 read-only and the ACL refuses it Write, so this cannot happen unless the \
2785                 grant check has been bypassed. Treating it as a machine identity for the \
2786                 gate; investigate immediately."
2787            );
2788            debug_assert!(false, "an anonymous principal reached the pre-receive gate");
2789            gunnar_policy::PrincipalKind::Machine
2790        }
2791    }
2792}
2793
2794impl PolicyGate {
2795    fn open(
2796        git_dir: &Path,
2797        request: &ServiceRequest,
2798        stores: &ServerStores,
2799        metrics: Arc<ServerMetrics>,
2800    ) -> Self {
2801        let policy_path = git_dir.join(POLICY_FILE);
2802        let (config, unreadable) = match load_policy(stores, &request.repo, &policy_path) {
2803            Ok(config) => (config, None),
2804            // An empty engine on purpose: nothing should be evaluated against
2805            // a policy that could not be read, and `check` refuses before it
2806            // would be.
2807            Err(why) => (PolicyConfig::default(), Some(why)),
2808        };
2809        // An ENCRYPTED repository adds the plaintext check to whatever policy
2810        // it has: the flag is the account admin's, not the policy file's, so
2811        // no policy edit can switch it off.
2812        let mut gates = config.gates();
2813        if request.repo.encrypted {
2814            gates.push(Box::new(gunnar_policy::EncryptedGate::new()));
2815        }
2816        PolicyGate {
2817            engine: PolicyEngine::new(gates),
2818            unreadable,
2819            principal: gunnar_policy::Principal {
2820                id: request.principal.id.clone(),
2821                name: request.principal.name.clone(),
2822                kind: policy_principal_kind(request.principal.kind),
2823            },
2824            repo: RepoId::new(&request.repo.namespace, &request.repo.name),
2825            keys: Box::new(key_directory(stores)),
2826            metrics,
2827            policy_path,
2828            refused_by: Mutex::new(HashMap::new()),
2829        }
2830    }
2831
2832    /// Which gate refused each reference, for the event-stream `PushRejected` events.
2833    ///
2834    /// Read after `ReceivePack::serve` has returned, so every `check` that was
2835    /// going to happen has happened.
2836    fn gate_that_refused(&self) -> HashMap<String, String> {
2837        self.refused_by
2838            .lock()
2839            .expect("the refusing-gate lock")
2840            .clone()
2841    }
2842}
2843
2844impl PushGate for PolicyGate {
2845    fn check(
2846        &self,
2847        command: &RefCommand,
2848        facts: &PushFacts<'_>,
2849    ) -> std::result::Result<(), String> {
2850        // One span per gate evaluation. It is a child of
2851        // `git.receive_pack`, so the connection, principal and repository come
2852        // for free; what is new is the reference and how long deciding it took.
2853        // Branch protection walks ancestry, and an ancestry walk on a large
2854        // repository is one of the two places a push spends unexplained
2855        // seconds.
2856        let span = tracing::debug_span!(
2857            target: TARGET_POLICY,
2858            "policy.gate",
2859            ref_name = %command.name,
2860        );
2861        let _entered = span.enter();
2862        let started = std::time::Instant::now();
2863        // ── the policy could not be READ, which is not "there is none" ─────
2864        //
2865        // First, before `is_empty()`, because `is_empty()` is the branch that
2866        // lets a push through and an unreadable policy produced exactly that
2867        // value (GUN-007). A pre-receive refusal costs pushes and nothing
2868        // else: clone, fetch and browse are untouched, and the operator is
2869        // told which document and why.
2870        if let Some(why) = &self.unreadable {
2871            self.metrics.add_gate_refusals(1);
2872            tracing::error!(
2873                target: TARGET_POLICY,
2874                event = "policy.gate.unreadable",
2875                ref_name = %command.name,
2876                %why,
2877                "the policy for this repository could not be read, so every gate refuses"
2878            );
2879            return Err(format!(
2880                "gunnar could not read the policy for this repository, so it refuses this push \
2881                 rather than serving it with no gates at all: {why}. Reads are unaffected; fix \
2882                 the document (or `gunnar control policy set-protection`) and push again."
2883            ));
2884        }
2885        if self.engine.is_empty() {
2886            // No gates are configured, so there is nothing to decide. Said out
2887            // loud rather than reached by an engine that happens to return an
2888            // empty decision, because "allowed by every gate" and "there are no
2889            // gates" are different facts.
2890            return Ok(());
2891        }
2892        // A gate that cannot see what was pushed cannot decide, and an
2893        // undecidable gate refuses (the pre-receive gate). This is the branch that keeps a
2894        // configured limit from being silently satisfied by an empty object set.
2895        let new_objects = match facts.new_objects {
2896            Ok(ids) => ids,
2897            // No live gate reads the set, so its absence decides nothing.
2898            // The empty slice is never read: `reads_new_objects()` is exactly
2899            // the question "would any gate look at it".
2900            Err(_) if !self.engine.reads_new_objects() => &[],
2901            Err(reason) => {
2902                self.metrics.add_gate_refusals(1);
2903                tracing::error!(
2904                    target: TARGET_POLICY,
2905                    event = "policy.gate.undecidable",
2906                    %reason,
2907                    "the pushed objects could not be enumerated, so every configured gate refuses"
2908                );
2909                return Err(format!(
2910                    "gunnar could not enumerate the pushed objects, so the policy gates in {} \
2911                     could not decide: {reason}",
2912                    self.policy_path.display()
2913                ));
2914            }
2915        };
2916        let updates = [RefUpdate::from_wire(
2917            command.name.clone(),
2918            command.old,
2919            command.new,
2920        )];
2921        let mut ctx = PushContext::new(
2922            &self.principal,
2923            &self.repo,
2924            &updates,
2925            new_objects,
2926            facts.objects,
2927            self.keys.as_ref(),
2928        );
2929        // The byte count reaches the gate. Without it
2930        // `LimitsGate` refuses any push at all whenever `max_pack_bytes` is
2931        // configured, because it will not pretend an unmeasured pack is under a
2932        // limit.
2933        if let Some(bytes) = facts.pack_bytes {
2934            ctx = ctx.with_pack_bytes(bytes);
2935        }
2936        // The push options reach the gate. Unconditionally, including the empty
2937        // slice: `Some(&[])` is "the client sent none" and `None` is "nobody
2938        // looked", and `PushOptionsGate` refuses the second. Before this line
2939        // the whole tree's only consumer of a parsed push option was `.len()`
2940        // in the log field twenty lines below, so `push-options` was advertised
2941        // on the wire and discarded.
2942        ctx = ctx.with_push_options(facts.push_options);
2943        let decision = self.engine.evaluate(&ctx);
2944        let elapsed_ms = started.elapsed().as_millis() as u64;
2945        if decision.is_allowed() {
2946            tracing::debug!(
2947                target: TARGET_POLICY,
2948                event = "policy.gate.allowed",
2949                gates = self.engine.gate_names().len(),
2950                new_objects = new_objects.len(),
2951                elapsed_ms,
2952                "every gate allowed this reference"
2953            );
2954            Ok(())
2955        } else {
2956            // `evaluate` runs every gate, so a reference can be refused by more
2957            // than one. The first in gate order is the one named, and the
2958            // summary leads with the same one, so a UI and the client's `ng`
2959            // line agree about who said no.
2960            self.metrics.add_gate_refusals(1);
2961            if let Some(first) = decision.rejections.first() {
2962                // **`warn!`, and it NAMES THE GATE.** The audit trail's `Verify` asks for
2963                // exactly one error line per refused push naming the gate and
2964                // the reference; before this, a refused push produced a
2965                // `PushRejected` event with an empty `gate` field and no log
2966                // line at all that said who refused it.
2967                tracing::warn!(
2968                    target: TARGET_POLICY,
2969                    event = "policy.gate.refused",
2970                    gate = %first.gate,
2971                    reason = %first.reason,
2972                    refused_by_gates = decision.rejections.len(),
2973                    elapsed_ms,
2974                    "a policy gate refused this reference"
2975                );
2976                self.refused_by
2977                    .lock()
2978                    .expect("the refusing-gate lock")
2979                    .insert(command.name.to_string(), first.gate.clone());
2980            } else {
2981                // `is_allowed()` said no and named nobody. That is a bug in the
2982                // engine, not a refusal, and it must not read as a normal one.
2983                tracing::error!(
2984                    target: TARGET_POLICY,
2985                    event = "policy.gate.refused",
2986                    gate = "",
2987                    elapsed_ms,
2988                    "the policy engine refused a reference without naming a gate"
2989                );
2990            }
2991            Err(decision.summary())
2992        }
2993    }
2994}
2995
2996/// Read the repository's policy document.
2997///
2998/// **`Err` is not an empty policy** (GUN-007). Until 2026-09-11 every failure
2999/// here — a document that did not parse, a control store that could not be
3000/// read, a file that could not be opened — returned `PolicyConfig::default()`,
3001/// and `PolicyGate::check` lets an empty engine through because "no gates are
3002/// configured" is a real and common answer. So a typo in a policy document, or
3003/// a transient storage fault, silently switched branch protection and the
3004/// signature requirement OFF for that push, at `error!` level in a log nobody
3005/// was reading at the time.
3006///
3007/// The old note here said refusing would be worse, because a typo would take
3008/// the repository offline. It does not: this is the PRE-RECEIVE gate, so a
3009/// refusal costs pushes and nothing else — clone, fetch, browse and every read
3010/// are untouched, and the operator is told which document and why. Degraded
3011/// operation must not be indistinguishable from full operation, which is the
3012/// same judgement `ancestry` already makes when a walk cannot finish.
3013///
3014/// `Ok(PolicyConfig::default())` survives for exactly one case and it is a
3015/// fact rather than a fault: nothing is stored and no file exists, so nothing
3016/// has been configured.
3017/// **The control store first, the file beside the repository second.**
3018///
3019/// `POLICY_FILE`'s constant used to carry the note *"When the catalog grows a
3020/// policy document, this constant is the only thing that changes."* The catalog
3021/// has grown one — `gunnar_core::PolicyStore`, at `refs/gunnar/policy` — and
3022/// this is that change. What did **not** change is the file: a repository
3023/// configured before the store existed keeps working, and an operator who edits
3024/// the JSON on disk still gets what they edited, until they write a policy over
3025/// the control plane and the stored one takes over.
3026///
3027/// The order is deliberate and is the only one that can be right. The stored
3028/// document is the one `Policy.SetProtection` writes and the one an operator
3029/// can see over gRPC; if a stale file could shadow it, turning protection on
3030/// through the console would appear to work and change nothing.
3031pub(crate) fn load_policy(
3032    stores: &ServerStores,
3033    repo: &gunnar_core::Repo,
3034    path: &Path,
3035) -> std::result::Result<PolicyConfig, String> {
3036    match stores.policies().get(&repo.path()) {
3037        Ok(Some(document)) => match PolicyConfig::from_json(&document) {
3038            Ok(config) => return Ok(config),
3039            Err(err) => {
3040                tracing::error!(
3041                    target: TARGET_POLICY,
3042                    repo = %repo.key(),
3043                    source = "control-store",
3044                    %err,
3045                    "the stored policy document did not parse; pushes to this repository are \
3046                     refused until it does"
3047                );
3048                return Err(format!("the stored policy document did not parse: {err}"));
3049            }
3050        },
3051        // Nothing stored. The file below is the pre-store shape and is still
3052        // the answer for a repository configured before `PolicyStore` existed.
3053        Ok(None) => {}
3054        Err(err) => {
3055            // NOT a fall-through to the file any more. The file "may be older
3056            // than what an operator configured" — its own words — so serving
3057            // it when the authoritative store is unreachable is a silent
3058            // downgrade to a policy nobody chose.
3059            tracing::error!(
3060                target: TARGET_POLICY,
3061                repo = %repo.key(),
3062                %err,
3063                "the policy store could not be read; pushes to this repository are refused \
3064                 rather than gated by a file that may be older than what an operator \
3065                 configured"
3066            );
3067            return Err(format!("the policy store could not be read: {err}"));
3068        }
3069    }
3070    let text = match std::fs::read_to_string(path) {
3071        Ok(text) => text,
3072        // The one `Ok(default)`: nothing stored and no file. Nothing has been
3073        // configured, which is a fact and not a fault.
3074        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3075            return Ok(PolicyConfig::default())
3076        }
3077        Err(err) => {
3078            tracing::error!(
3079                target: TARGET_POLICY,
3080                path = %path.display(),
3081                %err,
3082                "the policy document could not be read; pushes to this repository are refused"
3083            );
3084            return Err(format!(
3085                "the policy document at {} could not be read: {err}",
3086                path.display()
3087            ));
3088        }
3089    };
3090    match PolicyConfig::from_json(&text) {
3091        Ok(config) => Ok(config),
3092        Err(err) => {
3093            tracing::error!(
3094                target: TARGET_POLICY,
3095                path = %path.display(),
3096                %err,
3097                "the policy document did not parse; pushes to this repository are refused until \
3098                 it does"
3099            );
3100            Err(format!(
3101                "the policy document at {} did not parse: {err}",
3102                path.display()
3103            ))
3104        }
3105    }
3106}
3107
3108/// Every key gunnar knows **that has not expired**, for the ssh-sig gate (the pre-receive gate).
3109///
3110/// `signing_keys()` and not `keys()`, and that one word is a defect fixed on
3111/// 2026-08-05: this function used to load every row in the table, so a key past
3112/// its `expires` could no longer authenticate an SSH session and could still
3113/// satisfy `require_signature` on a push. The expiry was enforced on exactly one
3114/// of the two things a key does.
3115pub(crate) fn key_directory(stores: &ServerStores) -> StaticKeyDirectory {
3116    let mut directory = StaticKeyDirectory::new();
3117    let keys = match stores.auth().signing_keys() {
3118        Ok(keys) => keys,
3119        Err(err) => {
3120            tracing::error!(
3121                target: TARGET_POLICY,
3122                %err,
3123                "the identity table could not be read for signature verification"
3124            );
3125            return directory;
3126        }
3127    };
3128    for key in keys {
3129        // The identity table holds two credential kinds in one hash space
3130        // (auth.rs, the mTLS plan): an `x509` row is the bootstrap certificate,
3131        // not an ssh key, and cannot verify an ssh-sig — skipped by name, so
3132        // the WARN below is kept for a key that IS ssh and DID go bad
3133        // (measured 2026-08-29: the band warned at every login).
3134        if !ssh_verifiable_kind(&key.algo) {
3135            tracing::debug!(target: TARGET_POLICY, algo = %key.algo, fingerprint = %key.fingerprint_sha256, "not an ssh key kind; not a signature verifier");
3136            continue;
3137        }
3138        match gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh) {
3139            Ok(parsed) => {
3140                directory.add(&key.principal_id, parsed);
3141            }
3142            Err(err) => tracing::warn!(
3143                target: TARGET_POLICY,
3144                fingerprint = %key.fingerprint_sha256,
3145                %err,
3146                "a registered key did not parse and cannot verify a signature"
3147            ),
3148        }
3149    }
3150    directory
3151}
3152
3153/// Is a registered key's `algo` (the name as OpenSSH writes it) something
3154/// the ssh-sig verifier can hold? `ssh-*`, `ecdsa-*`, `sk-*` are; `x509`
3155/// (the bootstrap certificate stored beside them) is not.
3156pub fn ssh_verifiable_kind(algo: &str) -> bool {
3157    algo.starts_with("ssh-") || algo.starts_with("ecdsa-") || algo.starts_with("sk-")
3158}
3159
3160// ───────────────────────────────────────────────────────────────────────────
3161// Guards
3162// ───────────────────────────────────────────────────────────────────────────
3163
3164#[cfg(test)]
3165mod tests {
3166
3167    /// ★ A build without the quarantine refuses an encrypted repository's push by name, and a
3168    /// plain repository's push is never refused for it (wiring tracer B 2026-10-08).
3169    #[test]
3170    fn a_build_without_the_quarantine_refuses_an_encrypted_push_by_name() {
3171        let line = super::unchecked_encrypted_push(true, false).expect("refused");
3172        assert!(
3173            line.contains("encrypted") && line.contains("this Gunnar"),
3174            "{line}"
3175        );
3176        assert_eq!(
3177            super::unchecked_encrypted_push(true, true),
3178            None,
3179            "the quarantine checks it"
3180        );
3181        assert_eq!(
3182            super::unchecked_encrypted_push(false, false),
3183            None,
3184            "a plain repository"
3185        );
3186    }
3187    use super::*;
3188    use gunnar_wire::pack::{IndexedPack, PackVersion};
3189
3190    #[test]
3191    fn the_signature_directory_takes_ssh_kinds_and_skips_the_x509_row_by_name() {
3192        for ssh in [
3193            "ssh-ed25519",
3194            "ssh-rsa",
3195            "ecdsa-sha2-nistp256",
3196            "sk-ssh-ed25519@openssh.com",
3197            "sk-ecdsa-sha2-nistp256@openssh.com",
3198        ] {
3199            assert!(ssh_verifiable_kind(ssh), "{ssh}");
3200        }
3201        assert!(
3202            !ssh_verifiable_kind("x509"),
3203            "the bootstrap certificate is not tried as an ssh key"
3204        );
3205        assert!(!ssh_verifiable_kind(""));
3206    }
3207
3208    /// A `PushOutcome` carrying whatever pack the caller wants to ask about.
3209    fn outcome_with(pack: Option<IndexedPack>) -> PushOutcome {
3210        PushOutcome {
3211            commands: Vec::new(),
3212            statuses: Vec::new(),
3213            unnameable: Vec::new(),
3214            push_options: Vec::new(),
3215            pack,
3216            pack_bytes: Some(0),
3217            new_objects: Ok(Vec::new()),
3218            atomic: false,
3219            unpack: Ok(()),
3220        }
3221    }
3222
3223    /// `stored_bytes: None` on purpose: these arms are about what
3224    /// `measured_pack_bytes` does when the ENGINE reported no count, which is
3225    /// the only case where stat'ing a path is still the answer.
3226    fn indexed(data_path: Option<std::path::PathBuf>) -> IndexedPack {
3227        indexed_with(data_path, None)
3228    }
3229
3230    fn indexed_with(
3231        data_path: Option<std::path::PathBuf>,
3232        stored_bytes: Option<u64>,
3233    ) -> IndexedPack {
3234        IndexedPack {
3235            num_objects: 1,
3236            pack_id: ObjectId::empty_blob(gix_hash::Kind::Sha1),
3237            index_id: ObjectId::empty_blob(gix_hash::Kind::Sha1),
3238            pack_version: PackVersion::V2,
3239            hash_kind: gix_hash::Kind::Sha1,
3240            index_path: None,
3241            data_path,
3242            keep_path: None,
3243            stored_bytes,
3244        }
3245    }
3246
3247    /// **An engine that writes no packfile still knows how big the pack was.**
3248    ///
3249    /// The znippy store appends the received bytes verbatim and writes no
3250    /// `.pack`, so `data_path` is `None` — and every push into the engine the
3251    /// appliance ships logged `pack_bytes_unknown="pack … was indexed without a
3252    /// directory, so there is nothing on disk to measure"`. That sentence was
3253    /// FALSE: the store counted the bytes it appended
3254    /// (`PushReceipt::pack_bytes`) and nobody carried the number this far. An
3255    /// absence rendered where a measurement exists is the same defect as the
3256    /// reverse, and it is the one this whole family of tests is about.
3257    ///
3258    /// Both directions, or a `stored_bytes` that were ignored would still pass
3259    /// the first line: a store that reports NEITHER a file nor a size is still
3260    /// an honest unknown, and must still say so.
3261    #[test]
3262    fn a_pack_with_no_file_is_measured_by_the_store_that_stored_it() {
3263        let (bytes, why) = measured_pack_bytes(&outcome_with(Some(indexed_with(None, Some(4096)))));
3264        assert_eq!(
3265            bytes,
3266            Some(4096),
3267            "the store's own count is the measurement"
3268        );
3269        assert_eq!(
3270            why, None,
3271            "a measured pack carries no reason for not being measured"
3272        );
3273
3274        let (bytes, why) = measured_pack_bytes(&outcome_with(Some(indexed_with(None, None))));
3275        assert_eq!(
3276            bytes, None,
3277            "neither a file nor a count is genuinely unknown"
3278        );
3279        assert!(
3280            why.as_deref().unwrap_or_default().contains("no size"),
3281            "and the reason must say the store reported no size, not merely that there is no \
3282             directory: {why:?}"
3283        );
3284    }
3285
3286    /// **The defect this replaced.** `stat` failing rendered as `pack_bytes=0`,
3287    /// which is indistinguishable from a push that genuinely carried nothing.
3288    ///
3289    /// Seen red: with the body restored to
3290    /// `.and_then(|path| std::fs::metadata(path).ok()).map(len).unwrap_or(0)`
3291    /// this fails with *"a pack whose bytes could not be read reported
3292    /// Some(0)"* — the old code's exact answer.
3293    #[test]
3294    fn a_pack_that_cannot_be_stated_reports_no_number_and_names_the_path() {
3295        let missing = std::path::PathBuf::from("/nonexistent/gunnar/pack-deadbeef.pack");
3296        let outcome = outcome_with(Some(indexed(Some(missing.clone()))));
3297        let (bytes, why) = measured_pack_bytes(&outcome);
3298        assert_eq!(
3299            bytes, None,
3300            "a pack whose bytes could not be read reported {bytes:?}; the only honest answer is \
3301             no number at all, because 0 is a legal pack size for a deletions-only push"
3302        );
3303        let why = why.expect("an unreadable pack must say why it is unreadable");
3304        assert!(
3305            why.contains(missing.to_str().unwrap()),
3306            "the reason must name the path an operator has to go and look at, got {why:?}"
3307        );
3308    }
3309
3310    /// The one arm that IS a zero, and must stay one: a deletions-only push
3311    /// carries no pack, and reporting that as *unknown* would be the mirror
3312    /// mistake.
3313    #[test]
3314    fn a_deletions_only_push_reports_a_measured_zero_and_no_reason() {
3315        let (bytes, why) = measured_pack_bytes(&outcome_with(None));
3316        assert_eq!(
3317            bytes,
3318            Some(0),
3319            "a push with no pack carries zero pack bytes"
3320        );
3321        assert_eq!(why, None, "zero bytes is a measurement, not an absence");
3322    }
3323
3324    /// **Three different answers over the same empty list**, which is the whole
3325    /// reason the count takes a `Result` and not a slice.
3326    ///
3327    /// * not enumerated, 3-object pack → 3. Seen red on the rig's SSE feed
3328    ///   ("pushed 0 objects (0 B)" for a push that carried a commit).
3329    /// * not enumerated, no pack at all → 0, because there is nothing to count.
3330    /// * ENUMERATED and empty → 0 even beside a 3-object pack. That is a
3331    ///   measured zero: the store said which objects the push introduced and
3332    ///   the answer was none. Under the old `is_empty()` fallback this case
3333    ///   reported 3, i.e. the record disagreed with the store that had been
3334    ///   asked. It is the arm that makes this test unable to pass against the
3335    ///   code it replaces.
3336    #[test]
3337    fn the_object_count_reads_the_reason_not_the_length() {
3338        let mut pack = indexed(None);
3339        pack.num_objects = 3;
3340        let three = outcome_with(Some(pack));
3341        let none = outcome_with(None);
3342        let unknown = Err("the policy has no gate over objects");
3343        assert_eq!(attested_object_count(unknown, &three), 3);
3344        assert_eq!(attested_object_count(unknown, &none), 0);
3345        assert_eq!(
3346            attested_object_count(Ok(&[]), &three),
3347            0,
3348            "an enumerated empty set is a MEASURED zero and must not be overruled by the pack"
3349        );
3350        let oid = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
3351        assert_eq!(attested_object_count(Ok(&[oid, oid]), &none), 2);
3352    }
3353
3354    /// A pack indexed with no directory wrote nothing, so there is nothing to
3355    /// measure — and that is not zero either.
3356    #[test]
3357    fn a_pack_never_written_to_disk_reports_no_number() {
3358        let (bytes, why) = measured_pack_bytes(&outcome_with(Some(indexed(None))));
3359        assert_eq!(bytes, None);
3360        assert!(
3361            why.expect("must say why").contains("without a directory"),
3362            "the reason must distinguish 'never written' from 'written and unreadable'"
3363        );
3364    }
3365
3366    /// The number is the file's real length, not a plausible one. Seen red by
3367    /// writing 7 bytes and asserting 6.
3368    #[test]
3369    fn a_pack_on_disk_reports_the_bytes_the_file_actually_holds() {
3370        let dir = tempfile::tempdir().expect("a temp dir");
3371        let path = dir.path().join("pack-x.pack");
3372        std::fs::write(&path, b"1234567").expect("write");
3373        let (bytes, why) = measured_pack_bytes(&outcome_with(Some(indexed(Some(path)))));
3374        assert_eq!(bytes, Some(7), "the length must come off the filesystem");
3375        assert_eq!(why, None);
3376    }
3377
3378    /// `wire_bytes` had the same `unwrap_or(0)`. `PushFacts::pack_bytes`
3379    /// documents `None` as "genuinely unavailable" and `Some(0)` as a
3380    /// deletions-only push; the log collapsed both onto `0`.
3381    #[test]
3382    fn an_uncounted_wire_is_not_a_wire_that_carried_nothing() {
3383        let (bytes, why) = measured(None, "the ingest did not report");
3384        assert_eq!(bytes, None, "an uncounted wire must not render as 0 bytes");
3385        assert_eq!(why.as_deref(), Some("the ingest did not report"));
3386
3387        let (bytes, why) = measured(Some(0), "unused");
3388        assert_eq!(bytes, Some(0), "a counted zero stays a zero");
3389        assert_eq!(why, None, "a counted zero carries no reason");
3390    }
3391
3392    /// The ssh-sig gate is handed **only unexpired** keys.
3393    ///
3394    /// The defect, found 2026-08-05: `key_directory` loaded `AuthTable::keys()`,
3395    /// which filters nothing, so a key past its `expires` could no longer open an
3396    /// SSH session and could still satisfy `require_signature` on a push. One
3397    /// credential with two lifetimes.
3398    ///
3399    /// `gunnar-core`'s `an_expired_key_signs_nothing_and_is_still_listed` guards
3400    /// the *predicate*. This guards the **wiring**, which is the half that was
3401    /// actually wrong: the predicate already existed and worked, and this call
3402    /// site simply did not ask it. A guard on the predicate alone would have been
3403    /// green over the whole bug.
3404    ///
3405    /// # Seen red
3406    ///
3407    /// With `signing_keys()` changed back to `keys()`, `all_keys()` has length 1
3408    /// and the first assertion fails. The valid-key half is the control: it fails
3409    /// if `key_directory` returns an empty directory for any other reason —
3410    /// an unreadable table, a key that will not parse — which would otherwise
3411    /// make the first assertion pass for the wrong reason.
3412    /// **Un-ignored 2026-08-10.** The reason it was skipped — *"this crate
3413    /// deliberately carries no storage engine"* — stopped being true when the
3414    /// engine became a feature of this crate with znippy as the default. There
3415    /// is no engine-free build of `gunnar-server` any longer to skip it for.
3416    #[test]
3417    fn the_signature_gate_is_handed_no_expired_key() {
3418        // The known-answer key `gunnar-core`'s own tests use, and a second one, so
3419        // the two principals below hold different key material.
3420        const EXPIRED_KEY: &str = "ssh-ed25519 \
3421            AAAAC3NzaC1lZDI1NTE5AAAAIMW4aqVr75w+3DWorOCbfwl5XCDCq1yCQ6puzU4qzol2 gunnar-kat";
3422        let minted = gunnar_core::mint_ed25519().expect("mint a key");
3423        let live_key = gunnar_core::ssh_key::PrivateKey::from_openssh(&minted)
3424            .expect("parse the minted key")
3425            .public_key()
3426            .to_openssh()
3427            .expect("render the public half");
3428
3429        let dir = tempfile::tempdir().expect("a temp dir");
3430        let stores = ServerStores::open(dir.path()).expect("open the stores");
3431        for (id, key, expires) in [
3432            // Already expired: the unix epoch.
3433            ("stale", EXPIRED_KEY, Some(1_000)),
3434            ("live", live_key.as_str(), None),
3435        ] {
3436            stores
3437                .auth()
3438                .add_principal(
3439                    gunnar_core::Principal::new(id, id, gunnar_core::PrincipalKind::Machine)
3440                        .expect("a valid id"),
3441                )
3442                .expect("add the principal");
3443            stores
3444                .auth()
3445                .register_key(id, key, expires)
3446                .expect("register the key");
3447        }
3448
3449        let directory = key_directory(&stores);
3450        assert!(
3451            directory.keys_for("stale").is_empty(),
3452            "an expired key must not be offered to the signature gate"
3453        );
3454        // The control. Without it an always-empty directory — an unreadable
3455        // table, a parse failure, a `key_directory` that returns early — passes
3456        // the line above while proving nothing.
3457        assert_eq!(
3458            directory.keys_for("live").len(),
3459            1,
3460            "an unexpired key must still be offered, or this test proves nothing"
3461        );
3462        assert_eq!(
3463            directory.all_keys().len(),
3464            1,
3465            "exactly the unexpired key, and nothing else, reaches the gate"
3466        );
3467    }
3468}