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