Skip to main content

gunnar_server/
admin.rs

1//! The administrative verbs: namespaces, repositories, principals, keys,
2//! grants, and `gc`.
3//!
4//! These run against the same [`RepoCatalog`] and [`AuthTable`] the gRPC
5//! control plane serves, so a repository created here is immediately visible
6//! there and vice versa. There is no second model and no separate file format
7//! (one implementation).
8
9use anyhow::{bail, Context, Result};
10use gunnar_core::observe::TARGET_STORE;
11use gunnar_core::{Fingerprint, Grant, NewRepo, Repo, RepoRef, Scope, Visibility};
12
13use crate::storage::MaintenanceReport;
14use crate::stores::ServerStores;
15use gunnar_common::cli::{
16    GcArgs, GrantArgs, KeyCommand, NamespaceCommand, PrincipalCommand, ReflogArgs, RepoCommand,
17    RevokeArgs,
18};
19
20/// `<namespace>/<name>`, through the same sandbox the SSH listener uses.
21///
22/// One resolver, not two: a path the CLI accepts and the SSH path sandbox
23/// rejects would be a repository nobody could clone (one implementation).
24fn repo_ref(path: &str) -> Result<RepoRef> {
25    gunnar_core::resolve_repo_path(path)
26        .with_context(|| format!("`{path}` is not a valid <namespace>/<name>"))
27}
28
29/// A certificate's DER, read from a file holding either PEM or DER.
30///
31/// The unwrap itself lives in `gunnar-core` beside the fingerprint, because the
32/// two must agree about what the certificate bytes are.
33fn read_certificate(path: &std::path::Path) -> Result<Vec<u8>> {
34    let raw = std::fs::read(path)
35        .with_context(|| format!("reading the certificate at {}", path.display()))?;
36    Ok(gunnar_core::auth::certificate_der(&raw)?)
37}
38
39/// An OpenSSH public key line, or `@<path>`.
40fn key_argument(spec: &str) -> Result<String> {
41    match spec.strip_prefix('@') {
42        Some(path) => std::fs::read_to_string(path)
43            .with_context(|| format!("reading the public key from {path}"))
44            .map(|text| text.trim().to_owned()),
45        None => Ok(spec.trim().to_owned()),
46    }
47}
48
49// ── the certificate issuer ─────────────────────────────────────────────────
50
51/// **The PKI document, as one value** — the issuer and the trusted-CA list.
52///
53/// The ONE reader (one implementation). `gunnar issuer show` prints it, and
54/// `CoreControlPlane::issuer_info` puts it on the wire for `Admin.IssuerInfo`;
55/// neither assembles its own, so a box cannot say one thing on its own disk
56/// and another over the control plane.
57///
58/// Takes an already-opened [`gunnar_core::Pki`] rather than the stores,
59/// because the CAPABILITY is the caller's decision: both callers hand it
60/// `ServerStores::pki_readonly()`, whose secret store refuses every call, and
61/// this function has no way to reach a private key whatever it is given — the
62/// two types it reads out of, `Issuer` and `TrustedCa`, have no field that
63/// could hold one.
64pub fn issuer_document(pki: &gunnar_core::Pki) -> Result<gunnar_proto::IssuerInfoResponse> {
65    let trusted = pki
66        .trusted_cas()
67        .context("reading the trusted-CA list")?
68        .into_iter()
69        .map(|ca| gunnar_proto::TrustedCaInfo {
70            label: ca.label,
71            fingerprint: ca.fingerprint.to_string(),
72            cert_pem: ca.cert_pem,
73            added_unix_ms: ca.added,
74            is_issuer: ca.is_issuer,
75        })
76        .collect();
77    Ok(match pki.issuer().context("reading the issuer")? {
78        Some(live) => gunnar_proto::IssuerInfoResponse {
79            has_issuer: true,
80            subject: live.subject,
81            fingerprint: live.fingerprint.to_string(),
82            cert_pem: live.cert_pem,
83            created_unix_ms: live.created,
84            origin: live.origin.to_string(),
85            trusted,
86        },
87        // Every other field empty, and `has_issuer` is what says so: a data
88        // root that has never been served has never minted one, and that is a
89        // different fact from "the document could not be read" — which is an
90        // error here, never an empty answer.
91        None => gunnar_proto::IssuerInfoResponse {
92            has_issuer: false,
93            trusted,
94            ..Default::default()
95        },
96    })
97}
98
99/// Run a `gunnar issuer …` verb.
100pub fn issuer(stores: &ServerStores, command: gunnar_common::cli::IssuerCommand) -> Result<()> {
101    use gunnar_common::cli::IssuerCommand;
102
103    match command {
104        IssuerCommand::Create { subject, days } => {
105            // Refused, loudly, on a server that already has one. That refusal
106            // lives in `gunnar_core::issuer` — in the document's own
107            // compare-and-swap, not only in a check this command could skip.
108            let created = stores.pki()?.create(&subject, days)?;
109            println!(
110                "issuer created: {} ({})",
111                created.subject, created.fingerprint
112            );
113            eprintln!(
114                "its private key is sealed in the secret store and is not in the control \
115                 database. There is no way to print it, and that is deliberate: a key that can \
116                 mint any user is the one whose loss means an attacker issues themselves an \
117                 administrator"
118            );
119        }
120        IssuerCommand::Import { cert, key } => {
121            let cert_pem = std::fs::read_to_string(&cert)
122                .with_context(|| format!("reading the CA certificate at {}", cert.display()))?;
123            let key_pem = std::fs::read_to_string(&key)
124                .with_context(|| format!("reading the CA private key at {}", key.display()))?;
125            let imported = stores.pki()?.import(&cert_pem, &key_pem)?;
126            println!(
127                "issuer imported: {} ({})",
128                imported.subject, imported.fingerprint
129            );
130        }
131        IssuerCommand::Show { pem, at, identity: _ } => {
132            // `--at` is the SAME verb asked over the wire, and it is dispatched
133            // in `gunnar-cli` before this arm is ever reached. Getting here
134            // with one means this binary was built without the control client,
135            // and the honest answer is to say so — not to silently read the
136            // local data root and let the operator believe they were told
137            // about the box they named.
138            if let Some(at) = at {
139                bail!(
140                    "`--at {at}` asks a running server, and this binary carries no control                      client (built without the `control` feature). Without it, `gunnar issuer                      show` can only read the data root it is standing in"
141                );
142            }
143            // The READ-ONLY handle: `show` is a control-plane read and must
144            // work on a binary with no storage engine, and it must be unable to
145            // reach the private key even if this arm were later edited.
146            //
147            // Through the SAME reader and the SAME renderer `Admin.IssuerInfo`
148            // uses (one implementation), so the lines printed here and the
149            // lines `--at` prints cannot disagree about one document.
150            print!("{}", issuer_document(&stores.pki_readonly()?)?.render(pem));
151        }
152        IssuerCommand::Trust { label, cert } => {
153            let cert_pem = std::fs::read_to_string(&cert)
154                .with_context(|| format!("reading the CA certificate at {}", cert.display()))?;
155            let label = if label.is_empty() {
156                cert.file_name()
157                    .map(|name| name.to_string_lossy().into_owned())
158                    .unwrap_or_else(|| "ca".to_owned())
159            } else {
160                label
161            };
162            let added = stores.pki()?.trust_ca(&label, &cert_pem)?;
163            println!("trusting {} ({})", added.label, added.fingerprint);
164        }
165        // Printed through `Display` rather than assembled here: `serve.rs`
166        // refuses with the same words out of the same type, and a second
167        // renderer is a second story about one server.
168        IssuerCommand::Readiness => {
169            print!("{}", crate::tls_preflight::preflight(stores)?);
170            println!();
171        }
172        IssuerCommand::Untrust { fingerprint } => {
173            let parsed = Fingerprint::parse(&fingerprint)?;
174            match stores.pki()?.untrust_ca(&parsed)? {
175                Some(removed) => println!("no longer trusting {} ({parsed})", removed.label),
176                None => bail!("no trusted CA has fingerprint {parsed}"),
177            }
178        }
179    }
180    Ok(())
181}
182
183/// Write an issued certificate — and, on the weaker path, its key — and
184/// register it to the principal it names.
185///
186/// The file names match what `cert-helper` writes and therefore what
187/// `bench/src/trust.rs` produces, so an operator holding both kinds of material
188/// does not have to know which tool made which.
189///
190/// The private key is written at `0600` on unix, and the mode is set at
191/// **creation** rather than after: a key that is world-readable for the
192/// microseconds between `write` and `set_permissions` is a key that was
193/// world-readable.
194fn hand_over(
195    stores: &ServerStores,
196    dir: &std::path::Path,
197    issued: &gunnar_core::Issued,
198) -> Result<()> {
199    std::fs::create_dir_all(dir)
200        .with_context(|| format!("creating the output directory {}", dir.display()))?;
201
202    let cert_path = dir.join(format!("{}_cert.pem", issued.principal_id));
203    std::fs::write(&cert_path, &issued.cert_pem)
204        .with_context(|| format!("writing {}", cert_path.display()))?;
205
206    // Registered as well as issued. The certificate-identity plan keeps the
207    // stronger form available at no cost: the CN gives the identity and the
208    // fingerprint gives the registration, and a deployment that wants both to
209    // agree needs the fingerprint to be in the table.
210    let registered =
211        stores
212            .auth()
213            .register_certificate(&issued.principal_id, &issued.cert_der, None)?;
214
215    println!(
216        "issued {} -> {}",
217        registered.fingerprint_sha256,
218        cert_path.display()
219    );
220
221    if let Some(key_pem) = &issued.key_pem {
222        let key_path = dir.join(format!("{}_pkey.pem", issued.principal_id));
223        write_private(&key_path, key_pem)?;
224        println!("private key -> {}", key_path.display());
225        eprintln!(
226            "this key was generated HERE and is now on the wire and on this disk. \
227             `--csr` is the form where it never leaves {}'s machine",
228            issued.principal_id
229        );
230    }
231    Ok(())
232}
233
234/// Write a private key so that it is never, at any instant, readable by anyone
235/// but its owner.
236fn write_private(path: &std::path::Path, contents: &str) -> Result<()> {
237    use std::io::Write as _;
238
239    let mut options = std::fs::OpenOptions::new();
240    options.write(true).create(true).truncate(true);
241    #[cfg(unix)]
242    {
243        use std::os::unix::fs::OpenOptionsExt as _;
244        options.mode(0o600);
245    }
246    // `mode()` applies only when the file is CREATED, so an existing file would
247    // keep whatever permissions it had. Remove it first.
248    if path.exists() {
249        std::fs::remove_file(path)
250            .with_context(|| format!("removing the previous {}", path.display()))?;
251    }
252    let mut file = options
253        .open(path)
254        .with_context(|| format!("creating {}", path.display()))?;
255    file.write_all(contents.as_bytes())
256        .with_context(|| format!("writing {}", path.display()))?;
257    Ok(())
258}
259
260// ── namespaces ──────────────────────────────────────────────────────────────
261
262/// Run a `gunnar namespace …` verb.
263pub fn namespace(stores: &ServerStores, command: NamespaceCommand) -> Result<()> {
264    match command {
265        NamespaceCommand::Create { name } => {
266            let created = stores.catalog().create_namespace(&name)?;
267            println!("created namespace {}", created.name);
268        }
269        NamespaceCommand::List => {
270            let mut names: Vec<String> = stores
271                .catalog()
272                .namespaces()?
273                .into_iter()
274                .map(|ns| ns.name)
275                .collect();
276            names.sort();
277            for name in names {
278                println!("{name}");
279            }
280        }
281        NamespaceCommand::Delete { name, delete_repos } => {
282            let gone = stores.catalog().delete_namespace(&name, delete_repos)?;
283            println!("deleted namespace {name} and {} repositories", gone.len());
284        }
285        NamespaceCommand::SetBudget {
286            name,
287            explode_budget,
288        } => {
289            let Some(spec) = explode_budget else {
290                anyhow::bail!(
291                    "`namespace set-budget` needs a budget to set; today that is \
292                     `--explode-budget <8G|512M|unlimited|default>`"
293                );
294            };
295            let bytes = match spec.trim().to_ascii_lowercase().as_str() {
296                "default" | "" => None,
297                other => Some(
298                    crate::storage::znippy_engine::parse_explode_budget(other)
299                        .context("reading the explode budget")?,
300                ),
301            };
302            let ns = stores
303                .catalog()
304                .set_namespace_explode_budget(&name, bytes)?;
305            // Applied to the stores that are open right now, not only to the
306            // ones opened later — an admin who raises a quota expects the
307            // repository being read this second to get it.
308            for repo in stores.catalog().list(Some(&name))? {
309                if let Ok(dir) = stores.repo_dir(&repo.path()) {
310                    stores.apply_explode_quota(&name, &dir);
311                }
312            }
313            match ns.explode_budget_bytes {
314                Some(0) => println!("{name}: explode budget unlimited"),
315                Some(b) => println!(
316                    "{name}: explode budget {}",
317                    crate::storage::znippy_engine::human_bytes(b)
318                ),
319                None => println!("{name}: explode budget back to the server default"),
320            }
321        }
322    }
323    Ok(())
324}
325
326// ── repositories ────────────────────────────────────────────────────────────
327
328/// Run a `gunnar repo …` verb.
329pub fn repo(stores: &ServerStores, command: RepoCommand) -> Result<()> {
330    match command {
331        RepoCommand::Create {
332            path,
333            hash,
334            default_branch,
335            public,
336            description,
337            adopt,
338        } => {
339            let path = repo_ref(&path)?;
340            let mut spec = NewRepo::new(path.clone())
341                .hash_kind(hash.into())
342                .visibility(if public {
343                    Visibility::Public
344                } else {
345                    Visibility::Private
346                });
347            spec.description = description;
348            if let Some(branch) = default_branch {
349                spec = spec.default_branch(full_branch(&branch));
350            }
351            // The catalog row and the on-disk repository, together and
352            // transactionally, refusing a store already at the name unless
353            // `--adopt`. A catalog entry with nothing behind it is listed by
354            // `repo list` and refused by `git clone`; a stranger's directory
355            // adopted by a create is a deleted repository resurrected.
356            let created = stores.create_repo(spec, adopt)?;
357            let store = stores.repo_store(&created)?;
358            println!(
359                "created {} ({}, default branch {}) at {}",
360                created.key(),
361                created.hash_kind,
362                created.default_branch,
363                store.git_dir().display()
364            );
365        }
366        RepoCommand::List { namespace } => {
367            let mut repos = stores.catalog().list(namespace.as_deref())?;
368            repos.sort_by_key(|repo| repo.key());
369            for repo in repos {
370                println!(
371                    "{}\t{}\t{}\t{:?}",
372                    repo.key(),
373                    repo.hash_kind,
374                    repo.default_branch,
375                    repo.visibility
376                );
377            }
378        }
379        RepoCommand::Delete {
380            path,
381            purge,
382            retention_days,
383        } => {
384            let path = repo_ref(&path)?;
385            // **An ORPHAN is a delete to finish, not a not-found** (2026-08-30):
386            // a store on disk with no row and no tombstone is what an old
387            // catalog-only delete left behind. `--purge` finishes it; a soft
388            // delete of one is refused, because a tombstone that records no
389            // row promises an undelete the server cannot honour. Same rule as
390            // the control plane's `delete_repo`, from the same two helpers.
391            let orphan = stores.is_orphan(&path)?;
392            anyhow::ensure!(
393                purge || !orphan,
394                "{}",
395                crate::stores::ServerStores::orphan_soft_delete_refusal(&path)
396            );
397            // Read the row BEFORE removing it: the tombstone records what the
398            // repository was, and an undelete that guessed the hash kind would
399            // reopen the resurrection the reclaim rules closed from the other side.
400            let removed = match stores.catalog().delete(&path) {
401                Ok(repo) => Some(repo),
402                Err(gunnar_core::Error::NoSuchRepo(_)) if orphan => None,
403                Err(err) => return Err(err.into()),
404            };
405            // Release the handle before anything else, so the store lock is
406            // not left holding a file inside a directory being removed.
407            stores.close_repo(&path);
408            if purge {
409                let dir = stores.repo_dir(&path)?;
410                let (before, after) = stores.purge_repo(&path)?;
411                println!(
412                    "deleted {}{} and purged {} ({} bytes reclaimed)",
413                    path,
414                    if removed.is_none() { " (an ORPHAN: it had no catalog row)" } else { "" },
415                    dir.display(),
416                    before.saturating_sub(after)
417                );
418            } else {
419                let removed = removed.expect("a soft delete of an orphan is refused above");
420                let days = retention_days.unwrap_or(crate::stores::DEFAULT_DELETE_RETENTION_DAYS);
421                let record = stores.soft_delete(
422                    &path,
423                    Some(&removed),
424                    "",
425                    std::time::Duration::from_secs(u64::from(days) * 86_400),
426                )?;
427                let bytes = crate::stores::dir_bytes(&stores.repo_dir(&path)?);
428                println!(
429                    "deleted {} — its {bytes} bytes are KEPT for {days} day(s)",
430                    removed.key()
431                );
432                println!(
433                    "  undelete: gunnar repo undelete {}    purge now: gunnar repo purge {}",
434                    removed.key(),
435                    removed.key()
436                );
437                if let Some(at) = record.purge_after_unix_ms {
438                    println!("  the sweep may reclaim them after unix_ms {at}");
439                }
440                println!("  until then they still count against the account's storage.");
441            }
442        }
443        RepoCommand::Undelete { path } => {
444            let path = repo_ref(&path)?;
445            let spec = stores.undelete_spec(&path)?;
446            // `adopt`: the store on disk is the one this repository always
447            // had, and the tombstone carries the hash kind the adopt path
448            // checks the store's own marker against.
449            let repo = stores.create_repo(spec, true)?;
450            stores.clear_tombstone(&path)?;
451            println!(
452                "undeleted {} ({}, default branch {})",
453                repo.key(),
454                repo.hash_kind,
455                repo.default_branch
456            );
457        }
458        RepoCommand::Purge { path } => {
459            let path = repo_ref(&path)?;
460            anyhow::ensure!(
461                stores.catalog().get(&path)?.is_none(),
462                "{path} is a LIVE repository, not a deleted one. `repo delete {path} --purge` \
463                 deletes and reclaims it in one step; purge alone only ends a retention window."
464            );
465            anyhow::ensure!(
466                stores.tombstone_of(&path)?.is_some(),
467                "{path} is neither catalogued nor tombstoned; there is nothing to purge"
468            );
469            let (before, after) = stores.purge_repo(&path)?;
470            println!(
471                "purged {path} ({} bytes reclaimed); it can no longer be undeleted",
472                before.saturating_sub(after)
473            );
474        }
475        RepoCommand::ListDeleted {} => {
476            let rows = stores.tombstones()?;
477            if rows.is_empty() {
478                println!("nothing is deleted");
479            }
480            let now = gunnar_core::now_ms();
481            let mut total = 0u64;
482            for row in &rows {
483                total += row.bytes;
484                let left = match row.tombstone.seconds_left(now) {
485                    Some(secs) => format!("{} day(s) left", secs / 86_400),
486                    None => "no expiry".to_owned(),
487                };
488                println!("{}\t{} bytes\t{left}", row.path, row.bytes);
489            }
490            if !rows.is_empty() {
491                println!(
492                    "{} deleted repository(ies) holding {total} bytes — still on the account",
493                    rows.len()
494                );
495            }
496        }
497        RepoCommand::ListOrphans { namespace } => {
498            let rows = stores.orphans()?;
499            let rows: Vec<_> = rows
500                .into_iter()
501                .filter(|row| {
502                    namespace
503                        .as_deref()
504                        .is_none_or(|want| row.path.namespace() == want)
505                })
506                .collect();
507            if rows.is_empty() {
508                println!("no orphan stores: every directory under the repository root is either \
509                          catalogued (`repo list`) or soft-deleted (`repo list-deleted`)");
510            }
511            let mut total = 0u64;
512            for row in &rows {
513                total += row.bytes;
514                println!(
515                    "{}\t{} bytes\t{}\tmtime {}\t{}{}",
516                    row.path,
517                    row.bytes,
518                    row.hash_marker.as_deref().unwrap_or("-"),
519                    row.modified_unix_ms,
520                    row.dir.display(),
521                    match &row.failed_reclaim {
522                        Some(note) => format!("\tRECLAIM FAILED: {note}"),
523                        None => String::new(),
524                    }
525                );
526            }
527            if !rows.is_empty() {
528                println!(
529                    "{} orphan store(s) holding {total} bytes — no catalog row, no tombstone",
530                    rows.len()
531                );
532                println!(
533                    "  reclaim the bytes: gunnar repo reclaim <path>    keep them: gunnar repo \
534                     create <path> --adopt --hash <the HASH column>"
535                );
536            }
537        }
538        RepoCommand::Reclaim { path } => {
539            let path = repo_ref(&path)?;
540            let bytes = stores.reclaim_orphan(&path)?;
541            println!("reclaimed {path} ({bytes} bytes); the name is usable again");
542        }
543        #[cfg(feature = "engine-znippy")]
544        RepoCommand::ArchiveOut { .. } => {
545            // Dispatched before the stores are opened (see `lib.rs`): an
546            // archive must be takeable from a box that is serving, and opening
547            // the control store would fight the running server for redb's lock.
548            unreachable!("archive-out is handled by repo_archive_out_without_stores")
549        }
550        #[cfg(feature = "engine-znippy")]
551        RepoCommand::ArchiveIn {
552            file,
553            as_path,
554            no_catalog,
555        } => {
556            let path = repo_ref(&as_path)?;
557            let dir = stores.repo_dir(&path)?;
558            let r = crate::storage::znippy_engine::archive_in(&file, &dir)?;
559            println!(
560                "restored {} bytes into {} (generation {})",
561                r.bytes_in,
562                r.repo_dir.display(),
563                r.to_generation
564            );
565            if !no_catalog {
566                // The hash kind comes from the restored marker, never from a
567                // flag: a repository restored as the wrong hash is a repository
568                // no client can push to, and the archive already carries the
569                // answer.
570                let marker =
571                    std::fs::read_to_string(dir.join(crate::storage::znippy_engine::OBJECT_FORMAT))
572                        .unwrap_or_default();
573                let hash = match marker.trim() {
574                    "sha1" => gunnar_core::HashKind::Sha1,
575                    "sha256" | "" => gunnar_core::HashKind::Sha256,
576                    other => bail!("the archive names an unknown object format {other:?}"),
577                };
578                let spec = NewRepo::new(path.clone()).hash_kind(hash);
579                // `adopt`: the store on disk is the one this command just
580                // wrote, which is the single case where adopting is exactly
581                // right rather than a resurrection.
582                //
583                // **A row that is already there is a SUCCESS, not a clash.**
584                // The control document is restored separately (it carries the
585                // default branch, the visibility and the grants, none of which
586                // are in a repository archive), and when it has been the row
587                // for this repository is already correct. Failing here would
588                // make the two halves of a restore refuse to be done in the
589                // order that keeps the most metadata.
590                match stores.create_repo(spec, true) {
591                    Ok(created) => {
592                        println!(
593                            "catalogued {} ({}, default branch {})",
594                            created.key(),
595                            created.hash_kind,
596                            created.default_branch
597                        );
598                        println!("{}", encrypted_flag_note(&path));
599                    }
600                    Err(e) if format!("{e}").contains("already exists") => {
601                        match stores.catalog().get(&path)? {
602                            Some(have) => println!(
603                                "the catalog already has {} ({}, default branch {}) — restored \
604                                 from the control document, and left alone",
605                                have.key(),
606                                have.hash_kind,
607                                have.default_branch
608                            ),
609                            None => println!("the catalog already has {path}"),
610                        }
611                    }
612                    Err(e) => return Err(e),
613                }
614            }
615        }
616    }
617    Ok(())
618}
619
620/// The first eight bytes of a digest, for a line a human reads.
621#[cfg(feature = "engine-znippy")]
622fn hex_head(d: &[u8; 32]) -> String {
623    d[..8].iter().map(|b| format!("{b:02x}")).collect()
624}
625
626/// **`gunnar repo archive-out`, WITHOUT opening the control store.**
627///
628/// A backup that can only be taken from a stopped server is a backup nobody
629/// takes, so this verb does not go through [`ServerStores`]: it resolves the
630/// repository directory through the same `resolve_repo_path` sandbox the SSH
631/// listener uses and reads three files out of it. Nothing it touches is
632/// written by the running server except by *append*, which is exactly why no
633/// quiesce is needed — see [`crate::storage::archive`].
634#[cfg(feature = "engine-znippy")]
635pub fn repo_archive_out_without_stores(
636    data_root: &std::path::Path,
637    command: RepoCommand,
638) -> Result<()> {
639    let RepoCommand::ArchiveOut {
640        path,
641        to,
642        from_generation,
643    } = command
644    else {
645        unreachable!("called with something other than archive-out")
646    };
647    let path = repo_ref(&path)?;
648    let dir = path
649        .path_under(&data_root.join("repos"))
650        .with_context(|| format!("resolving the directory for {path}"))?;
651    if !dir.is_dir() {
652        bail!("{path} has nothing on disk at {}", dir.display());
653    }
654    let r = crate::storage::znippy_engine::archive_out(&dir, &to, from_generation)?;
655    for s in &r.segments {
656        println!(
657            "  {:<44} {:>12} bytes  from {}",
658            s.name, s.len, s.from_offset
659        );
660    }
661    println!(
662        "archived {path} -> {}  {} bytes, generation {} -> {}  sha256 {}",
663        to.display(),
664        r.bytes_out,
665        r.from_generation,
666        r.to_generation,
667        hex_head(&r.file_sha256)
668    );
669    println!("next incremental: --from-generation {}", r.to_generation);
670    Ok(())
671}
672
673/// `main` → `refs/heads/main`; a full ref name is left alone.
674fn full_branch(name: &str) -> String {
675    if name.starts_with("refs/") {
676        name.to_owned()
677    } else {
678        format!("refs/heads/{name}")
679    }
680}
681
682// ── principals and keys ─────────────────────────────────────────────────────
683
684/// Run a `gunnar principal …` verb.
685pub fn principal(stores: &ServerStores, command: PrincipalCommand) -> Result<()> {
686    match command {
687        PrincipalCommand::Add {
688            id,
689            name,
690            kind,
691            csr,
692            server_generated_key,
693            out,
694            days,
695        } => {
696            let display = name.unwrap_or_else(|| id.clone());
697            let created = stores.auth().add_principal(gunnar_core::Principal::new(
698                &id,
699                display,
700                kind.into(),
701            )?)?;
702            println!("added principal {} ({:?})", created.id, created.kind);
703
704            // The certificate, if one was asked for. **After** the principal
705            // exists, because `register_certificate` refuses an unknown
706            // principal and the whole point of the certificate-identity plan is
707            // that the two are one act.
708            match (csr, server_generated_key) {
709                (Some(path), _) => {
710                    let request = std::fs::read_to_string(&path).with_context(|| {
711                        format!("reading the signing request at {}", path.display())
712                    })?;
713                    if let Some(claimed) = gunnar_core::issuer::csr_claimed_common_name(&request)? {
714                        if claimed != created.id {
715                            // Not an error: the request is being issued under
716                            // the id that was asked for, which is the correct
717                            // outcome. But an operator has to be able to see
718                            // that the request asked for something else.
719                            println!(
720                                "note: the request claims CN={claimed}; it is being issued as \
721                                 {} — a request does not choose its own name",
722                                created.id
723                            );
724                        }
725                    }
726                    let issued = stores.pki()?.issue_from_csr(&created.id, &request, days)?;
727                    hand_over(stores, &out, &issued)?;
728                }
729                (None, true) => {
730                    let issued = stores.pki()?.issue_with_server_key(&created.id, days)?;
731                    hand_over(stores, &out, &issued)?;
732                }
733                (None, false) => {
734                    if stores
735                        .control_store()
736                        .read_doc(gunnar_core::ISSUER_REF)?
737                        .is_some()
738                    {
739                        println!(
740                            "no certificate was issued. `--csr <file>` signs a request whose \
741                             key never leaves {}'s machine; `--server-generated-key` mints one \
742                             here instead",
743                            created.id
744                        );
745                    }
746                }
747            }
748        }
749        PrincipalCommand::List => {
750            let mut principals = stores.auth().principals()?;
751            principals.sort_by(|a, b| a.id.cmp(&b.id));
752            for principal in principals {
753                println!("{}\t{}\t{:?}", principal.id, principal.name, principal.kind);
754            }
755        }
756        PrincipalCommand::Remove { id } => {
757            let removed = stores.auth().remove_principal(&id)?;
758            println!("removed principal {}", removed.id);
759        }
760    }
761    Ok(())
762}
763
764/// Run a `gunnar key …` verb.
765pub fn key(stores: &ServerStores, command: KeyCommand) -> Result<()> {
766    match command {
767        KeyCommand::Add {
768            principal,
769            key,
770            cert,
771        } => {
772            let registered = match (key, cert) {
773                (_, Some(path)) => {
774                    let der = read_certificate(&path)?;
775                    stores.auth().register_certificate(&principal, &der, None)?
776                }
777                (Some(key), None) => {
778                    let line = key_argument(&key)?;
779                    stores.auth().register_key(&principal, &line, None)?
780                }
781                (None, None) => bail!("give a public key or --cert"),
782            };
783            println!(
784                "registered {} ({}) to {}",
785                registered.fingerprint_sha256, registered.algo, registered.principal_id
786            );
787        }
788        KeyCommand::List { principal } => {
789            let mut keys = match principal {
790                Some(id) => stores.auth().keys_of(&id)?,
791                None => stores.auth().keys()?,
792            };
793            keys.sort_by(|a, b| a.fingerprint_sha256.cmp(&b.fingerprint_sha256));
794            for key in keys {
795                println!(
796                    "{}\t{}\t{}",
797                    key.fingerprint_sha256, key.algo, key.principal_id
798                );
799            }
800        }
801        KeyCommand::Revoke { fingerprint } => {
802            let parsed = Fingerprint::parse(&fingerprint)?;
803            match stores.auth().revoke_key(&parsed)? {
804                Some(key) => println!("revoked {} from {}", parsed, key.principal_id),
805                None => bail!("no key with fingerprint {parsed} is registered"),
806            }
807        }
808    }
809    Ok(())
810}
811
812/// `gunnar token`.
813///
814/// # The secret is printed once and then does not exist
815///
816/// `gunnar_core::mint_token_secret` produces it, `register_token` stores only
817/// its sha-256, and this is the only place it is ever written out. It goes to
818/// **stdout alone**, with the explanation on stderr, so `TOKEN=$(gunnar token
819/// add ci)` captures the credential and nothing else.
820pub fn token(stores: &ServerStores, command: gunnar_common::cli::TokenCommand) -> Result<()> {
821    use gunnar_common::cli::TokenCommand;
822
823    match command {
824        TokenCommand::Add {
825            principal,
826            label,
827            expires_in,
828        } => {
829            let secret = gunnar_core::mint_token_secret();
830            let expires =
831                expires_in.map(|days| gunnar_core::now_ms() + (days as i64) * 24 * 60 * 60 * 1000);
832            let registered = stores
833                .auth()
834                .register_token(&principal, &secret, &label, expires)?;
835            // stdout, once. The hash goes to the operator too, because it is
836            // what `revoke` takes and it will never be derivable from anything
837            // they still have.
838            println!("{secret}");
839            eprintln!(
840                "minted for {} (hash {}); this is the only time the token is printed",
841                registered.principal_id, registered.hash
842            );
843        }
844        TokenCommand::List { principal } => {
845            let mut tokens = stores.auth().tokens()?;
846            if let Some(id) = &principal {
847                tokens.retain(|token| &token.principal_id == id);
848            }
849            tokens.sort_by(|a, b| a.hash.cmp(&b.hash));
850            for token in tokens {
851                println!("{}\t{}\t{}", token.hash, token.principal_id, token.label);
852            }
853        }
854        TokenCommand::Revoke { hash } => match stores.auth().revoke_token(&hash)? {
855            Some(token) => println!("revoked {} from {}", token.hash, token.principal_id),
856            None => bail!("no token with hash {hash} is registered"),
857        },
858    }
859    Ok(())
860}
861
862// ── grants ──────────────────────────────────────────────────────────────────
863
864fn scope_of(server: bool, namespace: Option<&str>, repo: Option<&str>) -> Result<Scope> {
865    match (server, namespace, repo) {
866        (true, _, _) => Ok(Scope::Server),
867        (_, Some(name), _) => Ok(Scope::Namespace(name.to_owned())),
868        (_, _, Some(path)) => Ok(Scope::Repo(repo_ref(path)?)),
869        _ => bail!("a grant needs a scope: --server, --namespace <ns>, or --repo <ns>/<name>"),
870    }
871}
872
873/// `gunnar grant`.
874pub fn grant(stores: &ServerStores, args: GrantArgs) -> Result<()> {
875    let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
876    let granted = stores.auth().grant(Grant {
877        principal: args.principal.clone(),
878        scope,
879        level: args.level.into(),
880    })?;
881    println!(
882        "{} now holds {} on {}",
883        granted.principal, granted.level, granted.scope
884    );
885    // Nested namespaces do NOT inherit: `team` does not reach
886    // `team/sub`. Said out loud at the moment the grant is made, because the
887    // alternative is an operator discovering it as a permission denial.
888    if let Scope::Namespace(name) = &granted.scope {
889        if name.contains('/')
890            || stores
891                .catalog()
892                .namespaces()?
893                .iter()
894                .any(|ns| ns.name.starts_with(&format!("{name}/")))
895        {
896            println!(
897                "note: namespace grants match EXACTLY; this does not reach nested namespaces \
898                 under {name}/"
899            );
900        }
901    }
902    Ok(())
903}
904
905/// `gunnar revoke`.
906pub fn revoke(stores: &ServerStores, args: RevokeArgs) -> Result<()> {
907    let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
908    if stores.auth().revoke_grant(&args.principal, &scope)? {
909        println!("revoked {}'s access to {scope}", args.principal);
910        Ok(())
911    } else {
912        bail!("{} holds no grant on {scope}", args.principal)
913    }
914}
915
916// ── gc ──────────────────────────────────────────────────────────────────────
917
918// `percent(Option<f32>) -> String` stood here, rendering a measured fraction or
919// `?` for one that could not be read. Its whole reason to exist was that `?` and
920// `0%` are different facts — and both facts were `RepackOptions`' delta figures,
921// which the storage restart amputated with the packfile engine. `MaintenanceReport` carries
922// no fraction: it reports `bytes_before`/`bytes_after`, `rows`, `retired_packs`,
923// `verified` and `sections_sealed`, every one of them a count or a pair, which
924// is the standing preference for a count over a ratio arrived at from the other
925// direction. Nothing left in this crate has an `Option<f32>` to render.
926
927/// `gunnar gc`, the repack seam, now driving `gunnar-store`'s `repack()`.
928///
929/// Each repository's pushed packs and loose objects are folded into **one new
930/// pack**, which is put in place and verified before anything is removed
931/// (never in place). A repository that is already consolidated is
932/// reported as such and left alone: rewriting it would churn the pack id and
933/// reclaim nothing.
934///
935/// **This is not the znippy cold tier `repack()` describes**, and the reason is
936/// That tier as specified stores *inflated* object content, which
937/// forecloses pack-copy permanently and makes every cold-tier fetch pay a full
938/// recompression. `gunnar-store::repack`'s module documentation carries the
939/// argument. What this verb does is git's own answer (many immutable
940/// containers plus a periodic merge) with the client's deltas and deflate
941/// preserved, which is what verbatim pack copy needs to exist at all.
942///
943/// The per-repository line reports **applied outputs**: objects, what was
944/// folded, what was copied rather than recompressed, and the bytes each way. A
945/// `gc` that printed only "done" would be indistinguishable from one that did
946/// nothing (measured, not asserted).
947///
948/// Unreachable objects are **kept unless `--prune` says otherwise**.
949/// `RepackOptions::prune_unreachable` had been implemented, tested
950/// (`gunnar-store/tests/repack.rs`) and reachable from nothing: no flag, no
951/// caller, so nothing gunnar ever reclaimed the objects of a rejected push, a
952/// deleted branch or a force-pushed-away history. It is a separate and
953/// destructive decision, which is why it is a flag and not a default, and the
954/// number of objects it dropped is reported rather than assumed.
955pub fn gc(stores: &ServerStores, args: GcArgs) -> Result<()> {
956    let repos = match &args.path {
957        Some(path) => {
958            let path = repo_ref(path)?;
959            vec![stores.catalog().open(&path)?]
960        }
961        None => stores.catalog().list(None)?,
962    };
963
964    // ── the storage restart: the tier maintains itself; there is no repack ──
965    //
966    // What stood here drove `gunnar_store::repack()` — consolidate the
967    // packfiles in `objects/pack`, optionally re-encoding deltas, with a
968    // `RepackOptions` carrying window, depth, delta order and policy. Every one
969    // of those is a packfile concept and the packfile engine is amputated.
970    //
971    // The engine's own two verbs replace all of it: `seal` folds the live logs
972    // into the reserved Arrow sections, `gc` computes the live set, tombstones
973    // the packs it found wholly dead, drops the dead index rows and compacts.
974    // Neither takes a window or a delta policy, which is why `GcArgs` no longer
975    // offers them — a flag that parses and is ignored is worse than one that is
976    // not there.
977    let _ = &args.prune;
978    let _ = &args.prune_grace;
979    let mut done = 0usize;
980    let mut failed = 0usize;
981    let mut reclaimed: i64 = 0;
982    for repo in &repos {
983        match gc_one(stores, repo) {
984            Ok(report) => {
985                done += 1;
986                reclaimed += report.bytes_before as i64 - report.bytes_after as i64;
987                println!(
988                    "{repo}\t{} -> {} bytes, {} live rows, {} pack(s) retired, {} section(s) \
989                     sealed [{}]",
990                    report.bytes_before,
991                    report.bytes_after,
992                    report.rows,
993                    report.retired_packs,
994                    report.sections_sealed,
995                    report.strategy,
996                );
997            }
998            Err(err) => {
999                failed += 1;
1000                eprintln!("{repo}\tgc failed: {err:#}");
1001            }
1002        }
1003    }
1004    println!(
1005        "{done} of {} repository(ies) maintained, {reclaimed} bytes reclaimed",
1006        repos.len()
1007    );
1008    if failed > 0 {
1009        bail!("{failed} repository(ies) could not be maintained; see above");
1010    }
1011    Ok(())
1012}
1013
1014/// **Maintain ONE repository. The only writer, and both doors call it.**
1015///
1016/// [`gc`] above is the CLI verb: it resolves what to maintain, loops, and
1017/// prints. `Repos.Gc` on the gRPC control plane resolves one repository and
1018/// reports bytes over the wire. Neither of those is maintenance — the
1019/// maintenance is here, once, so there is no second answer to "what does gc
1020/// do" to drift out of step with the first (one implementation). The split is deliberate:
1021/// **everything that is common is in this function and everything that
1022/// differs — argument parsing, the loop, the printed lines, the exit code — is
1023/// in the caller**, which is why extracting it changed no CLI output.
1024///
1025/// The span, the timing and the two log records live here rather than in the
1026/// callers, because the operator asking "what did the box just spend that I/O
1027/// on" gets the same record whether the run came from a shell or from a console
1028/// button. A failure logs `store.maintain_failed` here and is returned, so the
1029/// CLI can count it toward its exit code and the RPC can turn it into a
1030/// `Status` — one record, two policies.
1031pub fn gc_one(stores: &ServerStores, repo: &Repo) -> Result<MaintenanceReport> {
1032    // A snapshot streaming from its cut reads the objects reachable from the cut's tips, which
1033    // a gc of a moved ref could drop (R5.1-A): it waits for the stream.
1034    stores.wait_unpinned("a gc");
1035    let span = tracing::info_span!(target: TARGET_STORE, "store.maintain", repo = %repo);
1036    let _entered = span.enter();
1037    let started = std::time::Instant::now();
1038    let store = stores.repo_store(repo)?;
1039    match stores.engine().maintain(store.git_dir(), store.hash_kind()) {
1040        Ok(report) => {
1041            tracing::info!(
1042                target: TARGET_STORE,
1043                event = "store.maintained",
1044                strategy = %report.strategy,
1045                bytes_before = report.bytes_before,
1046                bytes_after = report.bytes_after,
1047                rows = report.rows,
1048                retired_packs = report.retired_packs,
1049                verified = report.verified,
1050                sections_sealed = report.sections_sealed,
1051                elapsed_ms = started.elapsed().as_millis() as u64,
1052                "the store was sealed and compacted"
1053            );
1054            Ok(report)
1055        }
1056        Err(err) => {
1057            tracing::error!(
1058                target: TARGET_STORE,
1059                event = "store.maintain_failed",
1060                %err,
1061                "maintaining the store failed"
1062            );
1063            Err(err)
1064        }
1065    }
1066}
1067
1068/// The bytes [`gc_one`] took off the filesystem, as one number.
1069///
1070/// `MaintenanceReport` carries the before/after PAIR on purpose — a lone
1071/// "reclaimed" figure cannot tell a run that compacted from one that did
1072/// nothing — so this is the lossy projection the wire asks for and not the
1073/// shape the report should have had. A run that grew the store (an in-place
1074/// compaction that had to write before it could retire) reports `0` rather
1075/// than an underflowed `u64`.
1076pub fn reclaimed_bytes(report: &MaintenanceReport) -> u64 {
1077    report.bytes_before.saturating_sub(report.bytes_after)
1078}
1079
1080/// `gunnar reflog` — read the reference history, and optionally seal it.
1081///
1082/// This verb exists because the history it reads was **write-only**. Every
1083/// compaction wrote the reflog it took out of the live log to `refs-archive/`
1084/// as one complete Arrow IPC stream, and no production code in the workspace
1085/// ever opened one; a library reader with no caller would have been the same
1086/// bug one layer up.
1087///
1088/// It opens **no store, and no catalog**. `History::read` globs the archive and
1089/// reads the live log through the same torn-tail scan an open uses, taking no
1090/// lock, so this works while the server is serving — which is the only state a
1091/// repository worth asking about is ever in.
1092///
1093/// The catalog exclusion was found by running this verb against a live server
1094/// and being refused, *after* the library underneath it had been proven
1095/// lock-free: `ServerStores::open` opens the control plane's own store, so
1096/// resolving `<ns>/<name>` through the catalog took the very lock this verb
1097/// exists to avoid. That was the control *repository*'s lock when it was found;
1098/// since 2026-08-10 it is redb's exclusive lock on `control.redb`, which the
1099/// catalog, the identity table and now the secrets all sit behind. Same
1100/// exclusion, one file instead of two. The repository directory is resolved
1101/// through the same
1102/// `RepoRef::path_under` sandbox the SSH listener uses, and the hash kind comes
1103/// from the repository's own `config` (`gunnar_store::fs::stored_hash_kind`),
1104/// which is where `FsStore` already reads it.
1105///
1106/// The listing reports **applied output**: real oids either side of each move,
1107/// the actor, and whether the row is a compaction's snapshot rather than a
1108/// reference actually moving. A verb that printed only a count would be
1109/// indistinguishable from one that read nothing (measured, not asserted).
1110///
1111/// # 🔴 It reads a **retired** format, and it now says so instead of guessing
1112///
1113/// **2026-08-10.** What stood here was
1114/// `let store_dir = todo!("reflog: ask the engine for its ref journal")` — a
1115/// live `todo!()` in a shipped admin verb, which panicked the moment anybody
1116/// ran it. It was invisible until today because a crate that fails at name
1117/// resolution never reaches the lint passes.
1118///
1119/// The obvious repair — hand [`gunnar_core::History::read`] a plausible path —
1120/// is the **worst** available answer, and measurably so:
1121/// [`History::read`](gunnar_core::History::read) globs an archive directory and
1122/// tests the live log with `exists()`, so a wrong path is not an error. It is
1123/// **`0 pushes`**, printed with a straight face, and `--seal` would write a
1124/// valid, empty Arrow artifact over it.
1125///
1126/// So the path is **probed rather than assumed**, and the probe has exactly two
1127/// outcomes:
1128///
1129/// * `<git-dir>/gunnar-store/` holds [`REFS_LOG`] or [`REFS_ARCHIVE_DIR`] — the
1130///   retired filesystem backend's Arrow reflog is really there, and this verb
1131///   reads it, which is what it has always done and the only reader those bytes
1132///   have.
1133/// * it does not — the verb **refuses for that repository**, names the engine
1134///   this process runs and says where that engine's ref history actually is.
1135///   `read == 0` across every repository is a non-zero exit, because a script
1136///   must not read "nothing to say" as success.
1137///
1138/// # Why this is not re-pointed at the engine's journal
1139///
1140/// It is not a missing path; it is a missing capability, and the gap is three
1141/// deep:
1142///
1143/// 1. **Neither `GitOps` nor `GitServe` carries a ref-history accessor**, and
1144///    one does not belong on the contract: the `GitServe` reading contract fixes the
1145///    ordering rule that `GitServe` is *"defined by what the Arrow arm needs to
1146///    serve a clone well"*, and ref history serves no clone. Worse, the gix arm
1147///    could never conform — `storage-git-gix` opens its ref store with
1148///    `WriteReflog::Disable` and says so deliberately (*"a git server has no use
1149///    for a per-ref reflog file tree, and `GitOps` does not expose one"*). A
1150///    contract method one arm is structurally unable to answer is the
1151///    two-arms-disagree defect the `Harness` exists to catch, written into the
1152///    contract on purpose.
1153/// 2. **The znippy arm's journal is `pub(crate)`.** `refs.log` is one Arrow IPC
1154///    frame per push under the store root, complete (a seal *copies* it into the
1155///    archive and a compaction preserves row order), but `GitStore::ref_log()`
1156///    is not public and the file name is `GitStore`'s layout, not this crate's.
1157/// 3. **Its rows do not carry what this verb prints.** The columns are `name`,
1158///    `target`, `peeled`, `symref_target`, `push_seq`, `updated_ms`. The
1159///    previous value is derivable by replay; **`actor` does not exist at all**,
1160///    and `--seal`'s artifact is a `gunnar-core` format the engine has its own
1161///    answer to. Re-pointing this verb is therefore a redesign of what
1162///    `gunnar reflog` reports, which is a design decision and not a repair.
1163///
1164/// [`REFS_LOG`]: gunnar_core::arrow_log::REFS_LOG
1165/// [`REFS_ARCHIVE_DIR`]: gunnar_core::arrow_log::REFS_ARCHIVE_DIR
1166pub fn reflog(data_root: &std::path::Path, args: ReflogArgs) -> Result<()> {
1167    let repos_root = data_root.join(crate::stores::REPOS_DIR);
1168    let repos: Vec<(String, std::path::PathBuf)> = match &args.path {
1169        Some(path) => {
1170            let path = repo_ref(path)?;
1171            let dir = path
1172                .path_under(&repos_root)
1173                .with_context(|| format!("resolving the directory for {path}"))?;
1174            if !dir.join("HEAD").exists() {
1175                bail!("there is no repository at {}", dir.display());
1176            }
1177            vec![(path.key(), dir)]
1178        }
1179        None => bare_repositories(&repos_root)?,
1180    };
1181    if repos.is_empty() {
1182        println!("no repositories under {}", repos_root.display());
1183        return Ok(());
1184    }
1185    // One repository gets the path it was given; many get one artifact each
1186    // under it, because a single file would silently keep only the last.
1187    let seal_is_a_directory = args.seal.is_some() && repos.len() > 1;
1188
1189    let mut read = 0usize;
1190    let mut failed = 0usize;
1191    let mut refused = 0usize;
1192    for (key, git_dir) in &repos {
1193        let store_dir = git_dir.join(LEGACY_STORE_DIR);
1194        // **Probed, never assumed.** `History::read` answers `0 pushes` for a
1195        // directory that is not there, so pointing it somewhere plausible would
1196        // print a number that means nothing. See this function's docs.
1197        if !legacy_reflog_present(&store_dir) {
1198            refused += 1;
1199            eprintln!(
1200                "{key}\tREFUSED: no reference history this verb can read — nothing at {}",
1201                store_dir.display()
1202            );
1203            continue;
1204        }
1205        let hash_kind = match stored_hash_kind(git_dir) {
1206            Ok(kind) => kind,
1207            Err(err) => {
1208                failed += 1;
1209                eprintln!("{key}\tFAILED: {err:#}");
1210                continue;
1211            }
1212        };
1213
1214        let history = match gunnar_core::History::read(&store_dir, hash_kind) {
1215            Ok(history) => history,
1216            Err(err) => {
1217                failed += 1;
1218                eprintln!("{key}\tFAILED: {err}");
1219                continue;
1220            }
1221        };
1222        read += 1;
1223
1224        match &args.seal {
1225            Some(target) => {
1226                let path = if seal_is_a_directory {
1227                    target.join(format!("{}.arrow", key.replace('/', "_")))
1228                } else {
1229                    target.clone()
1230                };
1231                let report = history.seal(&path)?;
1232                println!(
1233                    "{key}\t{} pushes ({} archived, {} live)\t{} rows\t{} B\t{:.1} B/push\t{:.2}x smaller than the {} B it read\t{}",
1234                    report.pushes,
1235                    history.archived_pushes(),
1236                    history.live_pushes(),
1237                    report.rows,
1238                    report.bytes,
1239                    report.bytes_per_push(),
1240                    report.shrink_x(),
1241                    report.bytes_in,
1242                    path.display(),
1243                );
1244            }
1245            None => {
1246                let transactions = history.transactions()?;
1247                // **`bytes_in` is on the line for a reason.** `History` reads a
1248                // live log through the same torn-tail scan an open uses, so a
1249                // log whose very first frame never landed has a valid prefix of
1250                // zero and is *correctly* replayed as no pushes at all. Without
1251                // the byte count, "0 pushes" out of a 40 KiB file and "0 pushes"
1252                // out of an empty directory print identically — and one of those
1253                // is a history an operator has lost (measured, not asserted).
1254                println!(
1255                    "{key}\t{} pushes ({} from the archive, {} live) in {} segment(s)\t{} rows\t{} B read",
1256                    history.pushes(),
1257                    history.archived_pushes(),
1258                    history.live_pushes(),
1259                    history.segments_read(),
1260                    transactions.len(),
1261                    history.bytes_in(),
1262                );
1263                let show = if args.all {
1264                    transactions.as_slice()
1265                } else {
1266                    let from = transactions.len().saturating_sub(args.limit);
1267                    &transactions[from..]
1268                };
1269                if show.len() < transactions.len() {
1270                    println!(
1271                        "  … {} older transaction(s) not shown; --all for every one",
1272                        transactions.len() - show.len()
1273                    );
1274                }
1275                for entry in show {
1276                    println!(
1277                        "  {:>6} {} {} -> {}{}",
1278                        entry.push_seq,
1279                        entry.refname,
1280                        value(entry.old_oid.as_ref(), entry.old_symref.as_deref()),
1281                        value(entry.new_oid.as_ref(), entry.new_symref.as_deref()),
1282                        if entry.is_snapshot() {
1283                            "  (compaction snapshot, not a move)"
1284                        } else {
1285                            ""
1286                        },
1287                    );
1288                }
1289            }
1290        }
1291    }
1292
1293    // Printed once rather than per repository: on a server with two hundred
1294    // repositories the explanation is the same explanation two hundred times,
1295    // and the operator's next action is one action.
1296    if refused > 0 {
1297        eprintln!("\n{}", refusal_explanation());
1298    }
1299    if failed > 0 {
1300        bail!(
1301            "{failed} of {} repositor{} could not be read; {read} were",
1302            repos.len(),
1303            if repos.len() == 1 { "y" } else { "ies" }
1304        );
1305    }
1306    // **A refusal is an exit code, not a remark.** Every repository refused and
1307    // exit 0 is indistinguishable from a history that is genuinely empty, which
1308    // is the failure this whole verb was rewritten to stop making.
1309    if read == 0 {
1310        bail!(
1311            "no reference history was read from any of the {} repositor{} looked at",
1312            repos.len(),
1313            if repos.len() == 1 { "y" } else { "ies" }
1314        );
1315    }
1316    Ok(())
1317}
1318
1319/// The store directory of the **retired** filesystem backend, verbatim.
1320///
1321/// It was `gunnar_store::fs::STORE_DIR` until the storage restart amputated the packfile
1322/// engine that owned it. The constant is repeated here rather than revived
1323/// there because nothing *writes* this layout any longer: it is the name of a
1324/// format on old disks, and the only code left that has any business knowing it
1325/// is the one reader of those bytes.
1326const LEGACY_STORE_DIR: &str = "gunnar-store";
1327
1328/// Whether `store_dir` really holds a legacy Arrow reflog.
1329///
1330/// Both halves are checked because either one alone is a complete history:
1331/// a repository compacted since its last push has everything in
1332/// `refs-archive/` and a live log holding only its schema, and one never
1333/// compacted has the lot in the live log and no archive at all.
1334///
1335/// The two names come from [`gunnar_core::arrow_log`], which is the module that
1336/// wrote them, rather than being spelled again here (one implementation).
1337fn legacy_reflog_present(store_dir: &std::path::Path) -> bool {
1338    store_dir.join(gunnar_core::arrow_log::REFS_LOG).is_file()
1339        || store_dir
1340            .join(gunnar_core::arrow_log::REFS_ARCHIVE_DIR)
1341            .is_dir()
1342}
1343
1344/// What an operator is told when there is no history for this verb to read.
1345///
1346/// It names the engine **this process** runs, because "where is my ref history"
1347/// has a different answer per arm and a generic refusal would send half of the
1348/// readers to the wrong place.
1349fn refusal_explanation() -> String {
1350    let engine = match crate::storage::Engine::selected() {
1351        Ok(engine) => engine.name(),
1352        // `selected()` refuses an engine name this binary does not carry. That
1353        // is worth reporting as itself rather than swallowing into "unknown",
1354        // but it must not turn an explanation into a second failure.
1355        Err(_) => "<unselectable>",
1356    };
1357    format!(
1358        "`reflog` reads the retired filesystem backend's Arrow reflog —\n\
1359         `<git-dir>/{LEGACY_STORE_DIR}/{log}` and `{archive}/` beside it. That backend went with\n\
1360         the packfile engine (the storage restart) and nothing writes those bytes any more, so\n\
1361         a repository created since has none and this verb has nothing to read.\n\
1362         \n\
1363         This process runs the `{engine}` engine. Where reference history is now:\n\
1364         \n\
1365         * znippy — one Arrow IPC frame per push in the store's own `refs.log`. It is real and it\n\
1366         \x20 is complete, but `GitStore::ref_log()` is `pub(crate)`, and its rows carry no actor\n\
1367         \x20 and no previous value, so this verb's output is not merely re-pointable at it.\n\
1368         * gix    — none at all. `storage-git-gix` opens its ref store with `WriteReflog::Disable`,\n\
1369         \x20 deliberately: a git server has no use for a per-ref reflog file tree.\n\
1370         \n\
1371         Neither `GitOps` nor `GitServe` carries a ref-history accessor, and the `GitServe`\n\
1372         reading contract's ordering rule says one does not belong on the contract. Making this\n\
1373         verb answer again is an engine-shaped seam plus a decision about what it should\n\
1374         report — see `agentO-report.md`.",
1375        log = gunnar_core::arrow_log::REFS_LOG,
1376        archive = gunnar_core::arrow_log::REFS_ARCHIVE_DIR,
1377    )
1378}
1379
1380/// The hash kind an existing repository was created with.
1381///
1382/// One key decides it, `extensions.objectformat`, and its absence means sha1,
1383/// which is the reading git gives `repositoryformatversion = 0`. Reading that
1384/// one key by hand is deliberate: linking `gix-config` to parse a file whose
1385/// every other key describes a worktree this server does not have is what put
1386/// `gix-config`, and through it the rest of the porcelain, into the appliance
1387/// build.
1388///
1389/// It is here because reading a repository's **history** must not open its
1390/// store or its catalog — both are locked by whatever server is serving them,
1391/// and dodging that lock is this verb's entire reason to exist. The hash kind
1392/// is the one thing a history reader needs that the log itself does not carry.
1393///
1394/// Restored verbatim from `gunnar_store::fs::stored_hash_kind`, which the storage restart
1395/// deleted with the backend around it. It is the only reader of this key left
1396/// in the workspace, so there is nothing for it to drift from.
1397fn stored_hash_kind(git_dir: &std::path::Path) -> Result<gix_hash::Kind> {
1398    const OBJECT_FORMAT_KEY: &str = "objectformat";
1399
1400    let config_path = git_dir.join("config");
1401    let text = match std::fs::read_to_string(&config_path) {
1402        Ok(text) => text,
1403        // A repository with no config at all is a sha1 repository, which is
1404        // also how git reads it.
1405        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(gix_hash::Kind::Sha1),
1406        Err(err) => {
1407            return Err(anyhow::Error::from(err))
1408                .with_context(|| format!("reading {}", config_path.display()))
1409        }
1410    };
1411
1412    let mut in_extensions = false;
1413    for line in text.lines() {
1414        let line = line.trim();
1415        if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
1416            in_extensions = header.trim().eq_ignore_ascii_case("extensions");
1417            continue;
1418        }
1419        if !in_extensions {
1420            continue;
1421        }
1422        let Some((key, value)) = line.split_once('=') else {
1423            continue;
1424        };
1425        // Git's config keys are case-insensitive; the value here is not.
1426        if !key.trim().eq_ignore_ascii_case(OBJECT_FORMAT_KEY) {
1427            continue;
1428        }
1429        return match value.trim() {
1430            "sha256" => Ok(gix_hash::Kind::Sha256),
1431            "sha1" => Ok(gix_hash::Kind::Sha1),
1432            other => bail!(
1433                "{} declares an object format gunnar does not know: {other:?}",
1434                config_path.display()
1435            ),
1436        };
1437    }
1438    Ok(gix_hash::Kind::Sha1)
1439}
1440
1441/// Every bare repository under `repos_root`, as `(<ns>/<name>, git dir)`.
1442///
1443/// Walked off the filesystem rather than listed from the catalog, for the same
1444/// reason the rest of this verb avoids it: the catalog is a store and a running
1445/// server holds its lock. A directory holding a `HEAD` is the same test
1446/// `FsStore::open_or_init` uses to decide a repository is already there.
1447fn bare_repositories(repos_root: &std::path::Path) -> Result<Vec<(String, std::path::PathBuf)>> {
1448    fn walk(
1449        dir: &std::path::Path,
1450        prefix: &str,
1451        out: &mut Vec<(String, std::path::PathBuf)>,
1452    ) -> Result<()> {
1453        let entries = match std::fs::read_dir(dir) {
1454            Ok(entries) => entries,
1455            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1456            Err(err) => return Err(err.into()),
1457        };
1458        for entry in entries {
1459            let entry = entry?;
1460            if !entry.file_type()?.is_dir() {
1461                continue;
1462            }
1463            let name = entry.file_name().to_string_lossy().into_owned();
1464            let path = entry.path();
1465            if let Some(stem) = name.strip_suffix(".git") {
1466                if path.join("HEAD").exists() {
1467                    out.push((format!("{prefix}{stem}"), path));
1468                    continue;
1469                }
1470            }
1471            // Namespaces nest (`team/sub`), so this recurses rather than
1472            // scanning one level.
1473            walk(&path, &format!("{prefix}{name}/"), out)?;
1474        }
1475        Ok(())
1476    }
1477    let mut out = Vec::new();
1478    walk(repos_root, "", &mut out)?;
1479    out.sort();
1480    Ok(out)
1481}
1482
1483/// One side of a reference move, as an operator reads it.
1484///
1485/// `-` rather than an empty column: a creation and a deletion must not look
1486/// like a truncated line.
1487fn value(oid: Option<&gunnar_store::gix_hash::ObjectId>, symref: Option<&str>) -> String {
1488    match (oid, symref) {
1489        (Some(id), _) => id.to_string(),
1490        (None, Some(name)) => format!("ref: {name}"),
1491        (None, None) => "-".to_owned(),
1492    }
1493}
1494
1495/// `gunnar attest …` — the transparency log over push attestations.
1496///
1497/// None of these verbs writes a record. They sign, check and prove over the
1498/// lines every push already appended, which is what makes them safe to run at
1499/// any time and impossible to use to change what the log says.
1500pub fn attest(stores: &ServerStores, command: gunnar_common::cli::AttestCommand) -> Result<()> {
1501    use gunnar_common::cli::AttestCommand;
1502
1503    let log = stores.transparency();
1504    match command {
1505        AttestCommand::Head { builds } => {
1506            let log = if builds { stores.build_log() } else { log };
1507            let key = stores.transparency_key()?;
1508            let signed = log.issue_head(&key, gunnar_grpc::bus::now_unix_ms())?;
1509            println!("size      {}", signed.head.size);
1510            println!("root      {}", signed.head.root);
1511            println!("signed by {}", signed.key_fingerprint);
1512            println!("appended  {}", log.heads_path().display());
1513            if signed.head.size == 0 {
1514                // Said out loud rather than left to be inferred from `size 0`.
1515                // A signed empty head is a real claim — "this server had
1516                // accepted no push at this moment" — and an operator who issued
1517                // one by accident should notice.
1518                println!();
1519                println!(
1520                    "This head covers an EMPTY log: it says this server had accepted no push \
1521                     when it was signed."
1522                );
1523            }
1524            Ok(())
1525        }
1526        AttestCommand::Audit { builds } => {
1527            let log = if builds { stores.build_log() } else { log };
1528            let key = stores.transparency_key()?;
1529            let verdict = log.audit(key.public_key())?;
1530            println!("{verdict}");
1531            if verdict.is_consistent() {
1532                return Ok(());
1533            }
1534            // A non-zero exit, because this is the verb an operator wires into
1535            // a check. Printing the verdict and exiting 0 would make a
1536            // tampered log look like a passing job.
1537            anyhow::bail!("the transparency log did not audit clean")
1538        }
1539        AttestCommand::Key => {
1540            let key = stores.transparency_key()?;
1541            println!(
1542                "{}",
1543                key.public_key()
1544                    .to_openssh()
1545                    .context("rendering the transparency log's public key")?
1546            );
1547            Ok(())
1548        }
1549        AttestCommand::Prove { pack_hash } => {
1550            let Some(inclusion) = log.prove_pack(&pack_hash)? else {
1551                anyhow::bail!(
1552                    "no attestation in this log names pack {pack_hash}. A proof for a record \
1553                     the log does not hold is not an empty proof."
1554                )
1555            };
1556            println!("index {}", inclusion.index);
1557            println!("size  {}", inclusion.size);
1558            println!("leaf  {}", inclusion.leaf);
1559            println!("root  {}", inclusion.root);
1560            for (step, hash) in inclusion.proof.iter().enumerate() {
1561                println!("proof[{step}] {hash}");
1562            }
1563            Ok(())
1564        }
1565    }
1566}
1567
1568/// `gunnar verify` — walk the chain backwards from a line of source.
1569///
1570/// **The exit code is the verdict.** Zero only when every link is proven; a
1571/// broken link and an unproven one both exit non-zero, and they print
1572/// differently. A command whose output an operator has to read to learn whether
1573/// it passed is a command nobody wires into anything.
1574pub fn verify(data_root: &std::path::Path, args: gunnar_common::cli::VerifyArgs) -> Result<()> {
1575    let stores = std::sync::Arc::new(crate::stores::ServerStores::open(data_root)?);
1576    let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
1577    let chain = match (&args.artifact, &args.repo) {
1578        (Some(sha256), _) => {
1579            crate::verify::verify_artifact(&stores, &plane, sha256, args.line.as_deref())?
1580        }
1581        (None, Some(repo)) => {
1582            let (namespace, name) = repo.split_once('/').with_context(|| {
1583                format!(
1584                    "{repo:?} is not `<namespace>/<repo>`; every repository lives in a \
1585                     namespace (multi-tenancy)"
1586                )
1587            })?;
1588            crate::verify::verify(
1589                &stores,
1590                &plane,
1591                &gunnar_proto::RepoPath::new(namespace, name),
1592                &args.at,
1593                args.line.as_deref(),
1594            )?
1595        }
1596        // clap's `required_unless_present` makes this unreachable; it is here
1597        // because a parser guarantee is not a type guarantee.
1598        (None, None) => anyhow::bail!("name a repository, or an --artifact to start from"),
1599    };
1600    println!("{chain}");
1601    if chain.is_unbroken() {
1602        return Ok(());
1603    }
1604    if chain.is_broken() {
1605        anyhow::bail!("the chain is broken");
1606    }
1607    anyhow::bail!("the chain is incomplete")
1608}
1609
1610/// `gunnar blame <ns>/<repo> <path> [--rev]` — who wrote each line.
1611///
1612/// A separate process from a running server for the reason `verify` is: opening
1613/// `ServerStores` takes redb's exclusive lock, which a live server holds.
1614pub fn blame(data_root: &std::path::Path, args: gunnar_common::cli::BlameArgs) -> Result<()> {
1615    let (namespace, name) = args.repo.split_once('/').with_context(|| {
1616        format!(
1617            "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1618             namespace (multi-tenancy)",
1619            args.repo
1620        )
1621    })?;
1622    let rows = crate::blame::blame_at(
1623        data_root,
1624        &gunnar_proto::RepoPath::new(namespace, name),
1625        &args.rev,
1626        &args.path,
1627    )?;
1628    // `<short-oid> <author> <line>`, one line each — the shape `git blame`
1629    // readers expect, trimmed to what an auditor asked: who, and what.
1630    for row in &rows {
1631        let hex = row.commit.to_hex().to_string();
1632        let short = &hex[..hex.len().min(12)];
1633        println!("{short} {}\t{}", row.author, String::from_utf8_lossy(&row.line));
1634    }
1635    Ok(())
1636}
1637
1638/// `gunnar search <ns>/<repo> <pattern> [--rev] [--max-*]` — a literal string
1639/// across the latest revision's files.
1640pub fn search(data_root: &std::path::Path, args: gunnar_common::cli::SearchArgs) -> Result<()> {
1641    let (namespace, name) = args.repo.split_once('/').with_context(|| {
1642        format!(
1643            "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1644             namespace (multi-tenancy)",
1645            args.repo
1646        )
1647    })?;
1648    let limits = crate::search::Limits {
1649        max_file_bytes: args.max_file_bytes,
1650        max_matches: args.max_matches,
1651    };
1652    let hits = crate::search::search_at(
1653        data_root,
1654        &gunnar_proto::RepoPath::new(namespace, name),
1655        &args.rev,
1656        &args.pattern,
1657        limits,
1658    )?;
1659    for hit in &hits {
1660        println!(
1661            "{}:{}:{}",
1662            hit.path,
1663            hit.line_number,
1664            String::from_utf8_lossy(&hit.line)
1665        );
1666    }
1667    Ok(())
1668}
1669
1670/// `gunnar build …` — build provenance (roadmap phase 3, links 5–6).
1671pub fn build(stores: &ServerStores, command: gunnar_common::cli::BuildCommand) -> Result<()> {
1672    use gunnar_common::cli::BuildCommand;
1673
1674    let log = stores.build_log();
1675    match command {
1676        BuildCommand::Attest {
1677            repo,
1678            commit,
1679            recipe,
1680            toolchain,
1681            builder,
1682            network,
1683            materials,
1684            artifacts,
1685        } => {
1686            // **Digested here, from the files.** A digest supplied on the
1687            // command line is a digest the caller chose, and the whole point of
1688            // the record is that it names what was actually produced.
1689            let subject = |path: &std::path::Path| -> Result<gunnar_core::Subject> {
1690                Ok(gunnar_core::Subject {
1691                    name: path
1692                        .file_name()
1693                        .map(|n| n.to_string_lossy().into_owned())
1694                        .unwrap_or_else(|| path.display().to_string()),
1695                    sha256: gunnar_core::provenance::digest_file(path)
1696                        .with_context(|| format!("hashing {}", path.display()))?,
1697                })
1698            };
1699            let now = gunnar_grpc::bus::now_unix_ms();
1700            let record = gunnar_core::BuildRecord {
1701                builder,
1702                source_repo: repo,
1703                source_commit: commit,
1704                recipe,
1705                toolchain,
1706                network,
1707                materials: materials
1708                    .iter()
1709                    .map(|p| subject(p))
1710                    .collect::<Result<_>>()?,
1711                subjects: artifacts
1712                    .iter()
1713                    .map(|p| subject(p))
1714                    .collect::<Result<_>>()?,
1715                started_at_unix_ms: now,
1716                finished_at_unix_ms: now,
1717            };
1718            let key = stores.provenance_key()?;
1719            let signed = gunnar_core::provenance::sign_build(&key, record)
1720                .context("signing the build record")?;
1721            let mut line = serde_json::to_string(&signed)?;
1722            line.push('\n');
1723            let _framed = gunnar_core::txlog::sink::append_line(log.records_path(), line.trim_end_matches('\n'))?;
1724            use std::io::Write as _;
1725            let mut file = std::fs::OpenOptions::new()
1726                .create(true)
1727                .append(true)
1728                .open(log.records_path())?;
1729            file.write_all(line.as_bytes())?;
1730
1731            println!("recorded  {}", signed.record.digest());
1732            println!("builder   {}", signed.record.builder);
1733            println!(
1734                "source    {}@{}",
1735                signed.record.source_repo, signed.record.source_commit
1736            );
1737            println!("network   {}", signed.record.network);
1738            for subject in &signed.record.subjects {
1739                println!("produced  {} {}", subject.sha256, subject.name);
1740            }
1741            println!("signed by {}", signed.key_fingerprint);
1742            println!();
1743            println!(
1744                "No signed head covers it yet. `gunnar attest head --builds` is what turns this \
1745                 record into evidence somebody else can check."
1746            );
1747            Ok(())
1748        }
1749        BuildCommand::Sbom { lock, artifact } => {
1750            let text = std::fs::read_to_string(&lock)
1751                .with_context(|| format!("reading {}", lock.display()))?;
1752            let packages = gunnar_core::sbom::parse_lock(&text);
1753            anyhow::ensure!(
1754                !packages.is_empty(),
1755                "{} yielded no packages. An empty SBOM is not a small one — it is a document \
1756                 that says this artifact contains nothing, and that is never true.",
1757                lock.display()
1758            );
1759            let sha256 = gunnar_core::provenance::digest_file(&artifact)
1760                .with_context(|| format!("hashing {}", artifact.display()))?;
1761            let name = artifact
1762                .file_name()
1763                .map(|n| n.to_string_lossy().into_owned())
1764                .unwrap_or_else(|| artifact.display().to_string());
1765            // The serial is the artifact's own digest. A random uuid would make
1766            // two SBOMs of the same artifact different documents; this way the
1767            // document is a function of what it describes.
1768            print!(
1769                "{}",
1770                gunnar_core::sbom::cyclonedx(&packages, &name, &sha256, &sha256)
1771            );
1772            Ok(())
1773        }
1774        BuildCommand::Admit { artifact } => {
1775            let sha256 = gunnar_core::provenance::digest_file(&artifact)
1776                .with_context(|| format!("hashing {}", artifact.display()))?;
1777            match crate::registry::admit(stores, &sha256)? {
1778                Ok(admitted) => {
1779                    println!("admitted  {sha256}");
1780                    println!("built by  {}", admitted.record.builder);
1781                    println!(
1782                        "source    {}@{}",
1783                        admitted.record.source_repo, admitted.record.source_commit
1784                    );
1785                    println!("record    {} of the build log", admitted.index);
1786                    println!(
1787                        "head      size {} root {}",
1788                        admitted.head.size, admitted.head.root
1789                    );
1790                    Ok(())
1791                }
1792                Err(refusal) => {
1793                    // Printed to stdout and then failed, so the reason survives
1794                    // in a log that captures one stream and the exit code is
1795                    // still the verdict.
1796                    println!("refused   {sha256}");
1797                    println!("{refusal}");
1798                    anyhow::bail!("the registry refuses this artifact")
1799                }
1800            }
1801        }
1802        BuildCommand::Run {
1803            repo,
1804            at,
1805            workspace,
1806            builder,
1807            allow_network,
1808        } => {
1809            let (namespace, name) = repo
1810                .split_once('/')
1811                .with_context(|| format!("{repo:?} is not `<namespace>/<repo>`"))?;
1812            let repo_ref = gunnar_core::RepoRef::new(namespace, name)
1813                .context("the repository path is not a legal one")?;
1814            let commit = resolve_commit(stores, &repo_ref, &at)?;
1815            let built = crate::runner::build_commit(
1816                stores,
1817                &repo_ref,
1818                commit,
1819                &workspace,
1820                &builder,
1821                allow_network,
1822            )?;
1823            println!(
1824                "built     {}@{}",
1825                built.record.source_repo, built.record.source_commit
1826            );
1827            println!("recipe    {}", built.record.recipe);
1828            println!("toolchain {}", built.record.toolchain);
1829            println!("network   {}", built.record.network);
1830            for subject in &built.record.subjects {
1831                println!("produced  {} {}", subject.sha256, subject.name);
1832            }
1833            println!("workdir   {}", built.workdir.display());
1834            println!();
1835            println!(
1836                "`gunnar attest head --builds` is what turns this record into evidence \
1837                 somebody else can check."
1838            );
1839            Ok(())
1840        }
1841        BuildCommand::Watch {
1842            workspace,
1843            builder,
1844            refs,
1845            allow_network,
1846            max_builds,
1847        } => {
1848            anyhow::bail!(
1849                "`gunnar build watch` needs the event bus the serving process owns, and this \
1850                 command opens the stores on its own. Start the server with `--runner \
1851                 --runner-workspace {} --runner-builder {}`{}{}; the runner then shares the \
1852                 bus every push publishes on.\n\n\
1853                 A second process cannot subscribe to an in-process bus, and a runner that \
1854                 polled the log instead would build a push some time after it landed rather \
1855                 than because it landed.",
1856                workspace.display(),
1857                builder,
1858                if allow_network {
1859                    " --runner-allow-network"
1860                } else {
1861                    ""
1862                },
1863                match (refs.is_empty(), max_builds) {
1864                    (true, None) => String::new(),
1865                    _ => " (--ref/--max-builds are `watch` options; the served runner builds \
1866                         every branch)".to_string(),
1867                }
1868            )
1869        }
1870        BuildCommand::Image {
1871            artifact,
1872            entrypoint,
1873            builder,
1874        } => {
1875            let bytes = std::fs::read(&artifact)
1876                .with_context(|| format!("reading {}", artifact.display()))?;
1877            let artifact_digest = gunnar_core::provenance::digest_bytes(&bytes);
1878
1879            // **The chain, one link at a time.** An image built from a binary
1880            // nobody attested to is an image whose provenance stops one link
1881            // short — and it would look exactly as green as one that does not.
1882            let admitted = match crate::registry::admit(stores, &artifact_digest)? {
1883                Ok(admitted) => admitted,
1884                Err(refusal) => {
1885                    anyhow::bail!("the binary this image would carry is not admitted: {refusal}")
1886                }
1887            };
1888
1889            let registry = crate::oci::Registry::under(stores.root());
1890            let image = crate::oci::build_and_store(&registry, &bytes, &entrypoint)?;
1891            registry.put_blob(&image.manifest)?;
1892
1893            // The image's own digest, attested in turn — same record type, same
1894            // key, same log. The material is the binary, which is what makes
1895            // this a link rather than a second, parallel claim.
1896            let now = gunnar_grpc::bus::now_unix_ms();
1897            let record = gunnar_core::BuildRecord {
1898                builder,
1899                source_repo: admitted.record.source_repo.clone(),
1900                source_commit: admitted.record.source_commit.clone(),
1901                recipe: format!("oci image of {entrypoint}"),
1902                toolchain: "gunnar oci".to_owned(),
1903                // No build ran: an image is assembled from bytes that already
1904                // exist. Recording `network: false` here is a fact and not a
1905                // sandbox claim.
1906                network: false,
1907                materials: vec![gunnar_core::Subject {
1908                    name: artifact
1909                        .file_name()
1910                        .map(|n| n.to_string_lossy().into_owned())
1911                        .unwrap_or_else(|| artifact.display().to_string()),
1912                    sha256: artifact_digest.clone(),
1913                }],
1914                subjects: vec![gunnar_core::Subject {
1915                    name: format!("{entrypoint} (oci manifest)"),
1916                    sha256: image.digest.trim_start_matches("sha256:").to_owned(),
1917                }],
1918                started_at_unix_ms: now,
1919                finished_at_unix_ms: now,
1920            };
1921            let key = stores.provenance_key()?;
1922            let signed = gunnar_core::provenance::sign_build(&key, record)
1923                .context("signing the image's build record")?;
1924            let mut line = serde_json::to_string(&signed)?;
1925            line.push('\n');
1926            let _framed = gunnar_core::txlog::sink::append_line(stores.build_log().records_path(), line.trim_end_matches('\n'))?;
1927            use std::io::Write as _;
1928            let mut file = std::fs::OpenOptions::new()
1929                .create(true)
1930                .append(true)
1931                .open(stores.build_log().records_path())?;
1932            file.write_all(line.as_bytes())?;
1933
1934            println!("image     {}", image.digest);
1935            println!("config    {}", image.config_digest);
1936            println!("layer     {}", image.layer_digest);
1937            println!("files     {}", image.files);
1938            println!("from      {}", artifact_digest);
1939            println!(
1940                "source    {}@{}",
1941                signed.record.source_repo, signed.record.source_commit
1942            );
1943            println!();
1944            println!(
1945                "Zero CVEs is this number: {} file(s) in the image's filesystem, on no base at \
1946                 all. Next: `gunnar attest head --builds`, then `gunnar registry push`.",
1947                image.files
1948            );
1949            Ok(())
1950        }
1951        BuildCommand::List => {
1952            let key = stores.provenance_key()?;
1953            let mut any = false;
1954            for line in log.lines_of()? {
1955                let signed: gunnar_core::SignedBuild =
1956                    serde_json::from_str(&line).context("decoding a build record")?;
1957                let verdict = match gunnar_core::provenance::verify_build(&signed, key.public_key())
1958                {
1959                    Ok(()) => "ok ".to_owned(),
1960                    Err(problem) => format!("BAD ({problem})"),
1961                };
1962                println!(
1963                    "{verdict} {} {}@{} {}",
1964                    signed.record.digest(),
1965                    signed.record.source_repo,
1966                    signed.record.source_commit,
1967                    signed.record.recipe
1968                );
1969                any = true;
1970            }
1971            if !any {
1972                // Said rather than left as blank output: an empty listing and a
1973                // listing that failed to read look the same on a terminal.
1974                println!("this server has attested to no builds");
1975            }
1976            Ok(())
1977        }
1978        BuildCommand::Key => {
1979            let key = stores.provenance_key()?;
1980            println!(
1981                "{}",
1982                key.public_key()
1983                    .to_openssh()
1984                    .context("rendering the provenance public key")?
1985            );
1986            Ok(())
1987        }
1988    }
1989}
1990
1991/// `gunnar forge …` — issues, reviews and merges as signed events.
1992pub fn forge(stores: &ServerStores, command: gunnar_common::cli::ForgeCommand) -> Result<()> {
1993    use gunnar_common::cli::ForgeCommand;
1994
1995    let log = stores.forge_log();
1996
1997    /// Read every event, verifying nothing: the fold is over what was written,
1998    /// and verification is a separate question with its own answer.
1999    fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
2000        log.lines_of()?
2001            .iter()
2002            .map(|line| serde_json::from_str(line).context("decoding a forge event"))
2003            .collect()
2004    }
2005
2006    fn append(
2007        stores: &ServerStores,
2008        identity: &std::path::Path,
2009        event: gunnar_core::ForgeEvent,
2010    ) -> Result<gunnar_core::SignedEvent> {
2011        let pem = std::fs::read_to_string(identity)
2012            .with_context(|| format!("reading {}", identity.display()))?;
2013        let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
2014            .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
2015        // **The key must already be in the ACL table.** A signature by a key
2016        // gunnar does not know is not evidence to anybody reading this log
2017        // later — they would have nothing to check it against — so it is
2018        // refused here rather than stored and found unverifiable.
2019        let fingerprint = key
2020            .public_key()
2021            .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
2022            .to_string();
2023        let known = stores
2024            .auth()
2025            .signing_keys()?
2026            .into_iter()
2027            .any(|row| row.fingerprint_sha256 == fingerprint);
2028        anyhow::ensure!(
2029            known,
2030            "{fingerprint} is not registered on this server, so nobody reading this event later \
2031             could check its signature. Register it with `gunnar key add` first."
2032        );
2033        let signed =
2034            gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
2035        let mut line = serde_json::to_string(&signed)?;
2036        line.push('\n');
2037        let _framed = gunnar_core::txlog::sink::append_line(stores.forge_log().records_path(), line.trim_end_matches('\n'))?;
2038        use std::io::Write as _;
2039        let mut file = std::fs::OpenOptions::new()
2040            .create(true)
2041            .append(true)
2042            .open(stores.forge_log().records_path())?;
2043        file.write_all(line.as_bytes())?;
2044        Ok(signed)
2045    }
2046
2047    match command {
2048        ForgeCommand::Comment {
2049            repo,
2050            pull,
2051            body,
2052            actor,
2053            identity,
2054        } => {
2055            append(
2056                stores,
2057                &identity,
2058                gunnar_core::ForgeEvent {
2059                    repo: repo.clone(),
2060                    pull,
2061                    actor,
2062                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2063                    event: gunnar_core::Event::Commented {
2064                        body,
2065                        path: String::new(),
2066                        line: 0,
2067                        blob_oid: String::new(),
2068                    },
2069                },
2070            )?;
2071            println!("commented on {repo}#{pull}");
2072            Ok(())
2073        }
2074        ForgeCommand::Approve {
2075            repo,
2076            pull,
2077            commit,
2078            actor,
2079            identity,
2080        } => {
2081            let signed = append(
2082                stores,
2083                &identity,
2084                gunnar_core::ForgeEvent {
2085                    repo: repo.clone(),
2086                    pull,
2087                    actor: actor.clone(),
2088                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2089                    event: gunnar_core::Event::Approved {
2090                        commit: commit.clone(),
2091                    },
2092                },
2093            )?;
2094            println!("{actor} approved {commit} on {repo}#{pull}");
2095            println!("signed by {}", signed.key_fingerprint);
2096            Ok(())
2097        }
2098        ForgeCommand::Reject {
2099            repo,
2100            pull,
2101            commit,
2102            reason,
2103            actor,
2104            identity,
2105        } => {
2106            let signed = append(
2107                stores,
2108                &identity,
2109                gunnar_core::ForgeEvent {
2110                    repo: repo.clone(),
2111                    pull,
2112                    actor: actor.clone(),
2113                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2114                    event: gunnar_core::Event::Rejected {
2115                        commit: commit.clone(),
2116                        reason,
2117                    },
2118                },
2119            )?;
2120            println!("{actor} withdrew their approval of {commit} on {repo}#{pull}");
2121            println!("signed by {}", signed.key_fingerprint);
2122            Ok(())
2123        }
2124        ForgeCommand::Show { repo, pull } => {
2125            let all = events(&log)?;
2126            let mine: Vec<gunnar_core::ForgeEvent> = all
2127                .iter()
2128                .filter(|e| e.event.repo == repo)
2129                .map(|e| e.event.clone())
2130                .collect();
2131            let state = gunnar_core::forge::fold(pull, &mine);
2132            // The REVIEW only. Whether `{repo}#{pull}` exists, what it is
2133            // called and whether it is open are `PullRequests`' answers, and
2134            // printing a second copy of them here would be a twin.
2135            println!("{repo}#{pull}  review");
2136            println!("comments  {}", state.comments);
2137            if state.approvals.is_empty() {
2138                println!("approvals none");
2139            }
2140            // **Each approval is re-verified against the ACL table**, here, when
2141            // it is read — not trusted because it is in the file. That is the
2142            // difference the whole design is about, and `PullRequests.Merge`
2143            // asks the same question before it moves a ref.
2144            let keys: Vec<(String, gunnar_core::ssh_key::PublicKey)> = stores
2145                .auth()
2146                .signing_keys()?
2147                .iter()
2148                .filter_map(|row| {
2149                    gunnar_core::ssh_key::PublicKey::from_openssh(&row.openssh)
2150                        .ok()
2151                        .map(|key| (row.fingerprint_sha256.clone(), key))
2152                })
2153                .collect();
2154            for (actor, commit) in &state.approvals {
2155                let signed = all.iter().rev().find(|e| {
2156                    e.event.repo == repo
2157                        && e.event.pull == pull
2158                        && e.event.actor == *actor
2159                        && matches!(&e.event.event, gunnar_core::Event::Approved { commit: c } if c == commit)
2160                });
2161                let verdict = match signed {
2162                    None => "NO EVENT".to_owned(),
2163                    Some(signed) => match keys
2164                        .iter()
2165                        .find(|(fingerprint, _)| *fingerprint == signed.key_fingerprint)
2166                    {
2167                        None => "UNKNOWN KEY".to_owned(),
2168                        Some((_, key)) => match gunnar_core::forge::verify_event(signed, key) {
2169                            Ok(()) => "verified".to_owned(),
2170                            Err(problem) => format!("BAD ({problem})"),
2171                        },
2172                    },
2173                };
2174                println!("approval  {actor} → {commit}  [{verdict}]");
2175            }
2176            Ok(())
2177        }
2178        ForgeCommand::Issue(issue) => forge_issue(stores, &log, issue),
2179    }
2180}
2181
2182/// `gunnar forge issue …` — issues as signed events, folded from the one log.
2183fn forge_issue(
2184    stores: &ServerStores,
2185    log: &crate::attest::TransparencyLog,
2186    command: gunnar_common::cli::IssueCommand,
2187) -> Result<()> {
2188    use gunnar_common::cli::IssueCommand;
2189
2190    /// Read every event, verifying nothing — the fold is over what was written.
2191    fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
2192        log.lines_of()?
2193            .iter()
2194            .map(|line| serde_json::from_str(line).context("decoding a forge event"))
2195            .collect()
2196    }
2197
2198    /// One repository's events, in log order, for the fold.
2199    fn repo_events(
2200        log: &crate::attest::TransparencyLog,
2201        repo: &str,
2202    ) -> Result<Vec<gunnar_core::ForgeEvent>> {
2203        Ok(events(log)?
2204            .into_iter()
2205            .filter(|e| e.event.repo == repo)
2206            .map(|e| e.event)
2207            .collect())
2208    }
2209
2210    /// Sign an event with the actor's own key, after checking the key is in the
2211    /// ACL table, and append it. The same door `gunnar forge comment` uses —
2212    /// one writer, one verification of what makes an event evidence (one implementation).
2213    fn append(
2214        stores: &ServerStores,
2215        identity: &std::path::Path,
2216        event: gunnar_core::ForgeEvent,
2217    ) -> Result<gunnar_core::SignedEvent> {
2218        let pem = std::fs::read_to_string(identity)
2219            .with_context(|| format!("reading {}", identity.display()))?;
2220        let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
2221            .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
2222        let fingerprint = key
2223            .public_key()
2224            .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
2225            .to_string();
2226        let known = stores
2227            .auth()
2228            .signing_keys()?
2229            .into_iter()
2230            .any(|row| row.fingerprint_sha256 == fingerprint);
2231        anyhow::ensure!(
2232            known,
2233            "{fingerprint} is not registered on this server, so nobody reading this event later \
2234             could check its signature. Register it with `gunnar key add` first."
2235        );
2236        let signed =
2237            gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
2238        let mut line = serde_json::to_string(&signed)?;
2239        line.push('\n');
2240        let _framed = gunnar_core::txlog::sink::append_line(stores.forge_log().records_path(), line.trim_end_matches('\n'))?;
2241        use std::io::Write as _;
2242        let mut file = std::fs::OpenOptions::new()
2243            .create(true)
2244            .append(true)
2245            .open(stores.forge_log().records_path())?;
2246        file.write_all(line.as_bytes())?;
2247        Ok(signed)
2248    }
2249
2250    match command {
2251        IssueCommand::Open {
2252            repo,
2253            title,
2254            body,
2255            actor,
2256            identity,
2257        } => {
2258            append(
2259                stores,
2260                &identity,
2261                gunnar_core::ForgeEvent {
2262                    repo: repo.clone(),
2263                    // An opening has no number yet: the fold assigns it.
2264                    pull: 0,
2265                    actor: actor.clone(),
2266                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2267                    event: gunnar_core::Event::IssueOpened {
2268                        title: title.clone(),
2269                        body,
2270                    },
2271                },
2272            )?;
2273            // Read back the number the fold now gives it: the Nth opening is
2274            // issue N, so the one just written is the last.
2275            let number = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?)
2276                .last()
2277                .map(|i| i.number)
2278                .unwrap_or(0);
2279            println!("opened {repo}#{number}  {title}");
2280            println!("{actor} filed it, signed with their own key");
2281            Ok(())
2282        }
2283        IssueCommand::Comment {
2284            repo,
2285            number,
2286            body,
2287            actor,
2288            identity,
2289        } => {
2290            anyhow::ensure!(
2291                gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2292                    .is_some_and(|i| i.open),
2293                "no OPEN issue #{number} in {repo}: a comment on a closed or absent issue is \
2294                 evidence about nothing"
2295            );
2296            append(
2297                stores,
2298                &identity,
2299                gunnar_core::ForgeEvent {
2300                    repo: repo.clone(),
2301                    pull: number,
2302                    actor,
2303                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2304                    event: gunnar_core::Event::IssueCommented { body },
2305                },
2306            )?;
2307            println!("commented on {repo}#{number}");
2308            Ok(())
2309        }
2310        IssueCommand::Close {
2311            repo,
2312            number,
2313            reason,
2314            actor,
2315            identity,
2316        } => {
2317            anyhow::ensure!(
2318                gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2319                    .is_some_and(|i| i.open),
2320                "no OPEN issue #{number} in {repo}: it is already closed, or was never opened"
2321            );
2322            append(
2323                stores,
2324                &identity,
2325                gunnar_core::ForgeEvent {
2326                    repo: repo.clone(),
2327                    pull: number,
2328                    actor: actor.clone(),
2329                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2330                    event: gunnar_core::Event::IssueClosed { reason },
2331                },
2332            )?;
2333            println!("{actor} closed {repo}#{number}");
2334            Ok(())
2335        }
2336        IssueCommand::List { repo } => {
2337            let issues = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?);
2338            if issues.is_empty() {
2339                println!("{repo} has no issues");
2340                return Ok(());
2341            }
2342            for issue in issues {
2343                let state = if issue.open { "open  " } else { "closed" };
2344                println!(
2345                    "{repo}#{}  [{state}]  {}  ({} comment(s), by {})",
2346                    issue.number, issue.title, issue.comments, issue.author
2347                );
2348            }
2349            Ok(())
2350        }
2351        IssueCommand::Show { repo, number } => {
2352            let all = repo_events(log, &repo)?;
2353            let Some(issue) = gunnar_core::forge::fold_issue(number, &all) else {
2354                anyhow::bail!("no issue #{number} in {repo}: nobody has opened one");
2355            };
2356            let state = if issue.open { "open" } else { "closed" };
2357            println!("{repo}#{number}  [{state}]  {}", issue.title);
2358            println!("opened by {}", issue.author);
2359            if !issue.body.is_empty() {
2360                println!();
2361                println!("{}", issue.body);
2362            }
2363            println!();
2364            println!("comments  {}", issue.comments);
2365            Ok(())
2366        }
2367    }
2368}
2369
2370/// A ref name or hex oid, as the commit it names.
2371///
2372/// The same two rules `Refs.Log` applies, spelled once: a hex oid of the
2373/// repository's own hash kind is taken as one, and anything else is validated
2374/// as a reference name and resolved against the snapshot.
2375fn resolve_commit(
2376    stores: &ServerStores,
2377    repo: &gunnar_core::RepoRef,
2378    rev: &str,
2379) -> Result<gix_hash::ObjectId> {
2380    let entry = stores
2381        .catalog()
2382        .get(repo)?
2383        .with_context(|| format!("no such repository: {repo}"))?;
2384    let store = stores.repo_store(&entry)?;
2385    let git = store.serve();
2386    if let Ok(oid) = gix_hash::ObjectId::from_hex(rev.as_bytes()) {
2387        anyhow::ensure!(
2388            oid.kind() == store.hash_kind(),
2389            "{rev} is a {} oid; this repository is {}",
2390            oid.kind(),
2391            store.hash_kind()
2392        );
2393        return Ok(oid);
2394    }
2395    let snapshot = gunnar_wire::RefSnapshot::read(git)?;
2396    let resolved = if rev == gunnar_store::HEAD {
2397        snapshot.head(git)?
2398    } else {
2399        snapshot.resolve(rev)?
2400    };
2401    resolved.with_context(|| format!("{rev} names no commit in {repo}"))
2402}
2403
2404/// `gunnar registry …` — the artifact registry (roadmap phase 4).
2405pub fn registry(
2406    stores: &std::sync::Arc<ServerStores>,
2407    command: gunnar_common::cli::RegistryCommand,
2408) -> Result<()> {
2409    use gunnar_common::cli::RegistryCommand;
2410
2411    let registry = crate::oci::Registry::under(stores.root());
2412    match command {
2413        RegistryCommand::Push { name, tag, digest } => {
2414            let manifest = registry
2415                .get_blob(&digest)?
2416                .with_context(|| format!("{digest} is not in this registry"))?;
2417            match registry.put_manifest(stores, &name, &tag, &manifest)? {
2418                Ok(landed) => {
2419                    println!(
2420                        "pushed {}:{} → {}",
2421                        landed.name, landed.reference, landed.digest
2422                    );
2423                    Ok(())
2424                }
2425                Err(refusal) => {
2426                    println!("refused {digest}");
2427                    println!("{refusal}");
2428                    anyhow::bail!("the registry refuses this manifest")
2429                }
2430            }
2431        }
2432        RegistryCommand::Resolve { name, tag } => {
2433            let digest = registry
2434                .resolve(&name, &tag)?
2435                .with_context(|| format!("{name}:{tag} is not in this registry"))?;
2436            println!("{digest}");
2437            Ok(())
2438        }
2439        RegistryCommand::Pull { name, tag, line } => {
2440            let digest = registry
2441                .resolve(&name, &tag)?
2442                .with_context(|| format!("{name}:{tag} is not in this registry"))?;
2443            // Read it back through the store, which verifies the blob still
2444            // hashes to its own name — a pull that skipped that would serve
2445            // whatever is on the disk under the right filename.
2446            let manifest = registry
2447                .get_blob(&digest)?
2448                .with_context(|| format!("{digest} is tagged and its manifest is not stored"))?;
2449            println!("{}", String::from_utf8_lossy(&manifest));
2450            println!();
2451
2452            // The handle we were given, not a second one: redb takes an
2453            // exclusive lock on `control.redb`, so opening a second
2454            // `ServerStores` in the same process is `Database already open`.
2455            let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(stores));
2456            let chain = crate::verify::verify_artifact(
2457                stores,
2458                &plane,
2459                digest.trim_start_matches("sha256:"),
2460                line.as_deref(),
2461            )?;
2462            println!("{chain}");
2463            if chain.is_unbroken() {
2464                return Ok(());
2465            }
2466            if chain.is_broken() {
2467                anyhow::bail!("the chain behind this image is broken");
2468            }
2469            anyhow::bail!("the chain behind this image is incomplete")
2470        }
2471    }
2472}
2473
2474/// **What a per-repository restore cannot bring back** (wiring tracer B 2026-10-08): a
2475/// repository archive carries the objects and the refs, never the control document's settings
2476/// — and a repository catalogued afresh by `archive-in` is NOT encrypted, whatever it was. The
2477/// unsafe direction, so it is said, with the one command that sets it again.
2478#[cfg(feature = "engine-znippy")]
2479pub(crate) fn encrypted_flag_note(path: &RepoRef) -> String {
2480    format!(
2481        "note: a repository archive does not carry the account's settings, so {path} is NOT \
2482         encrypted now, whatever it was. If it was, the account admin turns it on again: at the \
2483         console (the repository's Settings, \"Turn encryption on\"), or gunnar control repo \
2484         set-encrypted {path} on where the control plane is in reach; or restore the control \
2485         document as well"
2486    )
2487}
2488
2489#[cfg(all(test, feature = "engine-znippy"))]
2490mod tests {
2491    use super::*;
2492
2493    #[test]
2494    fn a_repository_restored_without_its_control_document_is_told_its_encryption_is_not_back() {
2495        let path = RepoRef::new("acme", "vault").expect("a legal path");
2496        let note = encrypted_flag_note(&path);
2497        assert!(note.contains("encrypted"), "{note}");
2498        assert!(note.contains("gunnar control repo set-encrypted acme/vault on"), "the one command that sets it again: {note}");
2499    }
2500
2501    /// ★ An installed estate binds its control plane to the WireGuard overlay, so the account
2502    /// admin who restored there has no `gunnar control` in reach: the note also names the door
2503    /// they do have — the repository's Settings at the console ("Turn encryption on").
2504    #[test]
2505    fn the_note_names_the_consoles_switch_an_estate_admin_can_reach() {
2506        let path = RepoRef::new("acme", "vault").expect("a legal path");
2507        let note = encrypted_flag_note(&path);
2508        assert!(note.contains("console") && note.contains("Turn encryption on"), "the console's switch: {note}");
2509    }
2510}