crates/gunnar-server/src/admin.rs
//! The administrative verbs: namespaces, repositories, principals, keys,
//! grants, and `gc`.
//!
//! These run against the same [`RepoCatalog`] and [`AuthTable`] the gRPC
//! control plane serves, so a repository created here is immediately visible
//! there and vice versa. There is no second model and no separate file format
//! (one implementation).
use anyhow::{bail, Context, Result};
use gunnar_core::observe::TARGET_STORE;
use gunnar_core::{Fingerprint, Grant, NewRepo, Repo, RepoRef, Scope, Visibility};
use crate::storage::MaintenanceReport;
use crate::stores::ServerStores;
use gunnar_common::cli::{
GcArgs, GrantArgs, KeyCommand, NamespaceCommand, PrincipalCommand, ReflogArgs, RepoCommand,
RevokeArgs,
};
/// `<namespace>/<name>`, through the same sandbox the SSH listener uses.
///
/// One resolver, not two: a path the CLI accepts and the SSH path sandbox
/// rejects would be a repository nobody could clone (one implementation).
fn repo_ref(path: &str) -> Result<RepoRef> {
gunnar_core::resolve_repo_path(path)
.with_context(|| format!("`{path}` is not a valid <namespace>/<name>"))
}
/// A certificate's DER, read from a file holding either PEM or DER.
///
/// The unwrap itself lives in `gunnar-core` beside the fingerprint, because the
/// two must agree about what the certificate bytes are.
fn read_certificate(path: &std::path::Path) -> Result<Vec<u8>> {
let raw = std::fs::read(path)
.with_context(|| format!("reading the certificate at {}", path.display()))?;
Ok(gunnar_core::auth::certificate_der(&raw)?)
}
/// An OpenSSH public key line, or `@<path>`.
fn key_argument(spec: &str) -> Result<String> {
match spec.strip_prefix('@') {
Some(path) => std::fs::read_to_string(path)
.with_context(|| format!("reading the public key from {path}"))
.map(|text| text.trim().to_owned()),
None => Ok(spec.trim().to_owned()),
}
}
// ── the certificate issuer ─────────────────────────────────────────────────
/// **The PKI document, as one value** — the issuer and the trusted-CA list.
///
/// The ONE reader (one implementation). `gunnar issuer show` prints it, and
/// `CoreControlPlane::issuer_info` puts it on the wire for `Admin.IssuerInfo`;
/// neither assembles its own, so a box cannot say one thing on its own disk
/// and another over the control plane.
///
/// Takes an already-opened [`gunnar_core::Pki`] rather than the stores,
/// because the CAPABILITY is the caller's decision: both callers hand it
/// `ServerStores::pki_readonly()`, whose secret store refuses every call, and
/// this function has no way to reach a private key whatever it is given — the
/// two types it reads out of, `Issuer` and `TrustedCa`, have no field that
/// could hold one.
pub fn issuer_document(pki: &gunnar_core::Pki) -> Result<gunnar_proto::IssuerInfoResponse> {
let trusted = pki
.trusted_cas()
.context("reading the trusted-CA list")?
.into_iter()
.map(|ca| gunnar_proto::TrustedCaInfo {
label: ca.label,
fingerprint: ca.fingerprint.to_string(),
cert_pem: ca.cert_pem,
added_unix_ms: ca.added,
is_issuer: ca.is_issuer,
})
.collect();
Ok(match pki.issuer().context("reading the issuer")? {
Some(live) => gunnar_proto::IssuerInfoResponse {
has_issuer: true,
subject: live.subject,
fingerprint: live.fingerprint.to_string(),
cert_pem: live.cert_pem,
created_unix_ms: live.created,
origin: live.origin.to_string(),
trusted,
},
// Every other field empty, and `has_issuer` is what says so: a data
// root that has never been served has never minted one, and that is a
// different fact from "the document could not be read" — which is an
// error here, never an empty answer.
None => gunnar_proto::IssuerInfoResponse {
has_issuer: false,
trusted,
..Default::default()
},
})
}
/// Run a `gunnar issuer …` verb.
pub fn issuer(stores: &ServerStores, command: gunnar_common::cli::IssuerCommand) -> Result<()> {
use gunnar_common::cli::IssuerCommand;
match command {
IssuerCommand::Create { subject, days } => {
// Refused, loudly, on a server that already has one. That refusal
// lives in `gunnar_core::issuer` — in the document's own
// compare-and-swap, not only in a check this command could skip.
let created = stores.pki()?.create(&subject, days)?;
println!(
"issuer created: {} ({})",
created.subject, created.fingerprint
);
eprintln!(
"its private key is sealed in the secret store and is not in the control \
database. There is no way to print it, and that is deliberate: a key that can \
mint any user is the one whose loss means an attacker issues themselves an \
administrator"
);
}
IssuerCommand::Import { cert, key } => {
let cert_pem = std::fs::read_to_string(&cert)
.with_context(|| format!("reading the CA certificate at {}", cert.display()))?;
let key_pem = std::fs::read_to_string(&key)
.with_context(|| format!("reading the CA private key at {}", key.display()))?;
let imported = stores.pki()?.import(&cert_pem, &key_pem)?;
println!(
"issuer imported: {} ({})",
imported.subject, imported.fingerprint
);
}
IssuerCommand::Show {
pem,
at,
identity: _,
} => {
// `--at` is the SAME verb asked over the wire, and it is dispatched
// in `gunnar-cli` before this arm is ever reached. Getting here
// with one means this binary was built without the control client,
// and the honest answer is to say so — not to silently read the
// local data root and let the operator believe they were told
// about the box they named.
if let Some(at) = at {
bail!(
"`--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"
);
}
// The READ-ONLY handle: `show` is a control-plane read and must
// work on a binary with no storage engine, and it must be unable to
// reach the private key even if this arm were later edited.
//
// Through the SAME reader and the SAME renderer `Admin.IssuerInfo`
// uses (one implementation), so the lines printed here and the
// lines `--at` prints cannot disagree about one document.
print!("{}", issuer_document(&stores.pki_readonly()?)?.render(pem));
}
IssuerCommand::Trust { label, cert } => {
let cert_pem = std::fs::read_to_string(&cert)
.with_context(|| format!("reading the CA certificate at {}", cert.display()))?;
let label = if label.is_empty() {
cert.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "ca".to_owned())
} else {
label
};
let added = stores.pki()?.trust_ca(&label, &cert_pem)?;
println!("trusting {} ({})", added.label, added.fingerprint);
}
// Printed through `Display` rather than assembled here: `serve.rs`
// refuses with the same words out of the same type, and a second
// renderer is a second story about one server.
IssuerCommand::Readiness => {
print!("{}", crate::tls_preflight::preflight(stores)?);
println!();
}
IssuerCommand::Untrust { fingerprint } => {
let parsed = Fingerprint::parse(&fingerprint)?;
match stores.pki()?.untrust_ca(&parsed)? {
Some(removed) => println!("no longer trusting {} ({parsed})", removed.label),
None => bail!("no trusted CA has fingerprint {parsed}"),
}
}
}
Ok(())
}
/// Write an issued certificate — and, on the weaker path, its key — and
/// register it to the principal it names.
///
/// The file names match what `cert-helper` writes and therefore what
/// `bench/src/trust.rs` produces, so an operator holding both kinds of material
/// does not have to know which tool made which.
///
/// The private key is written at `0600` on unix, and the mode is set at
/// **creation** rather than after: a key that is world-readable for the
/// microseconds between `write` and `set_permissions` is a key that was
/// world-readable.
fn hand_over(
stores: &ServerStores,
dir: &std::path::Path,
issued: &gunnar_core::Issued,
) -> Result<()> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating the output directory {}", dir.display()))?;
let cert_path = dir.join(format!("{}_cert.pem", issued.principal_id));
std::fs::write(&cert_path, &issued.cert_pem)
.with_context(|| format!("writing {}", cert_path.display()))?;
// Registered as well as issued. The certificate-identity plan keeps the
// stronger form available at no cost: the CN gives the identity and the
// fingerprint gives the registration, and a deployment that wants both to
// agree needs the fingerprint to be in the table.
let registered =
stores
.auth()
.register_certificate(&issued.principal_id, &issued.cert_der, None)?;
println!(
"issued {} -> {}",
registered.fingerprint_sha256,
cert_path.display()
);
if let Some(key_pem) = &issued.key_pem {
let key_path = dir.join(format!("{}_pkey.pem", issued.principal_id));
write_private(&key_path, key_pem)?;
println!("private key -> {}", key_path.display());
eprintln!(
"this key was generated HERE and is now on the wire and on this disk. \
`--csr` is the form where it never leaves {}'s machine",
issued.principal_id
);
}
Ok(())
}
/// Write a private key so that it is never, at any instant, readable by anyone
/// but its owner.
fn write_private(path: &std::path::Path, contents: &str) -> Result<()> {
use std::io::Write as _;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
// `mode()` applies only when the file is CREATED, so an existing file would
// keep whatever permissions it had. Remove it first.
if path.exists() {
std::fs::remove_file(path)
.with_context(|| format!("removing the previous {}", path.display()))?;
}
let mut file = options
.open(path)
.with_context(|| format!("creating {}", path.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
// ── namespaces ──────────────────────────────────────────────────────────────
/// Run a `gunnar namespace …` verb.
pub fn namespace(stores: &ServerStores, command: NamespaceCommand) -> Result<()> {
match command {
NamespaceCommand::Create { name } => {
let created = stores.catalog().create_namespace(&name)?;
println!("created namespace {}", created.name);
}
NamespaceCommand::List => {
let mut names: Vec<String> = stores
.catalog()
.namespaces()?
.into_iter()
.map(|ns| ns.name)
.collect();
names.sort();
for name in names {
println!("{name}");
}
}
NamespaceCommand::Delete { name, delete_repos } => {
let gone = stores.catalog().delete_namespace(&name, delete_repos)?;
println!("deleted namespace {name} and {} repositories", gone.len());
}
NamespaceCommand::SetBudget {
name,
explode_budget,
} => {
let Some(spec) = explode_budget else {
anyhow::bail!(
"`namespace set-budget` needs a budget to set; today that is \
`--explode-budget <8G|512M|unlimited|default>`"
);
};
let bytes = match spec.trim().to_ascii_lowercase().as_str() {
"default" | "" => None,
#[cfg(feature = "engine-znippy")]
other => Some(
crate::storage::znippy_engine::parse_explode_budget(other)
.context("reading the explode budget")?,
),
#[cfg(not(feature = "engine-znippy"))]
other => anyhow::bail!("an explode budget ({other}) is the znippy engine's; this server was built without it"),
};
let ns = stores
.catalog()
.set_namespace_explode_budget(&name, bytes)?;
// Applied to the stores that are open right now, not only to the
// ones opened later — an admin who raises a quota expects the
// repository being read this second to get it.
for repo in stores.catalog().list(Some(&name))? {
if let Ok(dir) = stores.repo_dir(&repo.path()) {
stores.apply_explode_quota(&name, &dir);
}
}
match ns.explode_budget_bytes {
Some(0) => println!("{name}: explode budget unlimited"),
#[cfg(feature = "engine-znippy")]
Some(b) => println!(
"{name}: explode budget {}",
crate::storage::znippy_engine::human_bytes(b)
),
#[cfg(not(feature = "engine-znippy"))]
Some(b) => println!("{name}: explode budget {b} bytes"),
None => println!("{name}: explode budget back to the server default"),
}
}
}
Ok(())
}
// ── repositories ────────────────────────────────────────────────────────────
/// Run a `gunnar repo …` verb.
pub fn repo(stores: &ServerStores, command: RepoCommand) -> Result<()> {
match command {
RepoCommand::Create {
path,
hash,
default_branch,
public,
description,
adopt,
} => {
let path = repo_ref(&path)?;
let mut spec = NewRepo::new(path.clone())
.hash_kind(hash.into())
.visibility(if public {
Visibility::Public
} else {
Visibility::Private
});
spec.description = description;
if let Some(branch) = default_branch {
spec = spec.default_branch(full_branch(&branch));
}
// The catalog row and the on-disk repository, together and
// transactionally, refusing a store already at the name unless
// `--adopt`. A catalog entry with nothing behind it is listed by
// `repo list` and refused by `git clone`; a stranger's directory
// adopted by a create is a deleted repository resurrected.
let created = stores.create_repo(spec, adopt)?;
let store = stores.repo_store(&created)?;
println!(
"created {} ({}, default branch {}) at {}",
created.key(),
created.hash_kind,
created.default_branch,
store.git_dir().display()
);
}
RepoCommand::List { namespace } => {
let mut repos = stores.catalog().list(namespace.as_deref())?;
repos.sort_by_key(|repo| repo.key());
for repo in repos {
println!(
"{}\t{}\t{}\t{:?}",
repo.key(),
repo.hash_kind,
repo.default_branch,
repo.visibility
);
}
}
RepoCommand::Delete {
path,
purge,
retention_days,
} => {
let path = repo_ref(&path)?;
// **An ORPHAN is a delete to finish, not a not-found** (2026-08-30):
// a store on disk with no row and no tombstone is what an old
// catalog-only delete left behind. `--purge` finishes it; a soft
// delete of one is refused, because a tombstone that records no
// row promises an undelete the server cannot honour. Same rule as
// the control plane's `delete_repo`, from the same two helpers.
let orphan = stores.is_orphan(&path)?;
anyhow::ensure!(
purge || !orphan,
"{}",
crate::stores::ServerStores::orphan_soft_delete_refusal(&path)
);
// Read the row BEFORE removing it: the tombstone records what the
// repository was, and an undelete that guessed the hash kind would
// reopen the resurrection the reclaim rules closed from the other side.
let removed = match stores.catalog().delete(&path) {
Ok(repo) => Some(repo),
Err(gunnar_core::Error::NoSuchRepo(_)) if orphan => None,
Err(err) => return Err(err.into()),
};
// Release the handle before anything else, so the store lock is
// not left holding a file inside a directory being removed.
stores.close_repo(&path);
if purge {
let dir = stores.repo_dir(&path)?;
let (before, after) = stores.purge_repo(&path)?;
println!(
"deleted {}{} and purged {} ({} bytes reclaimed)",
path,
if removed.is_none() {
" (an ORPHAN: it had no catalog row)"
} else {
""
},
dir.display(),
before.saturating_sub(after)
);
} else {
let removed = removed.expect("a soft delete of an orphan is refused above");
let days = retention_days.unwrap_or(crate::stores::DEFAULT_DELETE_RETENTION_DAYS);
let record = stores.soft_delete(
&path,
Some(&removed),
"",
std::time::Duration::from_secs(u64::from(days) * 86_400),
)?;
let bytes = crate::stores::dir_bytes(&stores.repo_dir(&path)?);
println!(
"deleted {} — its {bytes} bytes are KEPT for {days} day(s)",
removed.key()
);
println!(
" undelete: gunnar repo undelete {} purge now: gunnar repo purge {}",
removed.key(),
removed.key()
);
if let Some(at) = record.purge_after_unix_ms {
println!(" the sweep may reclaim them after unix_ms {at}");
}
println!(" until then they still count against the account's storage.");
}
}
RepoCommand::Undelete { path } => {
let path = repo_ref(&path)?;
let spec = stores.undelete_spec(&path)?;
// `adopt`: the store on disk is the one this repository always
// had, and the tombstone carries the hash kind the adopt path
// checks the store's own marker against.
let repo = stores.create_repo(spec, true)?;
stores.clear_tombstone(&path)?;
println!(
"undeleted {} ({}, default branch {})",
repo.key(),
repo.hash_kind,
repo.default_branch
);
}
RepoCommand::Purge { path } => {
let path = repo_ref(&path)?;
anyhow::ensure!(
stores.catalog().get(&path)?.is_none(),
"{path} is a LIVE repository, not a deleted one. `repo delete {path} --purge` \
deletes and reclaims it in one step; purge alone only ends a retention window."
);
anyhow::ensure!(
stores.tombstone_of(&path)?.is_some(),
"{path} is neither catalogued nor tombstoned; there is nothing to purge"
);
let (before, after) = stores.purge_repo(&path)?;
println!(
"purged {path} ({} bytes reclaimed); it can no longer be undeleted",
before.saturating_sub(after)
);
}
RepoCommand::ListDeleted {} => {
let rows = stores.tombstones()?;
if rows.is_empty() {
println!("nothing is deleted");
}
let now = gunnar_core::now_ms();
let mut total = 0u64;
for row in &rows {
total += row.bytes;
let left = match row.tombstone.seconds_left(now) {
Some(secs) => format!("{} day(s) left", secs / 86_400),
None => "no expiry".to_owned(),
};
println!("{}\t{} bytes\t{left}", row.path, row.bytes);
}
if !rows.is_empty() {
println!(
"{} deleted repository(ies) holding {total} bytes — still on the account",
rows.len()
);
}
}
RepoCommand::ListOrphans { namespace } => {
let rows = stores.orphans()?;
let rows: Vec<_> = rows
.into_iter()
.filter(|row| {
namespace
.as_deref()
.is_none_or(|want| row.path.namespace() == want)
})
.collect();
if rows.is_empty() {
println!(
"no orphan stores: every directory under the repository root is either \
catalogued (`repo list`) or soft-deleted (`repo list-deleted`)"
);
}
let mut total = 0u64;
for row in &rows {
total += row.bytes;
println!(
"{}\t{} bytes\t{}\tmtime {}\t{}{}",
row.path,
row.bytes,
row.hash_marker.as_deref().unwrap_or("-"),
row.modified_unix_ms,
row.dir.display(),
match &row.failed_reclaim {
Some(note) => format!("\tRECLAIM FAILED: {note}"),
None => String::new(),
}
);
}
if !rows.is_empty() {
println!(
"{} orphan store(s) holding {total} bytes — no catalog row, no tombstone",
rows.len()
);
println!(
" reclaim the bytes: gunnar repo reclaim <path> keep them: gunnar repo \
create <path> --adopt --hash <the HASH column>"
);
}
}
RepoCommand::Reclaim { path } => {
let path = repo_ref(&path)?;
let bytes = stores.reclaim_orphan(&path)?;
println!("reclaimed {path} ({bytes} bytes); the name is usable again");
}
#[cfg(not(feature = "engine-znippy"))]
RepoCommand::ArchiveOut { .. } | RepoCommand::ArchiveIn { .. } => {
anyhow::bail!(
"repository archives are the znippy engine's; this server was built without it"
)
}
#[cfg(feature = "engine-znippy")]
RepoCommand::ArchiveOut { .. } => {
// Dispatched before the stores are opened (see `lib.rs`): an
// archive must be takeable from a box that is serving, and opening
// the control store would fight the running server for redb's lock.
unreachable!("archive-out is handled by repo_archive_out_without_stores")
}
#[cfg(feature = "engine-znippy")]
RepoCommand::ArchiveIn {
file,
as_path,
no_catalog,
} => {
let path = repo_ref(&as_path)?;
let dir = stores.repo_dir(&path)?;
let r = crate::storage::znippy_engine::archive_in(&file, &dir)?;
println!(
"restored {} bytes into {} (generation {})",
r.bytes_in,
r.repo_dir.display(),
r.to_generation
);
if !no_catalog {
// The hash kind comes from the restored marker, never from a
// flag: a repository restored as the wrong hash is a repository
// no client can push to, and the archive already carries the
// answer.
let marker =
std::fs::read_to_string(dir.join(crate::storage::znippy_engine::OBJECT_FORMAT))
.unwrap_or_default();
let hash = match marker.trim() {
"sha1" => gunnar_core::HashKind::Sha1,
"sha256" | "" => gunnar_core::HashKind::Sha256,
other => bail!("the archive names an unknown object format {other:?}"),
};
let spec = NewRepo::new(path.clone()).hash_kind(hash);
// `adopt`: the store on disk is the one this command just
// wrote, which is the single case where adopting is exactly
// right rather than a resurrection.
//
// **A row that is already there is a SUCCESS, not a clash.**
// The control document is restored separately (it carries the
// default branch, the visibility and the grants, none of which
// are in a repository archive), and when it has been the row
// for this repository is already correct. Failing here would
// make the two halves of a restore refuse to be done in the
// order that keeps the most metadata.
match stores.create_repo(spec, true) {
Ok(created) => {
println!(
"catalogued {} ({}, default branch {})",
created.key(),
created.hash_kind,
created.default_branch
);
println!("{}", encrypted_flag_note(&path));
}
Err(e) if format!("{e}").contains("already exists") => {
match stores.catalog().get(&path)? {
Some(have) => println!(
"the catalog already has {} ({}, default branch {}) — restored \
from the control document, and left alone",
have.key(),
have.hash_kind,
have.default_branch
),
None => println!("the catalog already has {path}"),
}
}
Err(e) => return Err(e),
}
}
}
}
Ok(())
}
/// The first eight bytes of a digest, for a line a human reads.
#[cfg(feature = "engine-znippy")]
fn hex_head(d: &[u8; 32]) -> String {
d[..8].iter().map(|b| format!("{b:02x}")).collect()
}
/// **`gunnar repo archive-out`, WITHOUT opening the control store.**
///
/// A backup that can only be taken from a stopped server is a backup nobody
/// takes, so this verb does not go through [`ServerStores`]: it resolves the
/// repository directory through the same `resolve_repo_path` sandbox the SSH
/// listener uses and reads three files out of it. Nothing it touches is
/// written by the running server except by *append*, which is exactly why no
/// quiesce is needed — see [`crate::storage::archive`].
#[cfg(feature = "engine-znippy")]
pub fn repo_archive_out_without_stores(
data_root: &std::path::Path,
command: RepoCommand,
) -> Result<()> {
let RepoCommand::ArchiveOut {
path,
to,
from_generation,
} = command
else {
unreachable!("called with something other than archive-out")
};
let path = repo_ref(&path)?;
let dir = path
.path_under(&data_root.join("repos"))
.with_context(|| format!("resolving the directory for {path}"))?;
if !dir.is_dir() {
bail!("{path} has nothing on disk at {}", dir.display());
}
let r = crate::storage::znippy_engine::archive_out(&dir, &to, from_generation)?;
for s in &r.segments {
println!(
" {:<44} {:>12} bytes from {}",
s.name, s.len, s.from_offset
);
}
println!(
"archived {path} -> {} {} bytes, generation {} -> {} sha256 {}",
to.display(),
r.bytes_out,
r.from_generation,
r.to_generation,
hex_head(&r.file_sha256)
);
println!("next incremental: --from-generation {}", r.to_generation);
Ok(())
}
/// `main` → `refs/heads/main`; a full ref name is left alone.
fn full_branch(name: &str) -> String {
if name.starts_with("refs/") {
name.to_owned()
} else {
format!("refs/heads/{name}")
}
}
// ── principals and keys ─────────────────────────────────────────────────────
/// Run a `gunnar principal …` verb.
pub fn principal(stores: &ServerStores, command: PrincipalCommand) -> Result<()> {
match command {
PrincipalCommand::Add {
id,
name,
kind,
csr,
server_generated_key,
out,
days,
} => {
let display = name.unwrap_or_else(|| id.clone());
let created = stores.auth().add_principal(gunnar_core::Principal::new(
&id,
display,
kind.into(),
)?)?;
println!("added principal {} ({:?})", created.id, created.kind);
// The certificate, if one was asked for. **After** the principal
// exists, because `register_certificate` refuses an unknown
// principal and the whole point of the certificate-identity plan is
// that the two are one act.
match (csr, server_generated_key) {
(Some(path), _) => {
let request = std::fs::read_to_string(&path).with_context(|| {
format!("reading the signing request at {}", path.display())
})?;
if let Some(claimed) = gunnar_core::issuer::csr_claimed_common_name(&request)? {
if claimed != created.id {
// Not an error: the request is being issued under
// the id that was asked for, which is the correct
// outcome. But an operator has to be able to see
// that the request asked for something else.
println!(
"note: the request claims CN={claimed}; it is being issued as \
{} — a request does not choose its own name",
created.id
);
}
}
let issued = stores.pki()?.issue_from_csr(&created.id, &request, days)?;
hand_over(stores, &out, &issued)?;
}
(None, true) => {
let issued = stores.pki()?.issue_with_server_key(&created.id, days)?;
hand_over(stores, &out, &issued)?;
}
(None, false) => {
if stores
.control_store()
.read_doc(gunnar_core::ISSUER_REF)?
.is_some()
{
println!(
"no certificate was issued. `--csr <file>` signs a request whose \
key never leaves {}'s machine; `--server-generated-key` mints one \
here instead",
created.id
);
}
}
}
}
PrincipalCommand::List => {
let mut principals = stores.auth().principals()?;
principals.sort_by(|a, b| a.id.cmp(&b.id));
for principal in principals {
println!("{}\t{}\t{:?}", principal.id, principal.name, principal.kind);
}
}
PrincipalCommand::Remove { id } => {
let removed = stores.auth().remove_principal(&id)?;
println!("removed principal {}", removed.id);
}
}
Ok(())
}
/// Run a `gunnar key …` verb.
pub fn key(stores: &ServerStores, command: KeyCommand) -> Result<()> {
match command {
KeyCommand::Add {
principal,
key,
cert,
} => {
let registered = match (key, cert) {
(_, Some(path)) => {
let der = read_certificate(&path)?;
stores.auth().register_certificate(&principal, &der, None)?
}
(Some(key), None) => {
let line = key_argument(&key)?;
stores.auth().register_key(&principal, &line, None)?
}
(None, None) => bail!("give a public key or --cert"),
};
println!(
"registered {} ({}) to {}",
registered.fingerprint_sha256, registered.algo, registered.principal_id
);
}
KeyCommand::List { principal } => {
let mut keys = match principal {
Some(id) => stores.auth().keys_of(&id)?,
None => stores.auth().keys()?,
};
keys.sort_by(|a, b| a.fingerprint_sha256.cmp(&b.fingerprint_sha256));
for key in keys {
println!(
"{}\t{}\t{}",
key.fingerprint_sha256, key.algo, key.principal_id
);
}
}
KeyCommand::Revoke { fingerprint } => {
let parsed = Fingerprint::parse(&fingerprint)?;
match stores.auth().revoke_key(&parsed)? {
Some(key) => println!("revoked {} from {}", parsed, key.principal_id),
None => bail!("no key with fingerprint {parsed} is registered"),
}
}
}
Ok(())
}
/// `gunnar token`.
///
/// # The secret is printed once and then does not exist
///
/// `gunnar_core::mint_token_secret` produces it, `register_token` stores only
/// its sha-256, and this is the only place it is ever written out. It goes to
/// **stdout alone**, with the explanation on stderr, so `TOKEN=$(gunnar token
/// add ci)` captures the credential and nothing else.
pub fn token(stores: &ServerStores, command: gunnar_common::cli::TokenCommand) -> Result<()> {
use gunnar_common::cli::TokenCommand;
match command {
TokenCommand::Add {
principal,
label,
expires_in,
} => {
let secret = gunnar_core::mint_token_secret();
let expires =
expires_in.map(|days| gunnar_core::now_ms() + (days as i64) * 24 * 60 * 60 * 1000);
let registered = stores
.auth()
.register_token(&principal, &secret, &label, expires)?;
// stdout, once. The hash goes to the operator too, because it is
// what `revoke` takes and it will never be derivable from anything
// they still have.
println!("{secret}");
eprintln!(
"minted for {} (hash {}); this is the only time the token is printed",
registered.principal_id, registered.hash
);
}
TokenCommand::List { principal } => {
let mut tokens = stores.auth().tokens()?;
if let Some(id) = &principal {
tokens.retain(|token| &token.principal_id == id);
}
tokens.sort_by(|a, b| a.hash.cmp(&b.hash));
for token in tokens {
println!("{}\t{}\t{}", token.hash, token.principal_id, token.label);
}
}
TokenCommand::Revoke { hash } => match stores.auth().revoke_token(&hash)? {
Some(token) => println!("revoked {} from {}", token.hash, token.principal_id),
None => bail!("no token with hash {hash} is registered"),
},
}
Ok(())
}
// ── grants ──────────────────────────────────────────────────────────────────
fn scope_of(server: bool, namespace: Option<&str>, repo: Option<&str>) -> Result<Scope> {
match (server, namespace, repo) {
(true, _, _) => Ok(Scope::Server),
(_, Some(name), _) => Ok(Scope::Namespace(name.to_owned())),
(_, _, Some(path)) => Ok(Scope::Repo(repo_ref(path)?)),
_ => bail!("a grant needs a scope: --server, --namespace <ns>, or --repo <ns>/<name>"),
}
}
/// `gunnar grant`.
pub fn grant(stores: &ServerStores, args: GrantArgs) -> Result<()> {
let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
let granted = stores.auth().grant(Grant {
principal: args.principal.clone(),
scope,
level: args.level.into(),
})?;
println!(
"{} now holds {} on {}",
granted.principal, granted.level, granted.scope
);
// Nested namespaces do NOT inherit: `team` does not reach
// `team/sub`. Said out loud at the moment the grant is made, because the
// alternative is an operator discovering it as a permission denial.
if let Scope::Namespace(name) = &granted.scope {
if name.contains('/')
|| stores
.catalog()
.namespaces()?
.iter()
.any(|ns| ns.name.starts_with(&format!("{name}/")))
{
println!(
"note: namespace grants match EXACTLY; this does not reach nested namespaces \
under {name}/"
);
}
}
Ok(())
}
/// `gunnar revoke`.
pub fn revoke(stores: &ServerStores, args: RevokeArgs) -> Result<()> {
let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
if stores.auth().revoke_grant(&args.principal, &scope)? {
println!("revoked {}'s access to {scope}", args.principal);
Ok(())
} else {
bail!("{} holds no grant on {scope}", args.principal)
}
}
// ── gc ──────────────────────────────────────────────────────────────────────
// `percent(Option<f32>) -> String` stood here, rendering a measured fraction or
// `?` for one that could not be read. Its whole reason to exist was that `?` and
// `0%` are different facts — and both facts were `RepackOptions`' delta figures,
// which the storage restart amputated with the packfile engine. `MaintenanceReport` carries
// no fraction: it reports `bytes_before`/`bytes_after`, `rows`, `retired_packs`,
// `verified` and `sections_sealed`, every one of them a count or a pair, which
// is the standing preference for a count over a ratio arrived at from the other
// direction. Nothing left in this crate has an `Option<f32>` to render.
/// `gunnar gc`, the repack seam, now driving `gunnar-store`'s `repack()`.
///
/// Each repository's pushed packs and loose objects are folded into **one new
/// pack**, which is put in place and verified before anything is removed
/// (never in place). A repository that is already consolidated is
/// reported as such and left alone: rewriting it would churn the pack id and
/// reclaim nothing.
///
/// **This is not the znippy cold tier `repack()` describes**, and the reason is
/// That tier as specified stores *inflated* object content, which
/// forecloses pack-copy permanently and makes every cold-tier fetch pay a full
/// recompression. `gunnar-store::repack`'s module documentation carries the
/// argument. What this verb does is git's own answer (many immutable
/// containers plus a periodic merge) with the client's deltas and deflate
/// preserved, which is what verbatim pack copy needs to exist at all.
///
/// The per-repository line reports **applied outputs**: objects, what was
/// folded, what was copied rather than recompressed, and the bytes each way. A
/// `gc` that printed only "done" would be indistinguishable from one that did
/// nothing (measured, not asserted).
///
/// Unreachable objects are **kept unless `--prune` says otherwise**.
/// `RepackOptions::prune_unreachable` had been implemented, tested
/// (`gunnar-store/tests/repack.rs`) and reachable from nothing: no flag, no
/// caller, so nothing gunnar ever reclaimed the objects of a rejected push, a
/// deleted branch or a force-pushed-away history. It is a separate and
/// destructive decision, which is why it is a flag and not a default, and the
/// number of objects it dropped is reported rather than assumed.
pub fn gc(stores: &ServerStores, args: GcArgs) -> Result<()> {
let repos = match &args.path {
Some(path) => {
let path = repo_ref(path)?;
vec![stores.catalog().open(&path)?]
}
None => stores.catalog().list(None)?,
};
// ── the storage restart: the tier maintains itself; there is no repack ──
//
// What stood here drove `gunnar_store::repack()` — consolidate the
// packfiles in `objects/pack`, optionally re-encoding deltas, with a
// `RepackOptions` carrying window, depth, delta order and policy. Every one
// of those is a packfile concept and the packfile engine is amputated.
//
// The engine's own two verbs replace all of it: `seal` folds the live logs
// into the reserved Arrow sections, `gc` computes the live set, tombstones
// the packs it found wholly dead, drops the dead index rows and compacts.
// Neither takes a window or a delta policy, which is why `GcArgs` no longer
// offers them — a flag that parses and is ignored is worse than one that is
// not there.
let _ = &args.prune;
let _ = &args.prune_grace;
let mut done = 0usize;
let mut failed = 0usize;
let mut reclaimed: i64 = 0;
for repo in &repos {
match gc_one(stores, repo) {
Ok(report) => {
done += 1;
reclaimed += report.bytes_before as i64 - report.bytes_after as i64;
println!(
"{repo}\t{} -> {} bytes, {} live rows, {} pack(s) retired, {} section(s) \
sealed [{}]",
report.bytes_before,
report.bytes_after,
report.rows,
report.retired_packs,
report.sections_sealed,
report.strategy,
);
}
Err(err) => {
failed += 1;
eprintln!("{repo}\tgc failed: {err:#}");
}
}
}
println!(
"{done} of {} repository(ies) maintained, {reclaimed} bytes reclaimed",
repos.len()
);
if failed > 0 {
bail!("{failed} repository(ies) could not be maintained; see above");
}
Ok(())
}
/// **Maintain ONE repository. The only writer, and both doors call it.**
///
/// [`gc`] above is the CLI verb: it resolves what to maintain, loops, and
/// prints. `Repos.Gc` on the gRPC control plane resolves one repository and
/// reports bytes over the wire. Neither of those is maintenance — the
/// maintenance is here, once, so there is no second answer to "what does gc
/// do" to drift out of step with the first (one implementation). The split is deliberate:
/// **everything that is common is in this function and everything that
/// differs — argument parsing, the loop, the printed lines, the exit code — is
/// in the caller**, which is why extracting it changed no CLI output.
///
/// The span, the timing and the two log records live here rather than in the
/// callers, because the operator asking "what did the box just spend that I/O
/// on" gets the same record whether the run came from a shell or from a console
/// button. A failure logs `store.maintain_failed` here and is returned, so the
/// CLI can count it toward its exit code and the RPC can turn it into a
/// `Status` — one record, two policies.
pub fn gc_one(stores: &ServerStores, repo: &Repo) -> Result<MaintenanceReport> {
// A snapshot streaming from its cut reads the objects reachable from the cut's tips, which
// a gc of a moved ref could drop (R5.1-A): it waits for the stream.
stores.wait_unpinned("a gc");
let span = tracing::info_span!(target: TARGET_STORE, "store.maintain", repo = %repo);
let _entered = span.enter();
let started = std::time::Instant::now();
let store = stores.repo_store(repo)?;
match stores.engine().maintain(store.git_dir(), store.hash_kind()) {
Ok(report) => {
tracing::info!(
target: TARGET_STORE,
event = "store.maintained",
strategy = %report.strategy,
bytes_before = report.bytes_before,
bytes_after = report.bytes_after,
rows = report.rows,
retired_packs = report.retired_packs,
verified = report.verified,
sections_sealed = report.sections_sealed,
elapsed_ms = started.elapsed().as_millis() as u64,
"the store was sealed and compacted"
);
Ok(report)
}
Err(err) => {
tracing::error!(
target: TARGET_STORE,
event = "store.maintain_failed",
%err,
"maintaining the store failed"
);
Err(err)
}
}
}
/// The bytes [`gc_one`] took off the filesystem, as one number.
///
/// `MaintenanceReport` carries the before/after PAIR on purpose — a lone
/// "reclaimed" figure cannot tell a run that compacted from one that did
/// nothing — so this is the lossy projection the wire asks for and not the
/// shape the report should have had. A run that grew the store (an in-place
/// compaction that had to write before it could retire) reports `0` rather
/// than an underflowed `u64`.
pub fn reclaimed_bytes(report: &MaintenanceReport) -> u64 {
report.bytes_before.saturating_sub(report.bytes_after)
}
/// `gunnar reflog` — read the reference history, and optionally seal it.
///
/// This verb exists because the history it reads was **write-only**. Every
/// compaction wrote the reflog it took out of the live log to `refs-archive/`
/// as one complete Arrow IPC stream, and no production code in the workspace
/// ever opened one; a library reader with no caller would have been the same
/// bug one layer up.
///
/// It opens **no store, and no catalog**. `History::read` globs the archive and
/// reads the live log through the same torn-tail scan an open uses, taking no
/// lock, so this works while the server is serving — which is the only state a
/// repository worth asking about is ever in.
///
/// The catalog exclusion was found by running this verb against a live server
/// and being refused, *after* the library underneath it had been proven
/// lock-free: `ServerStores::open` opens the control plane's own store, so
/// resolving `<ns>/<name>` through the catalog took the very lock this verb
/// exists to avoid. That was the control *repository*'s lock when it was found;
/// since 2026-08-10 it is redb's exclusive lock on `control.redb`, which the
/// catalog, the identity table and now the secrets all sit behind. Same
/// exclusion, one file instead of two. The repository directory is resolved
/// through the same
/// `RepoRef::path_under` sandbox the SSH listener uses, and the hash kind comes
/// from the repository's own `config` (`gunnar_store::fs::stored_hash_kind`),
/// which is where `FsStore` already reads it.
///
/// The listing reports **applied output**: real oids either side of each move,
/// the actor, and whether the row is a compaction's snapshot rather than a
/// reference actually moving. A verb that printed only a count would be
/// indistinguishable from one that read nothing (measured, not asserted).
///
/// # 🔴 It reads a **retired** format, and it now says so instead of guessing
///
/// **2026-08-10.** What stood here was
/// `let store_dir = todo!("reflog: ask the engine for its ref journal")` — a
/// live `todo!()` in a shipped admin verb, which panicked the moment anybody
/// ran it. It was invisible until today because a crate that fails at name
/// resolution never reaches the lint passes.
///
/// The obvious repair — hand [`gunnar_core::History::read`] a plausible path —
/// is the **worst** available answer, and measurably so:
/// [`History::read`](gunnar_core::History::read) globs an archive directory and
/// tests the live log with `exists()`, so a wrong path is not an error. It is
/// **`0 pushes`**, printed with a straight face, and `--seal` would write a
/// valid, empty Arrow artifact over it.
///
/// So the path is **probed rather than assumed**, and the probe has exactly two
/// outcomes:
///
/// * `<git-dir>/gunnar-store/` holds [`REFS_LOG`] or [`REFS_ARCHIVE_DIR`] — the
/// retired filesystem backend's Arrow reflog is really there, and this verb
/// reads it, which is what it has always done and the only reader those bytes
/// have.
/// * it does not — the verb **refuses for that repository**, names the engine
/// this process runs and says where that engine's ref history actually is.
/// `read == 0` across every repository is a non-zero exit, because a script
/// must not read "nothing to say" as success.
///
/// # Why this is not re-pointed at the engine's journal
///
/// It is not a missing path; it is a missing capability, and the gap is three
/// deep:
///
/// 1. **Neither `GitOps` nor `GitServe` carries a ref-history accessor**, and
/// one does not belong on the contract: the `GitServe` reading contract fixes the
/// ordering rule that `GitServe` is *"defined by what the Arrow arm needs to
/// serve a clone well"*, and ref history serves no clone. Worse, the gix arm
/// could never conform — `storage-git-gix` opens its ref store with
/// `WriteReflog::Disable` and says so deliberately (*"a git server has no use
/// for a per-ref reflog file tree, and `GitOps` does not expose one"*). A
/// contract method one arm is structurally unable to answer is the
/// two-arms-disagree defect the `Harness` exists to catch, written into the
/// contract on purpose.
/// 2. **The znippy arm's journal is `pub(crate)`.** `refs.log` is one Arrow IPC
/// frame per push under the store root, complete (a seal *copies* it into the
/// archive and a compaction preserves row order), but `GitStore::ref_log()`
/// is not public and the file name is `GitStore`'s layout, not this crate's.
/// 3. **Its rows do not carry what this verb prints.** The columns are `name`,
/// `target`, `peeled`, `symref_target`, `push_seq`, `updated_ms`. The
/// previous value is derivable by replay; **`actor` does not exist at all**,
/// and `--seal`'s artifact is a `gunnar-core` format the engine has its own
/// answer to. Re-pointing this verb is therefore a redesign of what
/// `gunnar reflog` reports, which is a design decision and not a repair.
///
/// [`REFS_LOG`]: gunnar_core::arrow_log::REFS_LOG
/// [`REFS_ARCHIVE_DIR`]: gunnar_core::arrow_log::REFS_ARCHIVE_DIR
pub fn reflog(data_root: &std::path::Path, args: ReflogArgs) -> Result<()> {
let repos_root = data_root.join(crate::stores::REPOS_DIR);
let repos: Vec<(String, std::path::PathBuf)> = match &args.path {
Some(path) => {
let path = repo_ref(path)?;
let dir = path
.path_under(&repos_root)
.with_context(|| format!("resolving the directory for {path}"))?;
if !dir.join("HEAD").exists() {
bail!("there is no repository at {}", dir.display());
}
vec![(path.key(), dir)]
}
None => bare_repositories(&repos_root)?,
};
if repos.is_empty() {
println!("no repositories under {}", repos_root.display());
return Ok(());
}
// One repository gets the path it was given; many get one artifact each
// under it, because a single file would silently keep only the last.
let seal_is_a_directory = args.seal.is_some() && repos.len() > 1;
let mut read = 0usize;
let mut failed = 0usize;
let mut refused = 0usize;
for (key, git_dir) in &repos {
let store_dir = git_dir.join(LEGACY_STORE_DIR);
// **Probed, never assumed.** `History::read` answers `0 pushes` for a
// directory that is not there, so pointing it somewhere plausible would
// print a number that means nothing. See this function's docs.
if !legacy_reflog_present(&store_dir) {
refused += 1;
eprintln!(
"{key}\tREFUSED: no reference history this verb can read — nothing at {}",
store_dir.display()
);
continue;
}
let hash_kind = match stored_hash_kind(git_dir) {
Ok(kind) => kind,
Err(err) => {
failed += 1;
eprintln!("{key}\tFAILED: {err:#}");
continue;
}
};
let history = match gunnar_core::History::read(&store_dir, hash_kind) {
Ok(history) => history,
Err(err) => {
failed += 1;
eprintln!("{key}\tFAILED: {err}");
continue;
}
};
read += 1;
match &args.seal {
Some(target) => {
let path = if seal_is_a_directory {
target.join(format!("{}.arrow", key.replace('/', "_")))
} else {
target.clone()
};
let report = history.seal(&path)?;
println!(
"{key}\t{} pushes ({} archived, {} live)\t{} rows\t{} B\t{:.1} B/push\t{:.2}x smaller than the {} B it read\t{}",
report.pushes,
history.archived_pushes(),
history.live_pushes(),
report.rows,
report.bytes,
report.bytes_per_push(),
report.shrink_x(),
report.bytes_in,
path.display(),
);
}
None => {
let transactions = history.transactions()?;
// **`bytes_in` is on the line for a reason.** `History` reads a
// live log through the same torn-tail scan an open uses, so a
// log whose very first frame never landed has a valid prefix of
// zero and is *correctly* replayed as no pushes at all. Without
// the byte count, "0 pushes" out of a 40 KiB file and "0 pushes"
// out of an empty directory print identically — and one of those
// is a history an operator has lost (measured, not asserted).
println!(
"{key}\t{} pushes ({} from the archive, {} live) in {} segment(s)\t{} rows\t{} B read",
history.pushes(),
history.archived_pushes(),
history.live_pushes(),
history.segments_read(),
transactions.len(),
history.bytes_in(),
);
let show = if args.all {
transactions.as_slice()
} else {
let from = transactions.len().saturating_sub(args.limit);
&transactions[from..]
};
if show.len() < transactions.len() {
println!(
" … {} older transaction(s) not shown; --all for every one",
transactions.len() - show.len()
);
}
for entry in show {
println!(
" {:>6} {} {} -> {}{}",
entry.push_seq,
entry.refname,
value(entry.old_oid.as_ref(), entry.old_symref.as_deref()),
value(entry.new_oid.as_ref(), entry.new_symref.as_deref()),
if entry.is_snapshot() {
" (compaction snapshot, not a move)"
} else {
""
},
);
}
}
}
}
// Printed once rather than per repository: on a server with two hundred
// repositories the explanation is the same explanation two hundred times,
// and the operator's next action is one action.
if refused > 0 {
eprintln!("\n{}", refusal_explanation());
}
if failed > 0 {
bail!(
"{failed} of {} repositor{} could not be read; {read} were",
repos.len(),
if repos.len() == 1 { "y" } else { "ies" }
);
}
// **A refusal is an exit code, not a remark.** Every repository refused and
// exit 0 is indistinguishable from a history that is genuinely empty, which
// is the failure this whole verb was rewritten to stop making.
if read == 0 {
bail!(
"no reference history was read from any of the {} repositor{} looked at",
repos.len(),
if repos.len() == 1 { "y" } else { "ies" }
);
}
Ok(())
}
/// The store directory of the **retired** filesystem backend, verbatim.
///
/// It was `gunnar_store::fs::STORE_DIR` until the storage restart amputated the packfile
/// engine that owned it. The constant is repeated here rather than revived
/// there because nothing *writes* this layout any longer: it is the name of a
/// format on old disks, and the only code left that has any business knowing it
/// is the one reader of those bytes.
const LEGACY_STORE_DIR: &str = "gunnar-store";
/// Whether `store_dir` really holds a legacy Arrow reflog.
///
/// Both halves are checked because either one alone is a complete history:
/// a repository compacted since its last push has everything in
/// `refs-archive/` and a live log holding only its schema, and one never
/// compacted has the lot in the live log and no archive at all.
///
/// The two names come from [`gunnar_core::arrow_log`], which is the module that
/// wrote them, rather than being spelled again here (one implementation).
fn legacy_reflog_present(store_dir: &std::path::Path) -> bool {
store_dir.join(gunnar_core::arrow_log::REFS_LOG).is_file()
|| store_dir
.join(gunnar_core::arrow_log::REFS_ARCHIVE_DIR)
.is_dir()
}
/// What an operator is told when there is no history for this verb to read.
///
/// It names the engine **this process** runs, because "where is my ref history"
/// has a different answer per arm and a generic refusal would send half of the
/// readers to the wrong place.
fn refusal_explanation() -> String {
let engine = match crate::storage::Engine::selected() {
Ok(engine) => engine.name(),
// `selected()` refuses an engine name this binary does not carry. That
// is worth reporting as itself rather than swallowing into "unknown",
// but it must not turn an explanation into a second failure.
Err(_) => "<unselectable>",
};
format!(
"`reflog` reads the retired filesystem backend's Arrow reflog —\n\
`<git-dir>/{LEGACY_STORE_DIR}/{log}` and `{archive}/` beside it. That backend went with\n\
the packfile engine (the storage restart) and nothing writes those bytes any more, so\n\
a repository created since has none and this verb has nothing to read.\n\
\n\
This process runs the `{engine}` engine. Where reference history is now:\n\
\n\
* znippy — one Arrow IPC frame per push in the store's own `refs.log`. It is real and it\n\
\x20 is complete, but `GitStore::ref_log()` is `pub(crate)`, and its rows carry no actor\n\
\x20 and no previous value, so this verb's output is not merely re-pointable at it.\n\
* gix — none at all. `storage-git-gix` opens its ref store with `WriteReflog::Disable`,\n\
\x20 deliberately: a git server has no use for a per-ref reflog file tree.\n\
\n\
Neither `GitOps` nor `GitServe` carries a ref-history accessor, and the `GitServe`\n\
reading contract's ordering rule says one does not belong on the contract. Making this\n\
verb answer again is an engine-shaped seam plus a decision about what it should\n\
report — see `agentO-report.md`.",
log = gunnar_core::arrow_log::REFS_LOG,
archive = gunnar_core::arrow_log::REFS_ARCHIVE_DIR,
)
}
/// The hash kind an existing repository was created with.
///
/// One key decides it, `extensions.objectformat`, and its absence means sha1,
/// which is the reading git gives `repositoryformatversion = 0`. Reading that
/// one key by hand is deliberate: linking `gix-config` to parse a file whose
/// every other key describes a worktree this server does not have is what put
/// `gix-config`, and through it the rest of the porcelain, into the appliance
/// build.
///
/// It is here because reading a repository's **history** must not open its
/// store or its catalog — both are locked by whatever server is serving them,
/// and dodging that lock is this verb's entire reason to exist. The hash kind
/// is the one thing a history reader needs that the log itself does not carry.
///
/// Restored verbatim from `gunnar_store::fs::stored_hash_kind`, which the storage restart
/// deleted with the backend around it. It is the only reader of this key left
/// in the workspace, so there is nothing for it to drift from.
fn stored_hash_kind(git_dir: &std::path::Path) -> Result<gix_hash::Kind> {
const OBJECT_FORMAT_KEY: &str = "objectformat";
let config_path = git_dir.join("config");
let text = match std::fs::read_to_string(&config_path) {
Ok(text) => text,
// A repository with no config at all is a sha1 repository, which is
// also how git reads it.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(gix_hash::Kind::Sha1),
Err(err) => {
return Err(anyhow::Error::from(err))
.with_context(|| format!("reading {}", config_path.display()))
}
};
let mut in_extensions = false;
for line in text.lines() {
let line = line.trim();
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
in_extensions = header.trim().eq_ignore_ascii_case("extensions");
continue;
}
if !in_extensions {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
// Git's config keys are case-insensitive; the value here is not.
if !key.trim().eq_ignore_ascii_case(OBJECT_FORMAT_KEY) {
continue;
}
return match value.trim() {
"sha256" => Ok(gix_hash::Kind::Sha256),
"sha1" => Ok(gix_hash::Kind::Sha1),
other => bail!(
"{} declares an object format gunnar does not know: {other:?}",
config_path.display()
),
};
}
Ok(gix_hash::Kind::Sha1)
}
/// Every bare repository under `repos_root`, as `(<ns>/<name>, git dir)`.
///
/// Walked off the filesystem rather than listed from the catalog, for the same
/// reason the rest of this verb avoids it: the catalog is a store and a running
/// server holds its lock. A directory holding a `HEAD` is the same test
/// `FsStore::open_or_init` uses to decide a repository is already there.
fn bare_repositories(repos_root: &std::path::Path) -> Result<Vec<(String, std::path::PathBuf)>> {
fn walk(
dir: &std::path::Path,
prefix: &str,
out: &mut Vec<(String, std::path::PathBuf)>,
) -> Result<()> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
if let Some(stem) = name.strip_suffix(".git") {
if path.join("HEAD").exists() {
out.push((format!("{prefix}{stem}"), path));
continue;
}
}
// Namespaces nest (`team/sub`), so this recurses rather than
// scanning one level.
walk(&path, &format!("{prefix}{name}/"), out)?;
}
Ok(())
}
let mut out = Vec::new();
walk(repos_root, "", &mut out)?;
out.sort();
Ok(out)
}
/// One side of a reference move, as an operator reads it.
///
/// `-` rather than an empty column: a creation and a deletion must not look
/// like a truncated line.
fn value(oid: Option<&gunnar_store::gix_hash::ObjectId>, symref: Option<&str>) -> String {
match (oid, symref) {
(Some(id), _) => id.to_string(),
(None, Some(name)) => format!("ref: {name}"),
(None, None) => "-".to_owned(),
}
}
/// `gunnar attest …` — the transparency log over push attestations.
///
/// None of these verbs writes a record. They sign, check and prove over the
/// lines every push already appended, which is what makes them safe to run at
/// any time and impossible to use to change what the log says.
pub fn attest(stores: &ServerStores, command: gunnar_common::cli::AttestCommand) -> Result<()> {
use gunnar_common::cli::AttestCommand;
let log = stores.transparency();
match command {
AttestCommand::Head => {
let key = stores.transparency_key()?;
let signed = log.issue_head(&key, gunnar_grpc::bus::now_unix_ms())?;
println!("size {}", signed.head.size);
println!("root {}", signed.head.root);
println!("signed by {}", signed.key_fingerprint);
println!("appended {}", log.heads_path().display());
if signed.head.size == 0 {
// Said out loud rather than left to be inferred from `size 0`.
// A signed empty head is a real claim — "this server had
// accepted no push at this moment" — and an operator who issued
// one by accident should notice.
println!();
println!(
"This head covers an EMPTY log: it says this server had accepted no push \
when it was signed."
);
}
Ok(())
}
AttestCommand::Audit => {
let key = stores.transparency_key()?;
let verdict = log.audit(key.public_key())?;
println!("{verdict}");
if verdict.is_consistent() {
return Ok(());
}
// A non-zero exit, because this is the verb an operator wires into
// a check. Printing the verdict and exiting 0 would make a
// tampered log look like a passing job.
anyhow::bail!("the transparency log did not audit clean")
}
AttestCommand::Key => {
let key = stores.transparency_key()?;
println!(
"{}",
key.public_key()
.to_openssh()
.context("rendering the transparency log's public key")?
);
Ok(())
}
AttestCommand::Prove { pack_hash } => {
let Some(inclusion) = log.prove_pack(&pack_hash)? else {
anyhow::bail!(
"no attestation in this log names pack {pack_hash}. A proof for a record \
the log does not hold is not an empty proof."
)
};
println!("index {}", inclusion.index);
println!("size {}", inclusion.size);
println!("leaf {}", inclusion.leaf);
println!("root {}", inclusion.root);
for (step, hash) in inclusion.proof.iter().enumerate() {
println!("proof[{step}] {hash}");
}
Ok(())
}
}
}
/// `gunnar verify` — walk the chain backwards from a line of source.
///
/// **The exit code is the verdict.** Zero only when every link is proven; a
/// broken link and an unproven one both exit non-zero, and they print
/// differently. A command whose output an operator has to read to learn whether
/// it passed is a command nobody wires into anything.
pub fn verify(data_root: &std::path::Path, args: gunnar_common::cli::VerifyArgs) -> Result<()> {
let stores = std::sync::Arc::new(crate::stores::ServerStores::open(data_root)?);
let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
let repo = &args.repo;
let (namespace, name) = repo.split_once('/').with_context(|| {
format!(
"{repo:?} is not `<namespace>/<repo>`; every repository lives in a \
namespace (multi-tenancy)"
)
})?;
let chain = crate::verify::verify(
&stores,
&plane,
&gunnar_proto::RepoPath::new(namespace, name),
&args.at,
args.line.as_deref(),
)?;
println!("{chain}");
if chain.is_unbroken() {
return Ok(());
}
if chain.is_broken() {
anyhow::bail!("the chain is broken");
}
anyhow::bail!("the chain is incomplete")
}
/// `gunnar blame <ns>/<repo> <path> [--rev]` — who wrote each line.
///
/// A separate process from a running server for the reason `verify` is: opening
/// `ServerStores` takes redb's exclusive lock, which a live server holds.
pub fn blame(data_root: &std::path::Path, args: gunnar_common::cli::BlameArgs) -> Result<()> {
let (namespace, name) = args.repo.split_once('/').with_context(|| {
format!(
"{:?} is not `<namespace>/<repo>`; every repository lives in a \
namespace (multi-tenancy)",
args.repo
)
})?;
let rows = crate::blame::blame_at(
data_root,
&gunnar_proto::RepoPath::new(namespace, name),
&args.rev,
&args.path,
)?;
// `<short-oid> <author> <line>`, one line each — the shape `git blame`
// readers expect, trimmed to what an auditor asked: who, and what.
for row in &rows {
let hex = row.commit.to_hex().to_string();
let short = &hex[..hex.len().min(12)];
println!(
"{short} {}\t{}",
row.author,
String::from_utf8_lossy(&row.line)
);
}
Ok(())
}
/// `gunnar search <ns>/<repo> <pattern> [--rev] [--max-*]` — a literal string
/// across the latest revision's files.
pub fn search(data_root: &std::path::Path, args: gunnar_common::cli::SearchArgs) -> Result<()> {
let (namespace, name) = args.repo.split_once('/').with_context(|| {
format!(
"{:?} is not `<namespace>/<repo>`; every repository lives in a \
namespace (multi-tenancy)",
args.repo
)
})?;
let limits = crate::search::Limits {
max_file_bytes: args.max_file_bytes,
max_matches: args.max_matches,
};
let hits = crate::search::search_at(
data_root,
&gunnar_proto::RepoPath::new(namespace, name),
&args.rev,
&args.pattern,
limits,
)?;
for hit in &hits {
println!(
"{}:{}:{}",
hit.path,
hit.line_number,
String::from_utf8_lossy(&hit.line)
);
}
Ok(())
}
/// `gunnar forge …` — issues, reviews and merges as signed events.
pub fn forge(stores: &ServerStores, command: gunnar_common::cli::ForgeCommand) -> Result<()> {
use gunnar_common::cli::ForgeCommand;
let log = stores.forge_log();
/// Read every event, verifying nothing: the fold is over what was written,
/// and verification is a separate question with its own answer.
fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
log.lines_of()?
.iter()
.map(|line| serde_json::from_str(line).context("decoding a forge event"))
.collect()
}
fn append(
stores: &ServerStores,
identity: &std::path::Path,
event: gunnar_core::ForgeEvent,
) -> Result<gunnar_core::SignedEvent> {
let pem = std::fs::read_to_string(identity)
.with_context(|| format!("reading {}", identity.display()))?;
let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
.with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
// **The key must already be in the ACL table.** A signature by a key
// gunnar does not know is not evidence to anybody reading this log
// later — they would have nothing to check it against — so it is
// refused here rather than stored and found unverifiable.
let fingerprint = key
.public_key()
.fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
.to_string();
let known = stores
.auth()
.signing_keys()?
.into_iter()
.any(|row| row.fingerprint_sha256 == fingerprint);
anyhow::ensure!(
known,
"{fingerprint} is not registered on this server, so nobody reading this event later \
could check its signature. Register it with `gunnar key add` first."
);
let signed =
gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
let mut line = serde_json::to_string(&signed)?;
line.push('\n');
let _framed = gunnar_core::txlog::sink::append_line(
stores.forge_log().records_path(),
line.trim_end_matches('\n'),
)?;
use std::io::Write as _;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(stores.forge_log().records_path())?;
file.write_all(line.as_bytes())?;
Ok(signed)
}
match command {
ForgeCommand::Comment {
repo,
pull,
body,
actor,
identity,
} => {
append(
stores,
&identity,
gunnar_core::ForgeEvent {
repo: repo.clone(),
pull,
actor,
at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
event: gunnar_core::Event::Commented {
body,
path: String::new(),
line: 0,
blob_oid: String::new(),
},
},
)?;
println!("commented on {repo}#{pull}");
Ok(())
}
ForgeCommand::Approve {
repo,
pull,
commit,
actor,
identity,
} => {
let signed = append(
stores,
&identity,
gunnar_core::ForgeEvent {
repo: repo.clone(),
pull,
actor: actor.clone(),
at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
event: gunnar_core::Event::Approved {
commit: commit.clone(),
},
},
)?;
println!("{actor} approved {commit} on {repo}#{pull}");
println!("signed by {}", signed.key_fingerprint);
Ok(())
}
ForgeCommand::Reject {
repo,
pull,
commit,
reason,
actor,
identity,
} => {
let signed = append(
stores,
&identity,
gunnar_core::ForgeEvent {
repo: repo.clone(),
pull,
actor: actor.clone(),
at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
event: gunnar_core::Event::Rejected {
commit: commit.clone(),
reason,
},
},
)?;
println!("{actor} withdrew their approval of {commit} on {repo}#{pull}");
println!("signed by {}", signed.key_fingerprint);
Ok(())
}
ForgeCommand::Show { repo, pull } => {
let all = events(&log)?;
let mine: Vec<gunnar_core::ForgeEvent> = all
.iter()
.filter(|e| e.event.repo == repo)
.map(|e| e.event.clone())
.collect();
let state = gunnar_core::forge::fold(pull, &mine);
// The REVIEW only. Whether `{repo}#{pull}` exists, what it is
// called and whether it is open are `PullRequests`' answers, and
// printing a second copy of them here would be a twin.
println!("{repo}#{pull} review");
println!("comments {}", state.comments);
if state.approvals.is_empty() {
println!("approvals none");
}
// **Each approval is re-verified against the ACL table**, here, when
// it is read — not trusted because it is in the file. That is the
// difference the whole design is about, and `PullRequests.Merge`
// asks the same question before it moves a ref.
let keys: Vec<(String, gunnar_core::ssh_key::PublicKey)> = stores
.auth()
.signing_keys()?
.iter()
.filter_map(|row| {
gunnar_core::ssh_key::PublicKey::from_openssh(&row.openssh)
.ok()
.map(|key| (row.fingerprint_sha256.clone(), key))
})
.collect();
let attest_key = stores.forge_attest_public()?;
for (actor, commit) in &state.approvals {
let signed = all.iter().rev().find(|e| {
e.event.repo == repo
&& e.event.pull == pull
&& e.event.actor == *actor
&& matches!(&e.event.event, gunnar_core::Event::Approved { commit: c } if c == commit)
});
let verdict = match signed {
None => "NO EVENT".to_owned(),
// Gunnar's attestation for a stepped-up session (GHUNT-19):
// checked against gunnar's own forge-attestation key alone.
Some(signed) if signed.attested.is_some() => match &attest_key {
None => "UNKNOWN KEY".to_owned(),
Some(key) => match gunnar_core::forge::verify_event(signed, key) {
Ok(()) => signed
.attested
.as_ref()
.map(|a| a.words())
.unwrap_or_default(),
Err(problem) => format!("BAD ({problem})"),
},
},
Some(signed) => match keys
.iter()
.find(|(fingerprint, _)| *fingerprint == signed.key_fingerprint)
{
None => "UNKNOWN KEY".to_owned(),
Some((_, key)) => match gunnar_core::forge::verify_event(signed, key) {
Ok(()) => "verified".to_owned(),
Err(problem) => format!("BAD ({problem})"),
},
},
};
println!("approval {actor} → {commit} [{verdict}]");
}
Ok(())
}
ForgeCommand::Issue(issue) => forge_issue(stores, &log, issue),
}
}
/// `gunnar forge issue …` — issues as signed events, folded from the one log.
fn forge_issue(
stores: &ServerStores,
log: &crate::attest::TransparencyLog,
command: gunnar_common::cli::IssueCommand,
) -> Result<()> {
use gunnar_common::cli::IssueCommand;
/// Read every event, verifying nothing — the fold is over what was written.
fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
log.lines_of()?
.iter()
.map(|line| serde_json::from_str(line).context("decoding a forge event"))
.collect()
}
/// One repository's events, in log order, for the fold.
fn repo_events(
log: &crate::attest::TransparencyLog,
repo: &str,
) -> Result<Vec<gunnar_core::ForgeEvent>> {
Ok(events(log)?
.into_iter()
.filter(|e| e.event.repo == repo)
.map(|e| e.event)
.collect())
}
/// Sign an event with the actor's own key, after checking the key is in the
/// ACL table, and append it. The same door `gunnar forge comment` uses —
/// one writer, one verification of what makes an event evidence (one implementation).
fn append(
stores: &ServerStores,
identity: &std::path::Path,
event: gunnar_core::ForgeEvent,
) -> Result<gunnar_core::SignedEvent> {
let pem = std::fs::read_to_string(identity)
.with_context(|| format!("reading {}", identity.display()))?;
let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
.with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
let fingerprint = key
.public_key()
.fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
.to_string();
let known = stores
.auth()
.signing_keys()?
.into_iter()
.any(|row| row.fingerprint_sha256 == fingerprint);
anyhow::ensure!(
known,
"{fingerprint} is not registered on this server, so nobody reading this event later \
could check its signature. Register it with `gunnar key add` first."
);
let signed =
gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
let mut line = serde_json::to_string(&signed)?;
line.push('\n');
let _framed = gunnar_core::txlog::sink::append_line(
stores.forge_log().records_path(),
line.trim_end_matches('\n'),
)?;
use std::io::Write as _;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(stores.forge_log().records_path())?;
file.write_all(line.as_bytes())?;
Ok(signed)
}
match command {
IssueCommand::Open {
repo,
title,
body,
actor,
identity,
} => {
append(
stores,
&identity,
gunnar_core::ForgeEvent {
repo: repo.clone(),
// An opening has no number yet: the fold assigns it.
pull: 0,
actor: actor.clone(),
at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
event: gunnar_core::Event::IssueOpened {
title: title.clone(),
body,
},
},
)?;
// Read back the number the fold now gives it: the Nth opening is
// issue N, so the one just written is the last.
let number = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?)
.last()
.map(|i| i.number)
.unwrap_or(0);
println!("opened {repo}#{number} {title}");
println!("{actor} filed it, signed with their own key");
Ok(())
}
IssueCommand::Comment {
repo,
number,
body,
actor,
identity,
} => {
anyhow::ensure!(
gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
.is_some_and(|i| i.open),
"no OPEN issue #{number} in {repo}: a comment on a closed or absent issue is \
evidence about nothing"
);
append(
stores,
&identity,
gunnar_core::ForgeEvent {
repo: repo.clone(),
pull: number,
actor,
at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
event: gunnar_core::Event::IssueCommented { body },
},
)?;
println!("commented on {repo}#{number}");
Ok(())
}
IssueCommand::Close {
repo,
number,
reason,
actor,
identity,
} => {
anyhow::ensure!(
gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
.is_some_and(|i| i.open),
"no OPEN issue #{number} in {repo}: it is already closed, or was never opened"
);
append(
stores,
&identity,
gunnar_core::ForgeEvent {
repo: repo.clone(),
pull: number,
actor: actor.clone(),
at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
event: gunnar_core::Event::IssueClosed { reason },
},
)?;
println!("{actor} closed {repo}#{number}");
Ok(())
}
IssueCommand::List { repo } => {
let issues = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?);
if issues.is_empty() {
println!("{repo} has no issues");
return Ok(());
}
for issue in issues {
let state = if issue.open { "open " } else { "closed" };
println!(
"{repo}#{} [{state}] {} ({} comment(s), by {})",
issue.number, issue.title, issue.comments, issue.author
);
}
Ok(())
}
IssueCommand::Show { repo, number } => {
let all = repo_events(log, &repo)?;
let Some(issue) = gunnar_core::forge::fold_issue(number, &all) else {
anyhow::bail!("no issue #{number} in {repo}: nobody has opened one");
};
let state = if issue.open { "open" } else { "closed" };
println!("{repo}#{number} [{state}] {}", issue.title);
println!("opened by {}", issue.author);
if !issue.body.is_empty() {
println!();
println!("{}", issue.body);
}
println!();
println!("comments {}", issue.comments);
Ok(())
}
}
}
/// **What a per-repository restore cannot bring back** (wiring tracer B 2026-10-08): a
/// repository archive carries the objects and the refs, never the control document's settings
/// — and a repository catalogued afresh by `archive-in` is NOT encrypted, whatever it was. The
/// unsafe direction, so it is said, with the one command that sets it again.
#[cfg(feature = "engine-znippy")]
pub(crate) fn encrypted_flag_note(path: &RepoRef) -> String {
format!(
"note: a repository archive does not carry the account's settings, so {path} is NOT \
encrypted now, whatever it was. If it was, the account admin turns it on again: at the \
console (the repository's Settings, \"Turn encryption on\"), or gunnar control repo \
set-encrypted {path} on where the control plane is in reach; or restore the control \
document as well"
)
}
#[cfg(all(test, feature = "engine-znippy"))]
mod tests {
use super::*;
#[test]
fn a_repository_restored_without_its_control_document_is_told_its_encryption_is_not_back() {
let path = RepoRef::new("acme", "vault").expect("a legal path");
let note = encrypted_flag_note(&path);
assert!(note.contains("encrypted"), "{note}");
assert!(
note.contains("gunnar control repo set-encrypted acme/vault on"),
"the one command that sets it again: {note}"
);
}
/// ★ An installed estate binds its control plane to the WireGuard overlay, so the account
/// admin who restored there has no `gunnar control` in reach: the note also names the door
/// they do have — the repository's Settings at the console ("Turn encryption on").
#[test]
fn the_note_names_the_consoles_switch_an_estate_admin_can_reach() {
let path = RepoRef::new("acme", "vault").expect("a legal path");
let note = encrypted_flag_note(&path);
assert!(
note.contains("console") && note.contains("Turn encryption on"),
"the console's switch: {note}"
);
}
}