gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
//! Shared fixtures. One place that knows how a control plane is wired, so the
//! test files cannot drift apart about it (one implementation).

// Every integration test compiles this module separately, so whatever one of
// them does not use looks dead from that binary's point of view.
#![allow(dead_code)]

use std::sync::Arc;

use gunnar_core::{AuthTable, ControlStore, HashKind, NewRepo, RepoCatalog, RepoRef, Visibility};

/// A known-answer OpenSSH ed25519 public key.
///
/// Generated once with the real `ssh-keygen` (OpenSSH_10.2p1, 2026-08-03) and
/// pasted here with its fingerprint. Neither value is computed by gunnar, which
/// is the point: a fingerprint checked against gunnar's own arithmetic proves
/// only that gunnar is self-consistent.
pub const KAT_ED25519: &str =
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMW4aqVr75w+3DWorOCbfwl5XCDCq1yCQ6puzU4qzol2 gunnar-kat";

/// `ssh-keygen -lf` on [`KAT_ED25519`].
pub const KAT_ED25519_FINGERPRINT: &str = "SHA256:mRq7xzwk8TswSflB9kTXW/SLFDtAsgf5ksW8h6skax4";

/// A known-answer OpenSSH RSA public key, in a second algorithm, because a
/// fingerprint routine that only ever sees ed25519 is one blob-encoding
/// assumption away from being wrong.
pub const KAT_RSA: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCk+UFkETJzTQaKJojczwNtCTGEE6TVWu7bkJLTY4v+ssDRZ8OadsXZDjAa+nDeHBH39ELZw0lA0XpjljxCYsg2Ta9x4qN8MX9MSJjl10FrUmn6UuO4hRi6OC60fBOvMM1G2U7UnYFbt2Mojo+JIw1IsnwrlITPpx7lmse2lqidO1yGIJW5BQyJutIQsKux1pQkc+tpGbhUwI1/8Vihuy88DJGrWdMp3mS8r1LW9fvOcQ8vYy78318/uV2vfzb71IvaslwC40zyP5tIsmwWlvYEsZwMhk/pNzcgmLkn4fzeUVF37N6FP5V6RRT4GM4EK2Uq4H1OHgsgicqsYTrnqErn gunnar-kat-rsa";

/// `ssh-keygen -lf` on [`KAT_RSA`].
pub const KAT_RSA_FINGERPRINT: &str = "SHA256:WORS4jaxEY/2QuUpJOlfRD03Lz7CCSeXMdD0FjNacJg";

/// A second ed25519 key, for "this key is not that key" assertions. Also from
/// the real `ssh-keygen`.
pub const OTHER_ED25519: &str =
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICoNhstm/zoeaAvHBeLWH192X+HjvMcX7NLMGq98FL0B other";

/// `ssh-keygen -lf` on [`OTHER_ED25519`].
pub const OTHER_ED25519_FINGERPRINT: &str = "SHA256:BT8pCbOuAqBeX1M+Ep3hZMawlY3h74H9/91esEqk3fw";

/// A control plane over the `mem` backend: catalog, identity table and the
/// stores under them.
pub struct Fixture {
    pub catalog: RepoCatalog,
    pub auth: AuthTable,
    pub store: Arc<dyn ControlStore>,
}

impl Fixture {
    pub fn new() -> Self {
        let store = gunnar_core::memory_control_store().expect("control store");
        Fixture {
            catalog: RepoCatalog::new(Arc::clone(&store)).expect("catalog"),
            auth: AuthTable::new(Arc::clone(&store)).expect("auth table"),
            store,
        }
    }

    /// A repository at `ns/name`, creating the namespace if it is new.
    pub fn repo(&self, ns: &str, name: &str) -> gunnar_core::Repo {
        self.repo_with(ns, name, HashKind::Sha256, Visibility::Private)
    }

    pub fn repo_with(
        &self,
        ns: &str,
        name: &str,
        hash_kind: HashKind,
        visibility: Visibility,
    ) -> gunnar_core::Repo {
        if self.catalog.namespace(ns).expect("read").is_none() {
            self.catalog.create_namespace(ns).expect("create namespace");
        }
        let path = RepoRef::new(ns, name).expect("legal path");
        self.catalog
            .create(
                NewRepo::new(path)
                    .hash_kind(hash_kind)
                    .visibility(visibility),
            )
            .expect("create repo")
    }

    /// Everything the identity table stores, as one string: the root document
    /// and **the transitive closure of every shard it points at**.
    ///
    /// The closure and not just the root, because the root document holds only
    /// a blob id per map (see `gunnar_core::doc`) — a redaction check that
    /// read the root alone would be reading a few hundred bytes that could
    /// never contain a secret whatever the code did, and would be green
    /// forever. Ids are hex in the stored JSON, so the walk is: read a blob,
    /// take every hex word the right length for a blob id, recurse.
    pub fn dump_auth_document(&self) -> String {
        let width = gunnar_core::BLOB_ID_LEN * 2;
        let root = self
            .store
            .read_doc(gunnar_core::AUTH_REF)
            .expect("read the auth document")
            .expect("the auth table has a version; something was stored");

        let mut seen = std::collections::BTreeSet::new();
        let mut queue = vec![root];
        let mut out = String::new();
        while let Some(id) = queue.pop() {
            if !seen.insert(id) {
                continue;
            }
            let Ok(Some(bytes)) = self.store.get_blob(&id) else {
                continue;
            };
            let text = String::from_utf8_lossy(&bytes).into_owned();
            for word in text.split(|c: char| !c.is_ascii_hexdigit()) {
                if word.len() == width {
                    if let Some(child) = gunnar_core::BlobId::from_hex(word) {
                        queue.push(child);
                    }
                }
            }
            out.push_str(&text);
            out.push('\n');
        }
        assert!(
            seen.len() > 1,
            "the auth document has no shards, so this walk read one blob and \
             proves nothing about where a secret could hide"
        );
        out
    }
}