gunnar_server/storage.rs
1//! **Which storage engine this binary carries, and which one it is running.**
2//!
3//! There is one storage contract in the constellation and it is
4//! [`git_storage_trait::GitOps`] — *the eleven* — which lives in znippy beside
5//! its main implementation. This module is the *only* place in gunnar that names
6//! an implementation of it.
7//!
8//! ```text
9//! git-storage-trait :: GitOps
10//! ┌───────────┴───────────┐
11//! znippy-plugin-git storage-git-gix
12//! (Arrow-IPC archive) (gix-odb / gix-ref)
13//! `engine-znippy` `engine-gix`
14//! └───────────┬───────────┘
15//! Engine::open_repo
16//! ──▶ Arc<OpenRepo>
17//! ```
18//!
19//! # What used to be here, and why it is gone
20//!
21//! A second, older seam: `trait StorageEngine` with `install()`, `installed()`
22//! and a `static ENGINE: OnceLock<&'static dyn StorageEngine>`. It existed
23//! because the engine was chosen by *linking a different crate* — `gunnar-znippy`
24//! — which had to run `install()` before `run()`, and therefore needed a `main`
25//! of its own. That crate declared `[[bin]] gunnar` and `[[bin]] gunnar-server`,
26//! two names the build declaration had already assigned to `gunnar-cli` and to
27//! this package, while being absent from that declaration itself: an undeclared
28//! second writer of two declared artifact names. It is why every matrix-built
29//! binary answered *"this binary carries no storage engine"*.
30//!
31//! The engine is a **feature** now. There is nothing left to install, so there is
32//! no `OnceLock`, no `install`, and no engine-free build — see the
33//! [`compile_error!`] below. `gunnar-znippy` is deleted.
34//!
35//! # Both engines may be compiled in at once
36//!
37//! Cargo features are additive and must be, so this is the correct idiom rather
38//! than a compromise:
39//!
40//! | build | engines linked | selection |
41//! |---|---|---|
42//! | `--features engine-znippy,engine-gix` | both | **runtime**, `GUNNAR_ENGINE`, defaulting to znippy |
43//! | default | znippy only | nothing to select |
44//! | `--no-default-features --features server,engine-gix` | gix only | nothing to select |
45//!
46//! **The rule.** When both are compiled the default is **znippy**, and asking for
47//! an engine that was not compiled in fails loudly, at startup, before anything
48//! is served. It never silently falls back to the other: a server quietly
49//! serving from the wrong engine is the worst possible outcome of this seam, and
50//! it is the one failure a measurement cannot see.
51//!
52//! That rule is kept **by construction** rather than by a check. [`Engine`]'s
53//! variants are themselves `#[cfg]`-gated, so in a gix-only build the value
54//! `Engine::Znippy` does not exist and cannot be constructed by any path,
55//! mistaken or otherwise. [`Engine::from_name`] still *knows* the name, which is
56//! what lets it refuse with "this binary was built without it" rather than with
57//! "unknown engine".
58//!
59//! # znippy's build-time C toolchain, and why this module is where the cost lands
60//!
61//! Cargo reads a path dependency's manifest at **resolve** time whether or not
62//! the feature selecting it is on. Naming `znippy-plugin-git` here therefore
63//! makes a sibling znippy checkout a hard requirement of *every* build of this
64//! workspace. MEASURED 2026-08-10 — see this crate's `Cargo.toml`, which records
65//! the command, the output and the 30 packages it adds to `Cargo.lock`.
66
67use std::io::BufRead;
68use std::path::{Path, PathBuf};
69use std::sync::Arc;
70
71use anyhow::{anyhow, bail, Context, Result};
72use gunnar_store::git_storage_trait::GitServe;
73use gunnar_store::RepoStore;
74
75#[cfg(not(any(feature = "engine-znippy", feature = "engine-gix")))]
76compile_error!(
77 "gunnar-server must be built with at least one storage engine: `engine-znippy` (the \
78 default) or `engine-gix`. A server binary with no engine is not a smaller server — it \
79 is the defect this seam replaced: it built, it shipped, it answered every push with \
80 `this binary carries no storage engine`, and the build matrix could not tell it from \
81 the real one."
82);
83
84#[cfg(feature = "engine-gix")]
85mod gix_engine;
86mod pack_end;
87mod store_cache;
88#[cfg(feature = "engine-znippy")]
89pub mod writer;
90/// **The engine seam.** `znippy_plugin_git::` may be named here and nowhere
91/// else — `xtask`'s `Rule::EngineByName` says so, because this file's own
92/// header claims to be the only place in gunnar that names an implementation.
93///
94/// Both sides of the 2026-08-31 merge had built this module and given it a
95/// different name and a different scope. Master's `archive` moved the SIX names
96/// that broke the rule (`archive_in`/`out`/`out_to`, `parse_explode_budget`,
97/// `human_bytes`); this one moves the whole adapter — `store_root`, `shutdown`,
98/// `cancel_index`, `index_progress`, `explode_use`, `set_explode_budget`,
99/// `drop_explode_cache`, `open`, `ingest`, `maintain` — and is the answer that
100/// makes the next engine a file beside `gix_engine.rs` rather than a `#[cfg]`
101/// in `admin.rs`. So the wider one wins and `archive.rs` retires into it, with
102/// master's type discipline carried across: the archive receipts are the
103/// plugin's, re-exported, never re-declared.
104///
105/// `pub` because `admin.rs` and `control_plane.rs` have to reach it: a seam
106/// nothing outside can reach is a rule nobody can keep.
107#[cfg(feature = "engine-znippy")]
108pub mod znippy_engine;
109
110/// **An encrypted repository's push, checked before a byte of it is kept**
111/// (crypt-level2 item 4; `crate::encrypted_quarantine` re-exports it). Here,
112/// not beside `transport.rs`, because it walks the pushed pack with the znippy
113/// engine's own reader — and the engine is named in this seam and nowhere else.
114#[cfg(feature = "engine-znippy")]
115pub mod encrypted_quarantine;
116
117/// The writer arm the znippy engine built its stores on (`uring` / `safe`),
118/// for the dashboard's disk cell; `None` before the first store, or on an
119/// engine that has no arms.
120pub fn writer_arm() -> Option<&'static str> {
121 #[cfg(feature = "engine-znippy")]
122 {
123 writer::selected().map(|s| s.arm.as_str())
124 }
125 #[cfg(not(feature = "engine-znippy"))]
126 {
127 None
128 }
129}
130
131/// **Release every store this process cached, on the way out.**
132///
133/// Engine-neutral, and deliberately not a method on [`Engine`]: it is not "shut
134/// down the engine this run selected", it is "let go of everything anything
135/// opened", and a build carrying both engines has two caches whichever one the
136/// request used.
137///
138/// The znippy arm is where this earns its keep — see
139/// [`znippy_engine::shutdown`] for the measurement and for why the cache itself
140/// stays. It is idempotent, and safe to call in a process that opened no
141/// repository at all: the `OnceLock` behind the cache is initialised on demand
142/// and an empty map clears to an empty map.
143///
144/// The gix arm now has something to release too. It has no database to be told
145/// it was closed, but since 2026-08-11 it caches a `GixStore` per store root for
146/// correctness — one handle so that a push refreshes the view the reader serves
147/// from ([`store_cache`]) — and those hold mmaps over every `.pack` and `.idx`
148/// they have touched. The line the old comment said would be needed "later" is
149/// the second one below.
150pub fn shutdown() {
151 #[cfg(feature = "engine-znippy")]
152 znippy_engine::shutdown();
153 #[cfg(feature = "engine-gix")]
154 gix_engine::shutdown();
155}
156
157/// The environment variable that picks an engine in a build carrying both.
158///
159/// An environment variable and not a flag, for now, because the flag would live
160/// on [`gunnar_common::cli`]'s `ServeArgs` — a crate this change does not own —
161/// and every verb that opens a repository needs the choice, not just `serve`.
162/// The variable is read exactly once, in [`Engine::selected`], on the startup
163/// path.
164pub const ENGINE_ENV: &str = "GUNNAR_ENGINE";
165
166/// **A storage engine this binary actually carries.**
167///
168/// The variants are `#[cfg]`-gated on purpose: a value of this type is proof
169/// that the engine it names is linked. There is no "selected but absent" state
170/// to guard against, because it cannot be represented.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum Engine {
173 /// `znippy-plugin-git` — Arrow-IPC in a znippy archive.
174 #[cfg(feature = "engine-znippy")]
175 Znippy,
176 /// `storage-git-gix` — `gix-odb` / `gix-ref` on a plain filesystem.
177 #[cfg(feature = "engine-gix")]
178 Gix,
179}
180
181/// The engine a build carrying znippy runs unless told otherwise.
182#[cfg(feature = "engine-znippy")]
183pub const DEFAULT_ENGINE: Engine = Engine::Znippy;
184
185/// With no znippy there is exactly one engine left, and it is the default by
186/// arithmetic rather than by preference. The `compile_error!` above is what
187/// makes this exhaustive.
188#[cfg(all(not(feature = "engine-znippy"), feature = "engine-gix"))]
189pub const DEFAULT_ENGINE: Engine = Engine::Gix;
190
191impl std::fmt::Display for Engine {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 f.write_str(self.name())
194 }
195}
196
197/// **Two resolver workers on a twin.** MEASURED 2026-09-15
198/// (`tests/twin_snapshot_memory.rs`): one worker per core, each inflating a whole blob, grew
199/// RssAnon +133 MiB for a 512 MiB snapshot install even with explode and graph off. Handed to
200/// `znippy_plugin_git::set_resolve_workers_default` by [`Engine::hold_twin_memory_bound`].
201pub const TWIN_RESOLVE_WORKERS: usize = 2;
202/// **16 MiB of resolver lookahead on a twin** (same measurement) — the bytes the workers may
203/// run ahead of the ordered output by. znippy's own default is 32 MiB.
204pub const TWIN_RESOLVE_LOOKAHEAD_BYTES: u64 = 16 << 20;
205/// **16 MiB explode budget on a twin** (same measurement). With the policy `off` nothing is
206/// stored at all; the cap is what keeps a later policy change bounded.
207pub const TWIN_EXPLODE_BUDGET_BYTES: u64 = 16 << 20;
208
209/// **Where the nordisk repositories live** — the ONE place an engine's source
210/// URL is spelled. Today codeberg; the day they move to gunnar.rs this is
211/// `https://gunnar.rs/nordisk` and every engine's link follows.
212pub const NORDISK_REPOS: &str = "https://codeberg.org/nordisk";
213
214impl Engine {
215 /// Every engine compiled into this binary, in preference order.
216 pub const COMPILED: &'static [Engine] = &[
217 #[cfg(feature = "engine-znippy")]
218 Engine::Znippy,
219 #[cfg(feature = "engine-gix")]
220 Engine::Gix,
221 ];
222
223 /// The name this engine is selected by and reported under.
224 pub fn name(self) -> &'static str {
225 match self {
226 #[cfg(feature = "engine-znippy")]
227 Engine::Znippy => "znippy",
228 #[cfg(feature = "engine-gix")]
229 Engine::Gix => "gix",
230 }
231 }
232
233 /// **Can replication read a repository this engine stores?**
234 ///
235 /// It reads one by opening it as a *filesystem git repository* — `gix::open`
236 /// in [`crate::mirror`], then `gunnar-client`'s send-pack, which is proven
237 /// against the real `git receive-pack` and must not be twinned. An engine
238 /// that writes no `.git` layout cannot be read that way, and the failure
239 /// lands inside the retry loop where nobody sees it: the server accepts
240 /// every push and the standby stays empty.
241 ///
242 /// So it is asked at start-up instead, and a server that cannot do what it
243 /// was configured to do refuses to come up. Closing the gap means a
244 /// server-side send-pack over `GitServe` — the store already emits packs
245 /// for `upload-pack` — at which point this predicate goes away rather than
246 /// gaining an arm.
247 pub fn replication_can_read_repositories(self) -> bool {
248 match self {
249 #[cfg(feature = "engine-znippy")]
250 Engine::Znippy => false,
251 #[cfg(feature = "engine-gix")]
252 Engine::Gix => true,
253 }
254 }
255
256 /// **The engine's crate**, as it is named on the Storage page and linked.
257 pub fn plugin(self) -> &'static str {
258 match self {
259 #[cfg(feature = "engine-znippy")]
260 Engine::Znippy => "znippy-plugin-git",
261 #[cfg(feature = "engine-gix")]
262 Engine::Gix => "storage-git-gix",
263 }
264 }
265
266 /// **What it stores and how**, in one line — the Storage page's tooltip.
267 pub fn summary(self) -> &'static str {
268 match self {
269 #[cfg(feature = "engine-znippy")]
270 Engine::Znippy => "Arrow-IPC archive; objects + refs",
271 #[cfg(feature = "engine-gix")]
272 Engine::Gix => "gix-odb packs + gix-ref (gitoxide); objects + refs",
273 }
274 }
275
276 /// **Where the engine's source lives**, off [`NORDISK_REPOS`]: znippy's
277 /// own repository; `storage-git-gix` is a crate of edda's (built on
278 /// gitoxide's `gix-odb` / `gix-ref`).
279 pub fn repo_url(self) -> String {
280 match self {
281 #[cfg(feature = "engine-znippy")]
282 Engine::Znippy => format!("{NORDISK_REPOS}/znippy"),
283 #[cfg(feature = "engine-gix")]
284 Engine::Gix => format!("{NORDISK_REPOS}/edda"),
285 }
286 }
287
288 /// **The engine, in parts** — name, one-line description, source
289 /// repository — for `Admin.StorageInfo`'s `engine` (2026-10-02).
290 pub fn report(self) -> gunnar_proto::StorageEngine {
291 gunnar_proto::StorageEngine {
292 name: self.plugin().to_owned(),
293 description: self.summary().to_owned(),
294 repo_url: self.repo_url(),
295 }
296 }
297
298 /// What answers, in one line, for an operator reading `storage info`:
299 /// `znippy-plugin-git (Arrow-IPC archive; objects + refs)`, made of the
300 /// same parts [`Self::report`] sends apart.
301 pub fn describe(self) -> String {
302 format!("{} ({})", self.plugin(), self.summary())
303 }
304
305 /// The engines this binary carries, as a human-readable list.
306 pub fn compiled_list() -> String {
307 Self::COMPILED
308 .iter()
309 .map(|e| e.name())
310 .collect::<Vec<_>>()
311 .join(", ")
312 }
313
314 /// **Resolve a name to an engine, or refuse.**
315 ///
316 /// Three outcomes, and the middle one is the whole point:
317 ///
318 /// * a name this build carries → that engine;
319 /// * a name this build *knows* but did not compile in → refused, saying so;
320 /// * anything else → refused as unknown.
321 ///
322 /// Never a fallback. See the module docs.
323 pub fn from_name(name: &str) -> Result<Engine> {
324 match name.trim().to_ascii_lowercase().as_str() {
325 #[cfg(feature = "engine-znippy")]
326 "znippy" => Ok(Engine::Znippy),
327 #[cfg(not(feature = "engine-znippy"))]
328 "znippy" => Err(not_compiled_in("znippy")),
329
330 #[cfg(feature = "engine-gix")]
331 "gix" => Ok(Engine::Gix),
332 #[cfg(not(feature = "engine-gix"))]
333 "gix" => Err(not_compiled_in("gix")),
334
335 other => Err(anyhow!(
336 "unknown storage engine {other:?}; the engine names are `znippy` and `gix`, and \
337 this binary carries {}",
338 Self::compiled_list()
339 )),
340 }
341 }
342
343 /// **The engine this process runs**, decided once, on the startup path.
344 ///
345 /// [`ENGINE_ENV`] if it is set and non-empty, [`DEFAULT_ENGINE`] otherwise.
346 /// An unset variable is *not* "no engine": it is the default, which is
347 /// znippy wherever znippy is compiled in.
348 pub fn selected() -> Result<Engine> {
349 match std::env::var(ENGINE_ENV) {
350 Ok(raw) if !raw.trim().is_empty() => Self::from_name(&raw).with_context(|| {
351 format!("{ENGINE_ENV}={raw:?} selects the storage engine for this process")
352 }),
353 _ => Ok(DEFAULT_ENGINE),
354 }
355 }
356
357 /// Open — creating if absent — the repository at `git_dir`.
358 ///
359 /// Unconditional. "It holds nothing yet" is the normal state of a
360 /// repository's first push, not a reason to answer with something else:
361 /// gating on what a repository already holds is what refuses every first
362 /// push, which is a mistake this seam has already made once.
363 pub fn open_repo(self, git_dir: &Path, hash_kind: gix_hash::Kind) -> Result<Arc<OpenRepo>> {
364 match self {
365 #[cfg(feature = "engine-znippy")]
366 Engine::Znippy => znippy_engine::open(git_dir, hash_kind),
367 #[cfg(feature = "engine-gix")]
368 Engine::Gix => gix_engine::open(git_dir, hash_kind),
369 }
370 }
371
372 /// Stop any index work on the repository at `git_dir` — the engine half
373 /// of `repo delete`. A no-op for an engine without a background indexer.
374 pub fn cancel_index(self, git_dir: &Path) {
375 match self {
376 #[cfg(feature = "engine-znippy")]
377 Engine::Znippy => znippy_engine::cancel_index(git_dir),
378 #[cfg(feature = "engine-gix")]
379 Engine::Gix => {
380 let _ = git_dir;
381 }
382 }
383 }
384
385 /// Index work in flight across every open repository, as
386 /// `(git_dir, snapshot)`. Empty for an engine without a background indexer.
387 pub fn index_progress(self) -> Vec<(PathBuf, IndexSnapshot)> {
388 match self {
389 #[cfg(feature = "engine-znippy")]
390 Engine::Znippy => znippy_engine::index_progress()
391 .into_iter()
392 .map(|(dir, s)| {
393 (
394 dir,
395 IndexSnapshot {
396 active: s.active,
397 resumed: s.resumed,
398 pack_id: s.pack_id,
399 objects_done: s.objects_done,
400 objects_total: s.objects_total,
401 bytes_done: s.bytes_done,
402 bytes_total: s.bytes_total,
403 phase: s.phase.as_str(),
404 started_at_ms: s.started_at_ms,
405 reinflations: s.reinflations,
406 pauses: s.pauses,
407 },
408 )
409 })
410 .collect(),
411 #[cfg(feature = "engine-gix")]
412 Engine::Gix => Vec::new(),
413 }
414 }
415
416 /// **Every open repository's explode band**, as `(git_dir, band)`.
417 ///
418 /// The exploded tier is a read cache with a byte budget, not storage: a
419 /// blob is written into it by the first reader that inflates it and
420 /// unlinked from it when the budget says so. Both numbers are here because
421 /// an operator asked to see a repository at its limit — an account at its
422 /// budget still serves every object, only slower, and nothing else on the
423 /// dashboard would say which repositories those are.
424 ///
425 /// A `metadata` call per pool, no store lock. Empty for an engine with no
426 /// such tier.
427 pub fn explode_use(self) -> Vec<(PathBuf, ExplodeBand)> {
428 match self {
429 #[cfg(feature = "engine-znippy")]
430 Engine::Znippy => znippy_engine::explode_use(),
431 #[cfg(feature = "engine-gix")]
432 Engine::Gix => Vec::new(),
433 }
434 }
435
436 /// **Indexed objects of every open store**, as `(git_dir, count)` — the
437 /// `objects` meter of `Usage.Read`, i.e. what this process is paying RAM
438 /// for (~382 B an object, measured). A closed store is absent, not zero.
439 ///
440 /// Empty on the gix engine: it keeps no in-RAM object index per open
441 /// store (its odb is mmapped pack indexes), so there is no per-object RAM
442 /// price to report and reporting one would be a made-up bill.
443 pub fn open_object_counts(self) -> Vec<(PathBuf, u64)> {
444 match self {
445 #[cfg(feature = "engine-znippy")]
446 Engine::Znippy => znippy_engine::open_object_counts(),
447 #[cfg(feature = "engine-gix")]
448 Engine::Gix => Vec::new(),
449 }
450 }
451
452 /// **Set the explode budget of every open repository under `git_dir`.**
453 ///
454 /// A prefix, so one call moves a whole account. Returns how many stores
455 /// were changed — applied output, so a control plane that reached nothing
456 /// (every repository closed) can say so instead of reporting success.
457 pub fn set_explode_budget(self, git_dir: &Path, bytes: u64) -> usize {
458 match self {
459 #[cfg(feature = "engine-znippy")]
460 Engine::Znippy => znippy_engine::set_explode_budget(git_dir, bytes),
461 #[cfg(feature = "engine-gix")]
462 Engine::Gix => {
463 let _ = (git_dir, bytes);
464 0
465 }
466 }
467 }
468
469 /// **A twin keeps no derived memo** (`.nornir/twin-transaction-log.md` C2): the znippy
470 /// engine's persisted commit graph (`objects.graph`) is off for every store this process
471 /// opens from now on. Process-wide, because the box's role is — called when stores open as
472 /// a twin and when a fresh box becomes one. The gix engine keeps no such file.
473 pub fn hold_no_graph_memo(self) {
474 match self {
475 #[cfg(feature = "engine-znippy")]
476 Engine::Znippy => znippy_engine::set_graph_cache_default(false),
477 #[cfg(feature = "engine-gix")]
478 Engine::Gix => {}
479 }
480 }
481
482 /// **A twin holds no read cache and resolves narrow** (`twin-memory-bound`, and lane T3's
483 /// one-medium run): the znippy engine's explode policy is `off`, its explode budget is
484 /// [`TWIN_EXPLODE_BUDGET_BYTES`], and the indexer's resolver runs [`TWIN_RESOLVE_WORKERS`]
485 /// wide with [`TWIN_RESOLVE_LOOKAHEAD_BYTES`] of lookahead, for every store this
486 /// process opens from now on. MEASURED: +36 MiB of RssAnon for a 512 MiB snapshot install
487 /// against a 64 MiB ceiling, against +149 MiB unbounded — and, with the policy left alone,
488 /// five `objects.exploded` files on a twin's volume that a twin may not hold.
489 ///
490 /// Process-wide, because the box's role is. It is a SIBLING of
491 /// [`Engine::hold_no_graph_memo`] and not folded into it, because the two answer different
492 /// files (`objects.graph` against `objects.exploded` and the resolver's heap) and a reader
493 /// who assumed the graph door covered the cache is exactly how the twin came to hold five
494 /// of them. The gix engine has neither.
495 pub fn hold_twin_memory_bound(self) {
496 match self {
497 #[cfg(feature = "engine-znippy")]
498 Engine::Znippy => znippy_engine::hold_twin_memory_bound(),
499 #[cfg(feature = "engine-gix")]
500 Engine::Gix => {}
501 }
502 }
503
504 /// **And the stores already open when that happened** — their exploded pools unlinked and
505 /// capped, since a store keeps the policy it opened with. Returns the bytes unlinked.
506 pub fn bound_open_stores_to_twin(self) -> u64 {
507 match self {
508 #[cfg(feature = "engine-znippy")]
509 Engine::Znippy => znippy_engine::bound_open_stores_to_twin(),
510 #[cfg(feature = "engine-gix")]
511 Engine::Gix => 0,
512 }
513 }
514
515 /// **`repo gc` reclaims the read cache**: every lazily-exploded object
516 /// unlinked, the commit-and-tree pool kept because that is the graph.
517 /// Returns the bytes that left the filesystem.
518 pub fn drop_explode_cache(self, git_dir: &Path) -> u64 {
519 match self {
520 #[cfg(feature = "engine-znippy")]
521 Engine::Znippy => znippy_engine::drop_explode_cache(git_dir),
522 #[cfg(feature = "engine-gix")]
523 Engine::Gix => {
524 let _ = git_dir;
525 0
526 }
527 }
528 }
529
530 /// **Take one pushed packfile straight into storage, off the wire.**
531 ///
532 /// The bytes go from the transport into the engine and are durable when this
533 /// returns. Nothing is staged through a packfile on the way: a seam spelled
534 /// in paths cannot express a server without them, which is why this one is
535 /// spelled in bytes.
536 ///
537 /// Everything but `want_objects` is engine-neutral and lives in
538 /// [`ingest_common`] — the pack header, the trailer and
539 /// [`GitOps::put_pack`](git_storage_trait::GitOps::put_pack) are the same
540 /// three facts whichever engine stored them. Enumerating the ids a push
541 /// introduced is **not** neutral: a pack entry carries no oid, so an engine
542 /// answers it out of its own index or not at all.
543 ///
544 /// # `progress`, and why it is threaded rather than fetched
545 ///
546 /// `Some(sink)` asks the engine to say how many objects of this pack it has
547 /// taken in **while it is still taking them in**
548 /// ([`GitOps::put_pack_observed`](git_storage_trait::GitOps::put_pack_observed)).
549 /// The count belongs to the code that splits the pack and to nothing else —
550 /// deriving it anywhere on the way here means inflating every entry a second
551 /// time, which is a second pack decoder (one implementation) — so the only thing this
552 /// layer can do is carry the sink down and let the walk publish.
553 ///
554 /// # `before_store`, and why the pushed pack is framed HERE
555 ///
556 /// Called with exactly the bytes the engine is about to store, before it stores them — the
557 /// transaction log's `Pack` frame (`gunnar_core::txlog`, the twin). This path opens the
558 /// engine by `git_dir` and never passes the `OpenRepo::logged` tee, so without it a pushed
559 /// pack was never a frame while its ref update was: MEASURED 2026-09-15 under KVM, the twin
560 /// and a restored primary both refused `refs/heads/main would point at …, which this
561 /// repository does not have`. An error from the hook refuses the push before a byte lands.
562 ///
563 /// `None` is not a degraded mode: a push over SSH or smart HTTP has no job
564 /// record and nobody polling one, so it asks for nothing and pays for
565 /// nothing. The mirror queue is the caller that has a console watching.
566 pub fn ingest_push(
567 self,
568 git_dir: &Path,
569 hash_kind: gix_hash::Kind,
570 pack: &mut dyn BufRead,
571 objects: ObjectSet,
572 progress: Option<&dyn git_storage_trait::PackProgress>,
573 before_store: Option<BeforeStore<'_>>,
574 ) -> Result<PushReceipt> {
575 match self {
576 #[cfg(feature = "engine-znippy")]
577 Engine::Znippy => znippy_engine::ingest(git_dir, hash_kind, pack, objects, progress, before_store),
578 #[cfg(feature = "engine-gix")]
579 Engine::Gix => gix_engine::ingest(git_dir, hash_kind, pack, objects, progress, before_store),
580 }
581 }
582
583 /// `gunnar gc` for one repository: seal, then reclaim.
584 ///
585 /// The order is the correctness argument rather than a preference. Sealing
586 /// folds pending index work into the derived tables; reclaiming computes its
587 /// live set out of those tables. Reclaiming first would judge liveness
588 /// against a graph missing everything pushed since the last drain, and
589 /// tombstone objects reachable only from those commits.
590 ///
591 /// Only the znippy arm has a seal. The gix arm's `gc` reads the packs and
592 /// the ref store directly, so there is no pending index work for a seal to
593 /// fold in, and it reports `sections_sealed: 0` — which is the truth and not
594 /// a stub.
595 pub fn maintain(self, git_dir: &Path, hash_kind: gix_hash::Kind) -> Result<MaintenanceReport> {
596 match self {
597 #[cfg(feature = "engine-znippy")]
598 Engine::Znippy => znippy_engine::maintain(git_dir, hash_kind),
599 #[cfg(feature = "engine-gix")]
600 Engine::Gix => gix_engine::maintain(git_dir, hash_kind),
601 }
602 }
603}
604
605/// The refusal a build that does not carry `name` gives when asked for it.
606///
607/// Written out in full rather than as "unsupported", because the operator's next
608/// action is a rebuild and this is where they find out which flag.
609#[allow(dead_code)]
610fn not_compiled_in(name: &str) -> anyhow::Error {
611 anyhow!(
612 "this binary was built without the `{name}` storage engine; it carries {}. Rebuild with \
613 `--features engine-{name}`, or select an engine that is here. Refusing rather than \
614 serving from a different engine: a server that quietly answers out of the wrong store \
615 is worse than one that will not start.",
616 Engine::compiled_list()
617 )
618}
619
620/// **One repository, opened on whichever engine this process carries.**
621///
622/// The value [`Engine::open_repo`] hands back, and the *only* implementor of
623/// [`RepoStore`] in gunnar. It is a **value**, not a seam: nothing in it can be
624/// implemented by a backend and no engine can behave differently through it —
625/// the engine-specific part is the one line in each adapter that constructs it.
626/// It replaced two near-identical `RepoStore` impls (`ZnippyRepo`, `GixRepo`)
627/// that differed only in which concrete store they held.
628///
629/// # Why it exists at all
630///
631/// `gunnar-server` is a *serving* process, and everything it does with a
632/// repository needs the **reading** contract:
633/// [`UploadPack::new`](gunnar_wire::UploadPack::new) and
634/// [`ReceivePack::new`](gunnar_wire::ReceivePack::new) take `&dyn GitServe`,
635/// `gunnar-policy`'s ancestry walk and signature gate take `&dyn GitServe`, and
636/// `HEAD` is reachable only through [`GitServe::head`] / [`GitServe::set_head`]
637/// because the `GitServe` HEAD ruling settled that it is not a row in `GitOps::refs`.
638///
639/// # ONE handle, since [`RepoStore::git`] widened — 2026-08-10
640///
641/// This struct carried **two** fat pointers to the same store, `ops` and
642/// `serve`, for one day: `RepoStore::git()` returned `Arc<dyn GitOps>`, the
643/// hand-off from the reading half to the storage half is one-way, and `dyn` has
644/// no downcast — so a server that had only the narrow handle could not serve a
645/// clone from it. The fix was one line in `gunnar-store`:
646/// `git()` hands out `Arc<dyn GitServe>`, which is still one method and still
647/// One repository's index work, engine-neutral. See
648/// `znippy_plugin_git::progress::Snapshot` for the source of every field.
649#[derive(Debug, Clone, PartialEq, Eq)]
650pub struct IndexSnapshot {
651 pub active: bool,
652 pub resumed: bool,
653 pub pack_id: u64,
654 pub objects_done: u64,
655 pub objects_total: u64,
656 pub bytes_done: u64,
657 pub bytes_total: u64,
658 pub phase: &'static str,
659 pub started_at_ms: u64,
660 pub reinflations: u64,
661 pub pauses: u64,
662}
663
664/// One repository's exploded read cache: what it holds, what it may hold, and
665/// what has already been evicted from it. Engine-neutral by construction —
666/// `znippy_plugin_git::exploded_cache::CacheUse` copied field for field, so a
667/// dashboard cell cannot tell which engine answered.
668#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
669pub struct ExplodeBand {
670 /// The pool the indexer writes at push time (commits and trees). Never
671 /// evicted: it is what `graph`, `reach` and a tag peel read.
672 pub eager_bytes: u64,
673 /// The pool first reads fill and the budget empties.
674 pub lazy_bytes: u64,
675 /// The cap. `0` is unlimited.
676 pub budget: u64,
677 pub segments: u64,
678 pub evicted_segments: u64,
679 /// Bytes that actually left the filesystem, measured before the unlink.
680 pub evicted_bytes: u64,
681 pub written_back: u64,
682}
683
684impl ExplodeBand {
685 pub fn used(&self) -> u64 {
686 self.eager_bytes + self.lazy_bytes
687 }
688 /// True while the repository is at or over its quota — the row an operator
689 /// wants to find. Not an error state: it reads correctly and more slowly.
690 pub fn at_limit(&self) -> bool {
691 self.budget != 0 && self.used() >= self.budget
692 }
693}
694
695impl IndexSnapshot {
696 pub fn percent(&self) -> u64 {
697 (self.objects_done * 100)
698 .checked_div(self.objects_total)
699 .map_or(0, |percent| percent.min(100))
700 }
701}
702
703/// one authority because `GitServe` **is** `GitOps`.
704///
705/// So the second field is gone, and with it the paragraph that explained why
706/// two fields were not two handles and why they could not be one upcast (trait
707/// upcasting stabilised in Rust 1.86 and this workspace declares `1.85`). There
708/// is one `Arc`, one refcount cell, and one view of one transaction.
709pub struct OpenRepo {
710 git_dir: PathBuf,
711 hash_kind: gix_hash::Kind,
712 serve: Arc<dyn GitServe>,
713 /// The engine's store UNDER the transaction-log tee, when `serve` is the
714 /// tee. The twin applies the primary's frames through this one, so its
715 /// own log holds the primary's numbers and never a second copy.
716 raw: Option<Arc<dyn GitServe>>,
717}
718
719impl OpenRepo {
720 /// Wrap one engine's concrete store.
721 ///
722 /// `S: GitServe` is the requirement that keeps an engine honest: a backend
723 /// that implements only the storage half cannot be opened by this server,
724 /// and the refusal is a compile error in the engine's adapter rather than a
725 /// repository that opens and then cannot answer a clone.
726 pub(crate) fn new<S: GitServe + 'static>(
727 git_dir: &Path,
728 hash_kind: gix_hash::Kind,
729 store: Arc<S>,
730 ) -> Arc<Self> {
731 Arc::new(OpenRepo {
732 git_dir: git_dir.to_path_buf(),
733 hash_kind,
734 serve: store as Arc<dyn GitServe>,
735 raw: None,
736 })
737 }
738
739 /// **The reading contract**: inflated objects, headers, `HEAD`, `emit_pack`,
740 /// `select` — and, by the `GitServe: GitOps` supertrait, the eleven as well.
741 ///
742 /// This is what the serving path holds. It blocks: every caller below is
743 /// already inside `spawn_blocking` or on a control-plane worker, and
744 /// The two-tier rule keeps the serving tier serial — request concurrency, no
745 /// intra-request fan-out.
746 pub fn serve(&self) -> &dyn GitServe {
747 self.serve.as_ref()
748 }
749
750 /// **The same store, behind the transaction log.** Every write through
751 /// the returned handle is a frame in `log` before the engine sees it
752 /// (`gunnar_core::txlog::tee::LoggedGit`); every read is the engine's.
753 /// `repo` is the catalog key and `hash` its hash kind, as the frame
754 /// records them for the twin.
755 pub(crate) fn logged(self: &Arc<Self>, log: Arc<gunnar_core::txlog::Log>, repo: &str, hash: &str) -> Arc<Self> {
756 let tee = gunnar_core::txlog::tee::LoggedGit::new(Arc::clone(&self.serve), log, repo, hash);
757 Arc::new(OpenRepo {
758 git_dir: self.git_dir.clone(),
759 hash_kind: self.hash_kind,
760 serve: Arc::new(tee) as Arc<dyn GitServe>,
761 raw: Some(Arc::clone(&self.serve)),
762 })
763 }
764
765 /// **The store with no log in front of it.** The twin's apply path and
766 /// nothing else: a write here is invisible to this box's transaction log.
767 pub fn unlogged(&self) -> &dyn GitServe {
768 self.raw.as_deref().unwrap_or(self.serve.as_ref())
769 }
770
771 /// Where this repository lives. The inherent twin of
772 /// [`RepoStore::git_dir`], so callers that only want a path do not have to
773 /// have the trait in scope.
774 pub fn git_dir(&self) -> &Path {
775 &self.git_dir
776 }
777
778 /// This repository's hash algorithm — asked, never defaulted. See
779 /// [`RepoStore::hash_kind`].
780 pub fn hash_kind(&self) -> gix_hash::Kind {
781 self.hash_kind
782 }
783}
784
785impl RepoStore for OpenRepo {
786 fn git_dir(&self) -> &Path {
787 &self.git_dir
788 }
789
790 fn hash_kind(&self) -> gix_hash::Kind {
791 self.hash_kind
792 }
793
794 fn git(&self) -> Arc<dyn GitServe> {
795 Arc::clone(&self.serve)
796 }
797}
798
799/// **How badly the caller needs the object set a push introduced.**
800///
801/// A `bool` said only *"enumerate it"*, and that conflated two callers with
802/// very different stakes:
803///
804/// * a **policy gate** over the pushed objects cannot decide without the set,
805/// so an engine that cannot produce one must refuse the push — passing it on
806/// an answer the engine never gave is the silent-yes this whole seam exists
807/// to prevent;
808/// * the **push attestation** wants it and can record its absence
809/// (`new_object_oids_unknown`), so an engine that cannot produce one must still
810/// serve the push.
811///
812/// With one flag, teaching the attestation to ask made every push refuse on the
813/// `gix` arm, which cannot enumerate at all. Two answers, one type.
814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
815pub enum ObjectSet {
816 /// A gate needs it. An engine that cannot answer must return an error.
817 Required,
818 /// Wanted for the record. An engine that cannot answer returns `None` and
819 /// the push is served.
820 Wanted,
821 /// Nobody asked. Do not pay for it.
822 NotNeeded,
823}
824
825/// The hook [`Engine::ingest_push`] runs on the pack before a byte of it is
826/// stored; an error refuses the push.
827pub type BeforeStore<'a> = &'a dyn Fn(&[u8]) -> Result<()>;
828
829/// What one push stored, read back off what was written.
830///
831/// Nothing here is restated from the request: the object count and version come
832/// off the stored pack's own 12-byte header, and `pack_id` is its trailing
833/// checksum.
834#[derive(Debug, Clone, PartialEq, Eq, Default)]
835pub struct PushReceipt {
836 /// From the pack's own header.
837 pub num_objects: u32,
838 /// The pack format version, likewise.
839 pub version: u32,
840 /// The pack's trailing checksum, which is its identity.
841 pub pack_id: Vec<u8>,
842 /// Bytes stored, which for a verbatim store is bytes received.
843 pub pack_bytes: u64,
844 /// The object ids this push introduced, when the caller asked for them.
845 ///
846 /// `None` is **not asked for**, never "none were introduced" — a gate handed
847 /// an empty set would pass, and one handed `None` refuses.
848 pub new_oids: Option<Vec<Vec<u8>>>,
849 /// Wall time of the ingest.
850 pub elapsed_ms: u64,
851}
852
853/// What one maintenance run did. Applied outputs, read off the result.
854#[derive(Debug, Clone, PartialEq, Eq, Default)]
855pub struct MaintenanceReport {
856 /// Which strategy ran, by its own name.
857 pub strategy: String,
858 /// Size before and after — the pair, because a "reclaimed" figure alone
859 /// cannot tell a run that compacted from one that did nothing.
860 pub bytes_before: u64,
861 pub bytes_after: u64,
862 /// Live index rows carried across. Maintenance never changes this, so a run
863 /// that did is a bug and this is how it becomes visible.
864 pub rows: u64,
865 /// Packs whose every object was dead, and whose rows were tombstoned.
866 pub retired_packs: u64,
867 /// Whether the result was read back and checked. `false` is not failure —
868 /// an in-place compaction verifies after it has already committed.
869 pub verified: bool,
870 /// Reserved sections the seal produced. Always `0` on an engine with no
871 /// seal; see [`Engine::maintain`].
872 pub sections_sealed: usize,
873}
874
875// ── the engine-neutral half of a push ────────────────────────────────────────
876
877/// One pushed pack, read off the wire, with the three facts its own bytes carry.
878///
879/// [`Debug`] is written out rather than derived: `bytes` is a whole packfile,
880/// and a derive would put tens of megabytes of it into any `expect_err` message
881/// or `tracing` line that ever touched one.
882pub(crate) struct PushedPack {
883 pub bytes: PackBytes,
884 /// From the pack's own 12-byte header, so nothing derived can disagree.
885 pub num_objects: u32,
886 pub version: u32,
887}
888
889/// Packs at or above this many bytes are spooled to disk while they arrive.
890pub(crate) const SPOOL_ABOVE: usize = 64 << 20;
891
892/// **How many of the first `consumed` bytes of a pushed pack are in its spool
893/// file, on the disk** — the rule [`read_pushed_pack_spooling_at`] follows,
894/// stated once for the space ledger (`crate::push_space::Gated`), which gives a
895/// spooled byte's promise back the moment it is written: below the threshold
896/// the pack is on the heap and nothing is on the disk; the moment it reaches
897/// the threshold everything consumed so far is written out, and from then on
898/// each chunk is written BEFORE it is consumed.
899/// `the_spool_holds_what_spooled_on_disk_says_at_every_consume` holds the two
900/// to each other.
901pub(crate) fn spooled_on_disk(consumed: u64, spool_above: u64) -> u64 {
902 if consumed >= spool_above {
903 consumed
904 } else {
905 0
906 }
907}
908
909/// The pushed bytes: on the heap for an ordinary push, **on disk and mapped**
910/// for a big one.
911///
912/// MEASURED on the KVM rig 2026-08-30: the linux mirror push (8.12
913/// GiB) killed an 8 GB and a 12 GB appliance *while the client was still
914/// sending* — `Kernel panic: System is deadlocked on memory` with no
915/// `store.znippy.ingested` line — because the whole pack was one `Vec<u8>`
916/// grown by `extend_from_slice` (a 16 GiB capacity step past 8 GiB). Above
917/// [`SPOOL_ABOVE`] the bytes now go to a temp file beside the store as they
918/// arrive and come back as a read-only mapping: page cache the kernel can
919/// reclaim, which `MemAvailable` (and the indexer's watchdog) counts as free.
920/// The walk, the closure check and the verbatim append read the mapping the
921/// same way they read a `Vec`.
922pub(crate) enum PackBytes {
923 Heap(Vec<u8>),
924 Spooled {
925 /// Removed when this drops — after `put_pack` has copied the bytes
926 /// into the archive.
927 _file: tempfile::NamedTempFile,
928 map: memmap2::Mmap,
929 },
930}
931
932impl std::ops::Deref for PackBytes {
933 type Target = [u8];
934 fn deref(&self) -> &[u8] {
935 match self {
936 PackBytes::Heap(v) => v,
937 PackBytes::Spooled { map, .. } => map,
938 }
939 }
940}
941
942impl PartialEq<Vec<u8>> for PackBytes {
943 fn eq(&self, other: &Vec<u8>) -> bool {
944 &**self == other.as_slice()
945 }
946}
947
948impl std::fmt::Debug for PackBytes {
949 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
950 match self {
951 PackBytes::Heap(v) => write!(f, "{} byte(s) on the heap", v.len()),
952 PackBytes::Spooled { map, .. } => write!(f, "{} byte(s) spooled", map.len()),
953 }
954 }
955}
956
957impl std::fmt::Debug for PushedPack {
958 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
959 f.debug_struct("PushedPack")
960 .field("bytes", &format_args!("{} byte(s)", self.bytes.len()))
961 .field("num_objects", &self.num_objects)
962 .field("version", &self.version)
963 .finish()
964 }
965}
966
967/// **Read exactly one packfile off the wire and read its header back out of
968/// it.**
969///
970/// One copy, called from both engine arms (one implementation): the header is a property of
971/// the file, not of the engine that stored it.
972///
973/// # The shape that does not line up, and the deadlock it caused
974///
975/// The seam hands a `&mut dyn BufRead` positioned on the packfile's first byte
976/// and gives **no length**, because the pkt-line framing does not carry one.
977/// `GitOps::put_pack` needs the bytes *as a slice*, because storing them
978/// verbatim is a single append of a caller-owned buffer. Until 2026-08-11 the
979/// two were joined by `std::io::copy`, i.e. by reading to end of stream, under
980/// a comment that said *"nothing follows the pack on that channel"*.
981///
982/// Nothing follows the pack, and that is the bug. A `receive-pack` client does
983/// not hang up after its pack: it holds the channel open and waits for the
984/// status report, which is the next thing this server owes it. So the server
985/// waited for an EOF the client would only send once it had been answered, and
986/// the client waited for an answer the server would only send once it had seen
987/// EOF. `gunnar.real_repo_clone` sat in that state for 452 seconds with **0 s
988/// of CPU on either side**, holding all 12 750 objects of the pack it was
989/// waiting for more of; see [`pack_end`] for the full measurement.
990///
991/// It survived because every client the suite drives happens to close its write
992/// end — stock `git push` does, smart HTTP does — and gunnar's own client, which
993/// does not have to, is the one that found it.
994///
995/// So the pack is **delimited**, the way `git index-pack --stdin` delimits it:
996/// [`pack_end::PackScanner`] walks the entries and stops on the last byte of the
997/// trailer, and this loop consumes from the reader only what the scanner claims.
998/// Everything after it — the report the client is waiting for, or nothing at all
999/// — is left in the stream, untouched.
1000/// Both engine arms pass a directory to spool a big pack into; `None` keeps
1001/// every pack on the heap, which is what the tests below want and what
1002/// [`read_pushed_pack`] is.
1003pub(crate) fn read_pushed_pack_spooling(
1004 pack: &mut dyn BufRead,
1005 hash_kind: gix_hash::Kind,
1006 spool_dir: Option<&Path>,
1007) -> Result<PushedPack> {
1008 read_pushed_pack_spooling_at(pack, hash_kind, spool_dir, SPOOL_ABOVE)
1009}
1010
1011/// [`read_pushed_pack_spooling`] with the threshold as a parameter — a test's
1012/// door to the spool path without a 64 MiB fixture.
1013pub(crate) fn read_pushed_pack_spooling_at(
1014 pack: &mut dyn BufRead,
1015 hash_kind: gix_hash::Kind,
1016 spool_dir: Option<&Path>,
1017 spool_above: usize,
1018) -> Result<PushedPack> {
1019 use std::io::Write as _;
1020 let mut scanner = pack_end::PackScanner::new(hash_kind.len_in_bytes());
1021 let mut bytes = Vec::new();
1022 let mut spool: Option<(tempfile::NamedTempFile, u64)> = None;
1023 while !scanner.done() {
1024 // `fill_buf`/`consume` and not `read`: the scanner is shown a *peek* and
1025 // answers with a prefix length, so the byte after the trailer is never
1026 // taken out of the reader. A `read` would already have removed it.
1027 let used = {
1028 let available = pack
1029 .fill_buf()
1030 .context("reading the pushed packfile off the wire")?;
1031 if available.is_empty() {
1032 if bytes.is_empty() {
1033 bail!("an empty push is not a pack");
1034 }
1035 bail!(
1036 "the pushed pack ended after {} byte(s), in the middle of {}. The client \
1037 hung up mid-push; nothing was stored",
1038 bytes.len(),
1039 scanner.waiting_for()
1040 );
1041 }
1042 let used = scanner.feed(available)?;
1043 match &mut spool {
1044 Some((f, n)) => {
1045 f.write_all(&available[..used])
1046 .context("spooling the pushed pack")?;
1047 *n += used as u64;
1048 }
1049 None => {
1050 bytes.extend_from_slice(&available[..used]);
1051 if let (Some(dir), true) = (spool_dir, bytes.len() >= spool_above) {
1052 // Past the threshold: everything so far goes to disk
1053 // and the heap copy is released. One row, once.
1054 let mut f = tempfile::Builder::new()
1055 .prefix("incoming-")
1056 .suffix(".pack")
1057 .tempfile_in(dir)
1058 .with_context(|| {
1059 format!("spooling the pushed pack under {}", dir.display())
1060 })?;
1061 f.write_all(&bytes).context("spooling the pushed pack")?;
1062 let n = bytes.len() as u64;
1063 bytes = Vec::new();
1064 tracing::info!(
1065 target: "gunnar::store",
1066 event = "push.spooled",
1067 dir = %dir.display(),
1068 threshold = spool_above,
1069 "a pushed pack over {} MiB is spooled to disk while it arrives, not held in RAM",
1070 spool_above >> 20
1071 );
1072 spool = Some((f, n));
1073 }
1074 }
1075 }
1076 used
1077 };
1078 pack.consume(used);
1079 }
1080 let (version, num_objects) = scanner.header();
1081 let bytes = match spool {
1082 None => PackBytes::Heap(bytes),
1083 Some((f, n)) => {
1084 f.as_file().sync_data().ok();
1085 // SAFETY: the file is ours alone — created with a unique name,
1086 // written only above, never written again while mapped, and
1087 // removed when the mapping's owner drops.
1088 let map =
1089 unsafe { memmap2::Mmap::map(f.as_file()) }.context("mapping the spooled pack")?;
1090 tracing::info!(
1091 target: "gunnar::store",
1092 event = "push.spooled.mapped",
1093 bytes = n,
1094 "the spooled pack is complete and mapped for the walk and the append"
1095 );
1096 PackBytes::Spooled { _file: f, map }
1097 }
1098 };
1099 Ok(PushedPack {
1100 bytes,
1101 num_objects,
1102 version,
1103 })
1104}
1105
1106/// The pack's trailing checksum, which is its identity.
1107pub(crate) fn pack_trailer(bytes: &[u8], hash_kind: gix_hash::Kind) -> Result<Vec<u8>> {
1108 let len = hash_kind.len_in_bytes();
1109 bytes
1110 .len()
1111 .checked_sub(len)
1112 .and_then(|at| bytes.get(at..))
1113 .map(<[u8]>::to_vec)
1114 .ok_or_else(|| anyhow!("the pushed pack has no trailing {hash_kind} checksum"))
1115}
1116
1117// `open_secret_log` used to live here — an Arrow secret log at
1118// `<git_dir>/gunnar-secrets`, created by both arms at `open_repo` time to
1119// satisfy `RepoStore::secrets()`.
1120//
1121// It is **deleted** (2026-08-10), and the removal is worth more than the eight
1122// lines it took back. It ran `create_dir_all` on the way past, so opening *any*
1123// repository minted a directory, an `O_EXCL` lock file and a `secrets.arrow`
1124// beside it — per repository, for a store that was never read, on an engine
1125// whose whole design goal is one file per repository. The only secret store the
1126// server ever read was the *control* repository's, and that one is now a table
1127// in `control.redb` (`gunnar_core::secrets`). Neither arm has a secret of its
1128// own to keep, so neither carries one.
1129
1130#[cfg(test)]
1131mod tests {
1132 use super::pack_end::fixture::fixture_pack;
1133 use super::*;
1134
1135 /// ★ **The engine is reported in parts: its name, what it is, and where
1136 /// its source lives** (owner, 2026-10-02: the Storage page read "Engine
1137 /// Backend znippy-plugin-git (Arrow-IPC archive; objects + refs)"; it
1138 /// shows the name, the description as a tooltip, and links the name). The
1139 /// repository URL comes from ONE constant per forge.
1140 #[test]
1141 fn the_engine_is_reported_as_name_description_and_source_repository() {
1142 for engine in Engine::COMPILED {
1143 let r = engine.report();
1144 assert!(!r.name.is_empty() && !r.name.contains(' '), "{engine:?}: the name is one word: {r:?}");
1145 assert!(!r.description.is_empty() && !r.description.contains(&r.name), "{engine:?}: {r:?}");
1146 assert!(r.repo_url.starts_with("https://"), "{engine:?}: {r:?}");
1147 assert_eq!(engine.describe(), format!("{} ({})", r.name, r.description), "the CLI's line is the same parts");
1148 }
1149 #[cfg(feature = "engine-znippy")]
1150 {
1151 let z = Engine::Znippy.report();
1152 assert_eq!((z.name.as_str(), z.description.as_str()), ("znippy-plugin-git", "Arrow-IPC archive; objects + refs"));
1153 assert_eq!(z.repo_url, format!("{NORDISK_REPOS}/znippy"));
1154 assert_eq!(z.repo_url, "https://codeberg.org/nordisk/znippy", "znippy is on codeberg, not on gunnar.rs (yet)");
1155 }
1156 #[cfg(feature = "engine-gix")]
1157 {
1158 let g = Engine::Gix.report();
1159 assert_eq!(g.name, "storage-git-gix");
1160 assert_eq!(g.repo_url, format!("{NORDISK_REPOS}/edda"), "storage-git-gix's source is edda");
1161 }
1162 }
1163
1164 /// [`read_pushed_pack_spooling`] with no spool directory: every pack stays
1165 /// on the heap. **Test-only.** Both engine arms pass a real directory, so
1166 /// a `pub(crate)` version of this shim was dead code in every shipped
1167 /// binary while looking like a production entry point — it is the shape
1168 /// `dead_code` caught on 2026-09-03.
1169 fn read_pushed_pack(pack: &mut dyn BufRead, hash_kind: gix_hash::Kind) -> Result<PushedPack> {
1170 read_pushed_pack_spooling(pack, hash_kind, None)
1171 }
1172
1173 /// **A `receive-pack` client, as it behaves after it has sent its pack: it
1174 /// waits, holding the channel open, for the status report.**
1175 ///
1176 /// Every byte of the pack is handed over. Then the reader does the only
1177 /// honest thing a socket can do here — it refuses to produce a byte,
1178 /// because there is not going to be one until the server speaks — and it
1179 /// counts how many times it was asked.
1180 ///
1181 /// **The refusal is an error rather than a block on purpose.** A reader
1182 /// that blocked would turn this into a test with a stopwatch in it, which
1183 /// passes on a fast machine and fails on a slow one and proves nothing
1184 /// about either. `reads_past_the_pack` is the assertion that cannot lie:
1185 /// the correct number is zero, and it is zero for reasons of protocol, not
1186 /// of timing.
1187 struct ClientWaitingForItsReport {
1188 stream: Vec<u8>,
1189 at: usize,
1190 pack_len: usize,
1191 reads_past_the_pack: usize,
1192 }
1193
1194 impl ClientWaitingForItsReport {
1195 fn holding(pack: &[u8]) -> Self {
1196 ClientWaitingForItsReport {
1197 stream: pack.to_vec(),
1198 at: 0,
1199 pack_len: pack.len(),
1200 reads_past_the_pack: 0,
1201 }
1202 }
1203
1204 /// The same client, with a report already sitting behind the pack. Used
1205 /// to prove the reader is left *positioned* correctly and not merely
1206 /// unblocked: a delimiter that stopped one byte late would pass the
1207 /// test above and desynchronise the session here.
1208 fn with_a_report_behind_the_pack(pack: &[u8], report: &[u8]) -> Self {
1209 let mut stream = pack.to_vec();
1210 stream.extend_from_slice(report);
1211 ClientWaitingForItsReport {
1212 stream,
1213 at: 0,
1214 pack_len: pack.len(),
1215 reads_past_the_pack: 0,
1216 }
1217 }
1218
1219 fn what_is_left(&self) -> &[u8] {
1220 &self.stream[self.at..]
1221 }
1222 }
1223
1224 impl std::io::Read for ClientWaitingForItsReport {
1225 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1226 let available = self.fill_buf()?.len().min(buf.len());
1227 buf[..available].copy_from_slice(&self.stream[self.at..self.at + available]);
1228 self.consume(available);
1229 Ok(available)
1230 }
1231 }
1232
1233 impl BufRead for ClientWaitingForItsReport {
1234 fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
1235 if self.at >= self.pack_len && self.at >= self.stream.len() {
1236 self.reads_past_the_pack += 1;
1237 return Err(std::io::Error::new(
1238 std::io::ErrorKind::WouldBlock,
1239 "the client sent its whole pack and is waiting for the status report; this \
1240 read would block for ever",
1241 ));
1242 }
1243 // Handed over in small pieces, because a socket does that and a
1244 // delimiter that only works on one whole buffer is a delimiter that
1245 // works in tests.
1246 let end = self.stream.len().min(self.at + 997);
1247 Ok(&self.stream[self.at..end])
1248 }
1249
1250 fn consume(&mut self, amount: usize) {
1251 self.at += amount;
1252 }
1253 }
1254
1255 /// **RED-WHEN-BROKEN: the push that deadlocked `gunnar.real_repo_clone`.**
1256 ///
1257 /// Watched go red against the `std::io::copy` this replaced, 2026-08-11:
1258 ///
1259 /// ```text
1260 /// ---- storage::tests::the_pack_read_stops_at_the_trailer_and_never_waits_for_eof stdout ----
1261 /// reading the pushed packfile off the wire
1262 /// Caused by: the client sent its whole pack and is waiting for the status
1263 /// report; this read would block for ever
1264 /// ```
1265 ///
1266 /// On the wire that error is not an error. It is 452 seconds of two
1267 /// processes at 0% CPU with an empty socket between them.
1268 /// **A big pack is spooled, not held**: with the threshold at
1269 /// zero the same bytes come back through a mapping of a temp file beside
1270 /// the store, the file exists while the pack is held and is gone when it
1271 /// drops. Seen RED by returning `PackBytes::Heap` from the spool arm: the
1272 /// temp dir stays empty.
1273 #[test]
1274 fn a_pack_over_the_threshold_is_spooled_to_disk_and_mapped() {
1275 let pack = fixture_pack();
1276 let dir = tempfile::tempdir().expect("tempdir");
1277 let mut client = std::io::Cursor::new(pack.clone());
1278 let pushed =
1279 read_pushed_pack_spooling_at(&mut client, gix_hash::Kind::Sha1, Some(dir.path()), 0)
1280 .expect("a well-formed pack");
1281 assert!(
1282 matches!(pushed.bytes, PackBytes::Spooled { .. }),
1283 "{:?}",
1284 pushed.bytes
1285 );
1286 assert_eq!(pushed.bytes, pack, "the mapped pack is not the pack sent");
1287 assert_eq!(pushed.bytes.len(), pack.len());
1288 let spooled: Vec<_> = std::fs::read_dir(dir.path()).unwrap().flatten().collect();
1289 assert_eq!(
1290 spooled.len(),
1291 1,
1292 "one incoming-*.pack while the pack is held"
1293 );
1294 assert!(spooled[0]
1295 .file_name()
1296 .to_string_lossy()
1297 .starts_with("incoming-"));
1298 assert_eq!(
1299 pack_trailer(&pushed.bytes, gix_hash::Kind::Sha1)
1300 .unwrap()
1301 .len(),
1302 20
1303 );
1304 drop(pushed);
1305 assert_eq!(
1306 std::fs::read_dir(dir.path()).unwrap().count(),
1307 0,
1308 "the spool file outlived the pack"
1309 );
1310 // Under the threshold: the heap, and nothing on disk.
1311 let mut client = std::io::Cursor::new(pack.clone());
1312 let pushed = read_pushed_pack_spooling_at(
1313 &mut client,
1314 gix_hash::Kind::Sha1,
1315 Some(dir.path()),
1316 usize::MAX,
1317 )
1318 .unwrap();
1319 assert!(matches!(pushed.bytes, PackBytes::Heap(_)));
1320 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1321 }
1322
1323 /// ★ The space ledger's rule for the spool ([`spooled_on_disk`]) is what
1324 /// the spool file actually holds, at every consume of the read — the ledger
1325 /// gives a byte's spool promise back at `consume`, so a rule that ran ahead
1326 /// of the write would be a byte nobody counts.
1327 #[test]
1328 fn the_spool_holds_what_spooled_on_disk_says_at_every_consume() {
1329 struct Probe<'a> {
1330 inner: std::io::Cursor<Vec<u8>>,
1331 dir: &'a Path,
1332 consumed: u64,
1333 threshold: u64,
1334 checked: u32,
1335 }
1336 impl std::io::Read for Probe<'_> {
1337 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1338 std::io::Read::read(&mut self.inner, buf)
1339 }
1340 }
1341 impl BufRead for Probe<'_> {
1342 fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
1343 let b = self.inner.fill_buf()?;
1344 Ok(&b[..b.len().min(37)])
1345 }
1346 fn consume(&mut self, n: usize) {
1347 self.inner.consume(n);
1348 self.consumed += n as u64;
1349 let on_disk: u64 = std::fs::read_dir(self.dir)
1350 .unwrap()
1351 .flatten()
1352 .map(|e| e.metadata().unwrap().len())
1353 .sum();
1354 assert_eq!(
1355 on_disk,
1356 spooled_on_disk(self.consumed, self.threshold),
1357 "after {} byte(s) consumed",
1358 self.consumed
1359 );
1360 self.checked += 1;
1361 }
1362 }
1363 let pack = fixture_pack();
1364 let threshold = (pack.len() / 3) as u64;
1365 let dir = tempfile::tempdir().unwrap();
1366 let mut probe = Probe {
1367 inner: std::io::Cursor::new(pack.clone()),
1368 dir: dir.path(),
1369 consumed: 0,
1370 threshold,
1371 checked: 0,
1372 };
1373 let pushed = read_pushed_pack_spooling_at(
1374 &mut probe,
1375 gix_hash::Kind::Sha1,
1376 Some(dir.path()),
1377 threshold as usize,
1378 )
1379 .unwrap();
1380 assert_eq!(pushed.bytes, pack);
1381 assert!(probe.checked > 3, "{} consume(s)", probe.checked);
1382 }
1383
1384 #[test]
1385 fn the_pack_read_stops_at_the_trailer_and_never_waits_for_eof() {
1386 let pack = fixture_pack();
1387 let mut client = ClientWaitingForItsReport::holding(&pack);
1388
1389 let pushed = read_pushed_pack(&mut client, gix_hash::Kind::Sha1)
1390 .expect("the pack is complete; nothing more is owed before the report");
1391
1392 assert_eq!(
1393 client.reads_past_the_pack, 0,
1394 "the server made {} read(s) past the last byte of the pack. There is nobody to \
1395 answer them: the client is waiting for the status report those reads come before, \
1396 and on a socket this is not an error, it is a deadlock",
1397 client.reads_past_the_pack
1398 );
1399 assert_eq!(pushed.bytes, pack, "the stored pack is not the pack sent");
1400 assert_eq!(pushed.num_objects, 4);
1401 assert_eq!(pushed.version, 2);
1402 }
1403
1404 /// The reader is left **positioned** on the byte after the trailer, not
1405 /// merely unblocked. Smart HTTP already worked with an over-reading server
1406 /// because its body ends; this is what a session that carries on needs.
1407 #[test]
1408 fn what_follows_the_pack_is_left_in_the_stream() {
1409 let pack = fixture_pack();
1410 let report = b"0009done\n0000";
1411 let mut client = ClientWaitingForItsReport::with_a_report_behind_the_pack(&pack, report);
1412
1413 let pushed =
1414 read_pushed_pack(&mut client, gix_hash::Kind::Sha1).expect("a well-formed pack");
1415
1416 assert_eq!(pushed.bytes.len(), pack.len());
1417 assert_eq!(
1418 client.what_is_left(),
1419 report,
1420 "the bytes after the pack were swallowed, so whatever spoke next would be read \
1421 out of the middle of the pack"
1422 );
1423 assert_eq!(client.reads_past_the_pack, 0);
1424 }
1425
1426 /// A client that hangs up mid-pack is a **refusal**, not a stored half
1427 /// push. The old code could not tell this from a completed one: both ended
1428 /// at EOF.
1429 #[test]
1430 fn a_client_that_hangs_up_mid_pack_is_refused_and_says_where() {
1431 let pack = fixture_pack();
1432 let truncated = &pack[..pack.len() - 4];
1433 let mut client = std::io::Cursor::new(truncated.to_vec());
1434
1435 let err = read_pushed_pack(&mut client, gix_hash::Kind::Sha1)
1436 .expect_err("half a pack must not be accepted as a push");
1437 let message = format!("{err:#}");
1438 assert!(
1439 message.contains("trailing checksum") && message.contains("nothing was stored"),
1440 "the refusal must name what never arrived: {message}"
1441 );
1442 }
1443
1444 /// A push with no bytes at all keeps the message it always had.
1445 #[test]
1446 fn an_empty_push_is_still_not_a_pack() {
1447 let mut nothing = std::io::Cursor::new(Vec::new());
1448 let err = read_pushed_pack(&mut nothing, gix_hash::Kind::Sha1).expect_err("no bytes");
1449 assert!(format!("{err:#}").contains("an empty push is not a pack"));
1450 }
1451
1452 /// The default is znippy wherever znippy is compiled in. Asserted rather
1453 /// than commented, because "defaulting to the other engine" is a change no
1454 /// output of a working server would show.
1455 #[test]
1456 #[cfg(feature = "engine-znippy")]
1457 fn the_default_is_znippy() {
1458 assert_eq!(DEFAULT_ENGINE, Engine::Znippy);
1459 assert_eq!(Engine::COMPILED[0], Engine::Znippy);
1460 }
1461
1462 /// Every compiled engine answers to its own name. A rename that broke this
1463 /// would make `GUNNAR_ENGINE` silently unusable.
1464 #[test]
1465 fn every_compiled_engine_answers_to_its_name() {
1466 for engine in Engine::COMPILED {
1467 assert_eq!(
1468 Engine::from_name(engine.name()).expect("a compiled engine resolves"),
1469 *engine
1470 );
1471 }
1472 }
1473
1474 /// An unknown name is refused, and the message says what is here.
1475 #[test]
1476 fn an_unknown_engine_is_refused() {
1477 let err = Engine::from_name("postgres").expect_err("not an engine");
1478 let msg = format!("{err:#}");
1479 assert!(msg.contains("unknown storage engine"), "{msg}");
1480 assert!(msg.contains(&Engine::compiled_list()), "{msg}");
1481 }
1482
1483 /// **The rule, asserted in the build that can violate it.**
1484 ///
1485 /// In a znippy-only build, asking for gix must refuse and say the engine was
1486 /// not compiled in — never fall back to znippy. This test only exists in the
1487 /// build where the fallback is possible, which is the build where it matters.
1488 #[test]
1489 #[cfg(all(feature = "engine-znippy", not(feature = "engine-gix")))]
1490 fn an_engine_that_was_not_compiled_in_refuses_rather_than_falling_back() {
1491 let err = Engine::from_name("gix").expect_err("gix is not in this build");
1492 let msg = format!("{err:#}");
1493 assert!(
1494 msg.contains("built without the `gix` storage engine"),
1495 "{msg}"
1496 );
1497 assert!(msg.contains("--features engine-gix"), "{msg}");
1498 }
1499
1500 /// The same, the other way round, in the gix-only build.
1501 #[test]
1502 #[cfg(all(feature = "engine-gix", not(feature = "engine-znippy")))]
1503 fn znippy_is_refused_when_it_was_not_compiled_in() {
1504 let err = Engine::from_name("znippy").expect_err("znippy is not in this build");
1505 assert!(
1506 format!("{err:#}").contains("built without the `znippy` storage engine"),
1507 "{err:#}"
1508 );
1509 }
1510}