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 => {
1528            let key = stores.transparency_key()?;
1529            let signed = log.issue_head(&key, gunnar_grpc::bus::now_unix_ms())?;
1530            println!("size      {}", signed.head.size);
1531            println!("root      {}", signed.head.root);
1532            println!("signed by {}", signed.key_fingerprint);
1533            println!("appended  {}", log.heads_path().display());
1534            if signed.head.size == 0 {
1535                // Said out loud rather than left to be inferred from `size 0`.
1536                // A signed empty head is a real claim — "this server had
1537                // accepted no push at this moment" — and an operator who issued
1538                // one by accident should notice.
1539                println!();
1540                println!(
1541                    "This head covers an EMPTY log: it says this server had accepted no push \
1542                     when it was signed."
1543                );
1544            }
1545            Ok(())
1546        }
1547        AttestCommand::Audit => {
1548            let key = stores.transparency_key()?;
1549            let verdict = log.audit(key.public_key())?;
1550            println!("{verdict}");
1551            if verdict.is_consistent() {
1552                return Ok(());
1553            }
1554            // A non-zero exit, because this is the verb an operator wires into
1555            // a check. Printing the verdict and exiting 0 would make a
1556            // tampered log look like a passing job.
1557            anyhow::bail!("the transparency log did not audit clean")
1558        }
1559        AttestCommand::Key => {
1560            let key = stores.transparency_key()?;
1561            println!(
1562                "{}",
1563                key.public_key()
1564                    .to_openssh()
1565                    .context("rendering the transparency log's public key")?
1566            );
1567            Ok(())
1568        }
1569        AttestCommand::Prove { pack_hash } => {
1570            let Some(inclusion) = log.prove_pack(&pack_hash)? else {
1571                anyhow::bail!(
1572                    "no attestation in this log names pack {pack_hash}. A proof for a record \
1573                     the log does not hold is not an empty proof."
1574                )
1575            };
1576            println!("index {}", inclusion.index);
1577            println!("size  {}", inclusion.size);
1578            println!("leaf  {}", inclusion.leaf);
1579            println!("root  {}", inclusion.root);
1580            for (step, hash) in inclusion.proof.iter().enumerate() {
1581                println!("proof[{step}] {hash}");
1582            }
1583            Ok(())
1584        }
1585    }
1586}
1587
1588/// `gunnar verify` — walk the chain backwards from a line of source.
1589///
1590/// **The exit code is the verdict.** Zero only when every link is proven; a
1591/// broken link and an unproven one both exit non-zero, and they print
1592/// differently. A command whose output an operator has to read to learn whether
1593/// it passed is a command nobody wires into anything.
1594pub fn verify(data_root: &std::path::Path, args: gunnar_common::cli::VerifyArgs) -> Result<()> {
1595    let stores = std::sync::Arc::new(crate::stores::ServerStores::open(data_root)?);
1596    let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
1597    let repo = &args.repo;
1598    let (namespace, name) = repo.split_once('/').with_context(|| {
1599        format!(
1600            "{repo:?} is not `<namespace>/<repo>`; every repository lives in a \
1601             namespace (multi-tenancy)"
1602        )
1603    })?;
1604    let chain = crate::verify::verify(
1605        &stores,
1606        &plane,
1607        &gunnar_proto::RepoPath::new(namespace, name),
1608        &args.at,
1609        args.line.as_deref(),
1610    )?;
1611    println!("{chain}");
1612    if chain.is_unbroken() {
1613        return Ok(());
1614    }
1615    if chain.is_broken() {
1616        anyhow::bail!("the chain is broken");
1617    }
1618    anyhow::bail!("the chain is incomplete")
1619}
1620
1621/// `gunnar blame <ns>/<repo> <path> [--rev]` — who wrote each line.
1622///
1623/// A separate process from a running server for the reason `verify` is: opening
1624/// `ServerStores` takes redb's exclusive lock, which a live server holds.
1625pub fn blame(data_root: &std::path::Path, args: gunnar_common::cli::BlameArgs) -> Result<()> {
1626    let (namespace, name) = args.repo.split_once('/').with_context(|| {
1627        format!(
1628            "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1629             namespace (multi-tenancy)",
1630            args.repo
1631        )
1632    })?;
1633    let rows = crate::blame::blame_at(
1634        data_root,
1635        &gunnar_proto::RepoPath::new(namespace, name),
1636        &args.rev,
1637        &args.path,
1638    )?;
1639    // `<short-oid> <author> <line>`, one line each — the shape `git blame`
1640    // readers expect, trimmed to what an auditor asked: who, and what.
1641    for row in &rows {
1642        let hex = row.commit.to_hex().to_string();
1643        let short = &hex[..hex.len().min(12)];
1644        println!(
1645            "{short} {}\t{}",
1646            row.author,
1647            String::from_utf8_lossy(&row.line)
1648        );
1649    }
1650    Ok(())
1651}
1652
1653/// `gunnar search <ns>/<repo> <pattern> [--rev] [--max-*]` — a literal string
1654/// across the latest revision's files.
1655pub fn search(data_root: &std::path::Path, args: gunnar_common::cli::SearchArgs) -> Result<()> {
1656    let (namespace, name) = args.repo.split_once('/').with_context(|| {
1657        format!(
1658            "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1659             namespace (multi-tenancy)",
1660            args.repo
1661        )
1662    })?;
1663    let limits = crate::search::Limits {
1664        max_file_bytes: args.max_file_bytes,
1665        max_matches: args.max_matches,
1666    };
1667    let hits = crate::search::search_at(
1668        data_root,
1669        &gunnar_proto::RepoPath::new(namespace, name),
1670        &args.rev,
1671        &args.pattern,
1672        limits,
1673    )?;
1674    for hit in &hits {
1675        println!(
1676            "{}:{}:{}",
1677            hit.path,
1678            hit.line_number,
1679            String::from_utf8_lossy(&hit.line)
1680        );
1681    }
1682    Ok(())
1683}
1684
1685/// `gunnar forge …` — issues, reviews and merges as signed events.
1686pub fn forge(stores: &ServerStores, command: gunnar_common::cli::ForgeCommand) -> Result<()> {
1687    use gunnar_common::cli::ForgeCommand;
1688
1689    let log = stores.forge_log();
1690
1691    /// Read every event, verifying nothing: the fold is over what was written,
1692    /// and verification is a separate question with its own answer.
1693    fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
1694        log.lines_of()?
1695            .iter()
1696            .map(|line| serde_json::from_str(line).context("decoding a forge event"))
1697            .collect()
1698    }
1699
1700    fn append(
1701        stores: &ServerStores,
1702        identity: &std::path::Path,
1703        event: gunnar_core::ForgeEvent,
1704    ) -> Result<gunnar_core::SignedEvent> {
1705        let pem = std::fs::read_to_string(identity)
1706            .with_context(|| format!("reading {}", identity.display()))?;
1707        let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
1708            .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
1709        // **The key must already be in the ACL table.** A signature by a key
1710        // gunnar does not know is not evidence to anybody reading this log
1711        // later — they would have nothing to check it against — so it is
1712        // refused here rather than stored and found unverifiable.
1713        let fingerprint = key
1714            .public_key()
1715            .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
1716            .to_string();
1717        let known = stores
1718            .auth()
1719            .signing_keys()?
1720            .into_iter()
1721            .any(|row| row.fingerprint_sha256 == fingerprint);
1722        anyhow::ensure!(
1723            known,
1724            "{fingerprint} is not registered on this server, so nobody reading this event later \
1725             could check its signature. Register it with `gunnar key add` first."
1726        );
1727        let signed =
1728            gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
1729        let mut line = serde_json::to_string(&signed)?;
1730        line.push('\n');
1731        let _framed = gunnar_core::txlog::sink::append_line(
1732            stores.forge_log().records_path(),
1733            line.trim_end_matches('\n'),
1734        )?;
1735        use std::io::Write as _;
1736        let mut file = std::fs::OpenOptions::new()
1737            .create(true)
1738            .append(true)
1739            .open(stores.forge_log().records_path())?;
1740        file.write_all(line.as_bytes())?;
1741        Ok(signed)
1742    }
1743
1744    match command {
1745        ForgeCommand::Comment {
1746            repo,
1747            pull,
1748            body,
1749            actor,
1750            identity,
1751        } => {
1752            append(
1753                stores,
1754                &identity,
1755                gunnar_core::ForgeEvent {
1756                    repo: repo.clone(),
1757                    pull,
1758                    actor,
1759                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
1760                    event: gunnar_core::Event::Commented {
1761                        body,
1762                        path: String::new(),
1763                        line: 0,
1764                        blob_oid: String::new(),
1765                    },
1766                },
1767            )?;
1768            println!("commented on {repo}#{pull}");
1769            Ok(())
1770        }
1771        ForgeCommand::Approve {
1772            repo,
1773            pull,
1774            commit,
1775            actor,
1776            identity,
1777        } => {
1778            let signed = append(
1779                stores,
1780                &identity,
1781                gunnar_core::ForgeEvent {
1782                    repo: repo.clone(),
1783                    pull,
1784                    actor: actor.clone(),
1785                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
1786                    event: gunnar_core::Event::Approved {
1787                        commit: commit.clone(),
1788                    },
1789                },
1790            )?;
1791            println!("{actor} approved {commit} on {repo}#{pull}");
1792            println!("signed by {}", signed.key_fingerprint);
1793            Ok(())
1794        }
1795        ForgeCommand::Reject {
1796            repo,
1797            pull,
1798            commit,
1799            reason,
1800            actor,
1801            identity,
1802        } => {
1803            let signed = append(
1804                stores,
1805                &identity,
1806                gunnar_core::ForgeEvent {
1807                    repo: repo.clone(),
1808                    pull,
1809                    actor: actor.clone(),
1810                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
1811                    event: gunnar_core::Event::Rejected {
1812                        commit: commit.clone(),
1813                        reason,
1814                    },
1815                },
1816            )?;
1817            println!("{actor} withdrew their approval of {commit} on {repo}#{pull}");
1818            println!("signed by {}", signed.key_fingerprint);
1819            Ok(())
1820        }
1821        ForgeCommand::Show { repo, pull } => {
1822            let all = events(&log)?;
1823            let mine: Vec<gunnar_core::ForgeEvent> = all
1824                .iter()
1825                .filter(|e| e.event.repo == repo)
1826                .map(|e| e.event.clone())
1827                .collect();
1828            let state = gunnar_core::forge::fold(pull, &mine);
1829            // The REVIEW only. Whether `{repo}#{pull}` exists, what it is
1830            // called and whether it is open are `PullRequests`' answers, and
1831            // printing a second copy of them here would be a twin.
1832            println!("{repo}#{pull}  review");
1833            println!("comments  {}", state.comments);
1834            if state.approvals.is_empty() {
1835                println!("approvals none");
1836            }
1837            // **Each approval is re-verified against the ACL table**, here, when
1838            // it is read — not trusted because it is in the file. That is the
1839            // difference the whole design is about, and `PullRequests.Merge`
1840            // asks the same question before it moves a ref.
1841            let keys: Vec<(String, gunnar_core::ssh_key::PublicKey)> = stores
1842                .auth()
1843                .signing_keys()?
1844                .iter()
1845                .filter_map(|row| {
1846                    gunnar_core::ssh_key::PublicKey::from_openssh(&row.openssh)
1847                        .ok()
1848                        .map(|key| (row.fingerprint_sha256.clone(), key))
1849                })
1850                .collect();
1851            let attest_key = stores.forge_attest_public()?;
1852            for (actor, commit) in &state.approvals {
1853                let signed = all.iter().rev().find(|e| {
1854                    e.event.repo == repo
1855                        && e.event.pull == pull
1856                        && e.event.actor == *actor
1857                        && matches!(&e.event.event, gunnar_core::Event::Approved { commit: c } if c == commit)
1858                });
1859                let verdict = match signed {
1860                    None => "NO EVENT".to_owned(),
1861                    // Gunnar's attestation for a stepped-up session (GHUNT-19):
1862                    // checked against gunnar's own forge-attestation key alone.
1863                    Some(signed) if signed.attested.is_some() => match &attest_key {
1864                        None => "UNKNOWN KEY".to_owned(),
1865                        Some(key) => match gunnar_core::forge::verify_event(signed, key) {
1866                            Ok(()) => signed
1867                                .attested
1868                                .as_ref()
1869                                .map(|a| a.words())
1870                                .unwrap_or_default(),
1871                            Err(problem) => format!("BAD ({problem})"),
1872                        },
1873                    },
1874                    Some(signed) => match keys
1875                        .iter()
1876                        .find(|(fingerprint, _)| *fingerprint == signed.key_fingerprint)
1877                    {
1878                        None => "UNKNOWN KEY".to_owned(),
1879                        Some((_, key)) => match gunnar_core::forge::verify_event(signed, key) {
1880                            Ok(()) => "verified".to_owned(),
1881                            Err(problem) => format!("BAD ({problem})"),
1882                        },
1883                    },
1884                };
1885                println!("approval  {actor} → {commit}  [{verdict}]");
1886            }
1887            Ok(())
1888        }
1889        ForgeCommand::Issue(issue) => forge_issue(stores, &log, issue),
1890    }
1891}
1892
1893/// `gunnar forge issue …` — issues as signed events, folded from the one log.
1894fn forge_issue(
1895    stores: &ServerStores,
1896    log: &crate::attest::TransparencyLog,
1897    command: gunnar_common::cli::IssueCommand,
1898) -> Result<()> {
1899    use gunnar_common::cli::IssueCommand;
1900
1901    /// Read every event, verifying nothing — the fold is over what was written.
1902    fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
1903        log.lines_of()?
1904            .iter()
1905            .map(|line| serde_json::from_str(line).context("decoding a forge event"))
1906            .collect()
1907    }
1908
1909    /// One repository's events, in log order, for the fold.
1910    fn repo_events(
1911        log: &crate::attest::TransparencyLog,
1912        repo: &str,
1913    ) -> Result<Vec<gunnar_core::ForgeEvent>> {
1914        Ok(events(log)?
1915            .into_iter()
1916            .filter(|e| e.event.repo == repo)
1917            .map(|e| e.event)
1918            .collect())
1919    }
1920
1921    /// Sign an event with the actor's own key, after checking the key is in the
1922    /// ACL table, and append it. The same door `gunnar forge comment` uses —
1923    /// one writer, one verification of what makes an event evidence (one implementation).
1924    fn append(
1925        stores: &ServerStores,
1926        identity: &std::path::Path,
1927        event: gunnar_core::ForgeEvent,
1928    ) -> Result<gunnar_core::SignedEvent> {
1929        let pem = std::fs::read_to_string(identity)
1930            .with_context(|| format!("reading {}", identity.display()))?;
1931        let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
1932            .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
1933        let fingerprint = key
1934            .public_key()
1935            .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
1936            .to_string();
1937        let known = stores
1938            .auth()
1939            .signing_keys()?
1940            .into_iter()
1941            .any(|row| row.fingerprint_sha256 == fingerprint);
1942        anyhow::ensure!(
1943            known,
1944            "{fingerprint} is not registered on this server, so nobody reading this event later \
1945             could check its signature. Register it with `gunnar key add` first."
1946        );
1947        let signed =
1948            gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
1949        let mut line = serde_json::to_string(&signed)?;
1950        line.push('\n');
1951        let _framed = gunnar_core::txlog::sink::append_line(
1952            stores.forge_log().records_path(),
1953            line.trim_end_matches('\n'),
1954        )?;
1955        use std::io::Write as _;
1956        let mut file = std::fs::OpenOptions::new()
1957            .create(true)
1958            .append(true)
1959            .open(stores.forge_log().records_path())?;
1960        file.write_all(line.as_bytes())?;
1961        Ok(signed)
1962    }
1963
1964    match command {
1965        IssueCommand::Open {
1966            repo,
1967            title,
1968            body,
1969            actor,
1970            identity,
1971        } => {
1972            append(
1973                stores,
1974                &identity,
1975                gunnar_core::ForgeEvent {
1976                    repo: repo.clone(),
1977                    // An opening has no number yet: the fold assigns it.
1978                    pull: 0,
1979                    actor: actor.clone(),
1980                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
1981                    event: gunnar_core::Event::IssueOpened {
1982                        title: title.clone(),
1983                        body,
1984                    },
1985                },
1986            )?;
1987            // Read back the number the fold now gives it: the Nth opening is
1988            // issue N, so the one just written is the last.
1989            let number = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?)
1990                .last()
1991                .map(|i| i.number)
1992                .unwrap_or(0);
1993            println!("opened {repo}#{number}  {title}");
1994            println!("{actor} filed it, signed with their own key");
1995            Ok(())
1996        }
1997        IssueCommand::Comment {
1998            repo,
1999            number,
2000            body,
2001            actor,
2002            identity,
2003        } => {
2004            anyhow::ensure!(
2005                gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2006                    .is_some_and(|i| i.open),
2007                "no OPEN issue #{number} in {repo}: a comment on a closed or absent issue is \
2008                 evidence about nothing"
2009            );
2010            append(
2011                stores,
2012                &identity,
2013                gunnar_core::ForgeEvent {
2014                    repo: repo.clone(),
2015                    pull: number,
2016                    actor,
2017                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2018                    event: gunnar_core::Event::IssueCommented { body },
2019                },
2020            )?;
2021            println!("commented on {repo}#{number}");
2022            Ok(())
2023        }
2024        IssueCommand::Close {
2025            repo,
2026            number,
2027            reason,
2028            actor,
2029            identity,
2030        } => {
2031            anyhow::ensure!(
2032                gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2033                    .is_some_and(|i| i.open),
2034                "no OPEN issue #{number} in {repo}: it is already closed, or was never opened"
2035            );
2036            append(
2037                stores,
2038                &identity,
2039                gunnar_core::ForgeEvent {
2040                    repo: repo.clone(),
2041                    pull: number,
2042                    actor: actor.clone(),
2043                    at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2044                    event: gunnar_core::Event::IssueClosed { reason },
2045                },
2046            )?;
2047            println!("{actor} closed {repo}#{number}");
2048            Ok(())
2049        }
2050        IssueCommand::List { repo } => {
2051            let issues = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?);
2052            if issues.is_empty() {
2053                println!("{repo} has no issues");
2054                return Ok(());
2055            }
2056            for issue in issues {
2057                let state = if issue.open { "open  " } else { "closed" };
2058                println!(
2059                    "{repo}#{}  [{state}]  {}  ({} comment(s), by {})",
2060                    issue.number, issue.title, issue.comments, issue.author
2061                );
2062            }
2063            Ok(())
2064        }
2065        IssueCommand::Show { repo, number } => {
2066            let all = repo_events(log, &repo)?;
2067            let Some(issue) = gunnar_core::forge::fold_issue(number, &all) else {
2068                anyhow::bail!("no issue #{number} in {repo}: nobody has opened one");
2069            };
2070            let state = if issue.open { "open" } else { "closed" };
2071            println!("{repo}#{number}  [{state}]  {}", issue.title);
2072            println!("opened by {}", issue.author);
2073            if !issue.body.is_empty() {
2074                println!();
2075                println!("{}", issue.body);
2076            }
2077            println!();
2078            println!("comments  {}", issue.comments);
2079            Ok(())
2080        }
2081    }
2082}
2083
2084/// **What a per-repository restore cannot bring back** (wiring tracer B 2026-10-08): a
2085/// repository archive carries the objects and the refs, never the control document's settings
2086/// — and a repository catalogued afresh by `archive-in` is NOT encrypted, whatever it was. The
2087/// unsafe direction, so it is said, with the one command that sets it again.
2088#[cfg(feature = "engine-znippy")]
2089pub(crate) fn encrypted_flag_note(path: &RepoRef) -> String {
2090    format!(
2091        "note: a repository archive does not carry the account's settings, so {path} is NOT \
2092         encrypted now, whatever it was. If it was, the account admin turns it on again: at the \
2093         console (the repository's Settings, \"Turn encryption on\"), or gunnar control repo \
2094         set-encrypted {path} on where the control plane is in reach; or restore the control \
2095         document as well"
2096    )
2097}
2098
2099#[cfg(all(test, feature = "engine-znippy"))]
2100mod tests {
2101    use super::*;
2102
2103    #[test]
2104    fn a_repository_restored_without_its_control_document_is_told_its_encryption_is_not_back() {
2105        let path = RepoRef::new("acme", "vault").expect("a legal path");
2106        let note = encrypted_flag_note(&path);
2107        assert!(note.contains("encrypted"), "{note}");
2108        assert!(
2109            note.contains("gunnar control repo set-encrypted acme/vault on"),
2110            "the one command that sets it again: {note}"
2111        );
2112    }
2113
2114    /// ★ An installed estate binds its control plane to the WireGuard overlay, so the account
2115    /// admin who restored there has no `gunnar control` in reach: the note also names the door
2116    /// they do have — the repository's Settings at the console ("Turn encryption on").
2117    #[test]
2118    fn the_note_names_the_consoles_switch_an_estate_admin_can_reach() {
2119        let path = RepoRef::new("acme", "vault").expect("a legal path");
2120        let note = encrypted_flag_note(&path);
2121        assert!(
2122            note.contains("console") && note.contains("Turn encryption on"),
2123            "the console's switch: {note}"
2124        );
2125    }
2126}