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