gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **The identity core, built at the start** (IDENTITY-HOST, 2026-10-27):
//! edda's `nornir_identity::Identity` over [`gunnar_grpc::identity::GunnarHost`].
//! Its own records (sessions and invites as hashes, the floors, the sign-up
//! state) live in `<data root>/identity.json`, written atomically and framed in
//! the transaction log like every other document, so a twin holds them too.
//! Mail: none (airgap) — an invite's link is handed over, and a sold estate's
//! first-login link is the shop's to mail.

use std::path::Path;
use std::sync::Arc;

use gunnar_grpc::identity::{
    system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables, Persist,
};
use gunnar_grpc::{ControlPlane, ServerConfig};
use nornir_identity::{Config, Identity, NoMail, Site};

/// The identity core's document under the data root.
pub const FILE: &str = "identity.json";

/// The stable code of [`read`]'s refusal.
pub const UNREADABLE: &str = "identity-unreadable";

/// **Read the tables** (RA5-9, fail closed): no document yet (`NotFound`) is
/// empty tables, the first start. ANY other failure — a read error (EACCES,
/// EIO, a restore with the wrong owner) or a document that does not parse — is
/// refused by name and the file is left exactly as it is: started empty over
/// it, a deactivated person's passkey would sign in again, every open invite
/// and session record would be gone, and the first login would write over the
/// unread file.
pub fn read(root: &Path) -> Result<IdentityTables, String> {
    let path = root.join(FILE);
    let text = match std::fs::read_to_string(&path) {
        Ok(text) => text,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(IdentityTables::default()),
        Err(e) => {
            return Err(format!(
                "[{UNREADABLE}] {} does not read ({e}): the identity core does not start on empty \
                 tables over it. Give it back to this server's user (owner and mode), or restore \
                 it, then start again; it was not touched",
                path.display()
            ))
        }
    };
    GunnarStore::read_tables(&text).map_err(|why| {
        format!(
            "[{UNREADABLE}] {}: {why}. The identity core does not start on empty tables over it; \
             restore it, then start again; it was not touched",
            path.display()
        )
    })
}

/// **Follow the main** (RA5-10): on a twin, the primary's `identity.json` arrived
/// (a frame, a snapshot) — the core's tables are read from it again, so the
/// twin's identity state is the main's, not the one it loaded at its own boot.
/// A document that does not read leaves the tables as they were and says so.
pub fn follow(root: &Path, identity: &GunnarIdentity) -> Result<(), String> {
    let tables = read(root)?;
    identity.host().gunnar_store().reload(tables);
    Ok(())
}

/// The core this server serves, or why it does not start ([`read`]).
pub fn try_build(
    root: &Path,
    control: Arc<dyn ControlPlane>,
    config: &ServerConfig,
    root_site: bool,
) -> Result<Arc<GunnarIdentity>, String> {
    let tables = read(root)?;
    let dir = root.to_path_buf();
    let persist: Persist = Box::new(move |json: &str| {
        crate::durable::write_atomic(&dir, FILE, json.as_bytes()).map_err(|e| format!("{e:#}"))
    });
    let clock = system_clock();
    let store = GunnarStore::new(control, tables, Some(persist), Arc::clone(&clock));
    let site = if root_site { Site::Root } else { Site::Private };
    let host = GunnarHost::new(site, store, Arc::new(NoMail), clock);
    let core = Config {
        parties: config.web_relying_parties.clone(),
        user_verification: config.web_user_verification,
        ..Config::default()
    };
    Ok(Arc::new(Identity::new(host, core)))
}

/// [`try_build`] for tests and tools, which have no start to refuse: a
/// document that does not read is a panic naming it. The server uses
/// [`try_build`].
pub fn build(
    root: &Path,
    control: Arc<dyn ControlPlane>,
    config: &ServerConfig,
    root_site: bool,
) -> Arc<GunnarIdentity> {
    try_build(root, control, config, root_site).unwrap_or_else(|why| panic!("{why}"))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The control plane of a fresh box under `dir`.
    fn plane(dir: &Path) -> (Arc<crate::stores::ServerStores>, Arc<dyn ControlPlane>) {
        let stores = Arc::new(crate::stores::ServerStores::open(dir).unwrap());
        let control: Arc<dyn ControlPlane> = Arc::new(crate::control_plane::CoreControlPlane::new(
            Arc::clone(&stores),
        ));
        (stores, control)
    }

    /// ★ RA5-9: a document that does not parse is refused by name and left in
    /// place: never moved aside into empty tables, never written over.
    #[test]
    fn a_document_that_does_not_parse_refuses_the_start_and_is_never_written_over() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(FILE), b"{not json").unwrap();
        let why = read(dir.path()).expect_err("no empty tables over an unread document");
        assert!(
            why.contains("[identity-unreadable]") && why.contains("identity.json"),
            "{why}"
        );
        let (_stores, control) = plane(dir.path());
        let refused = try_build(dir.path(), control, &ServerConfig::default(), false)
            .map(|_| ())
            .expect_err("the core does not start");
        assert!(refused.contains("[identity-unreadable]"), "{refused}");
        assert_eq!(
            std::fs::read(dir.path().join(FILE)).unwrap(),
            b"{not json",
            "the file is untouched"
        );
        let names: Vec<_> = std::fs::read_dir(dir.path())
            .unwrap()
            .flatten()
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .collect();
        assert!(
            !names
                .iter()
                .any(|n| n.starts_with("identity.json.unreadable-")),
            "nothing moved aside: {names:?}"
        );
    }

    /// ★ RA5-9: a document this server's user cannot read (mode 0000: a restore
    /// with the wrong owner, EACCES) is refused by name, never read as empty.
    #[cfg(unix)]
    #[test]
    fn an_unreadable_document_refuses_the_start_by_name_and_is_never_written_over() {
        use std::os::unix::fs::PermissionsExt as _;
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(FILE);
        std::fs::write(&path, br#"{"people":{"ex-admin":{"state":"deactivated"}}}"#).unwrap();
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
        if std::fs::read(&path).is_ok() {
            // root reads a 0000 file; the refusal is then not reachable this way.
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
            eprintln!("skipped: this user reads a mode-0000 file (root)");
            return;
        }
        let why = read(dir.path()).expect_err("an unreadable document is no empty tables");
        assert!(
            why.contains("[identity-unreadable]") && why.contains("does not read"),
            "{why}"
        );
        let (_stores, control) = plane(dir.path());
        assert!(
            try_build(dir.path(), control, &ServerConfig::default(), false).is_err(),
            "the core does not start"
        );
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
        assert_eq!(
            std::fs::read(&path).unwrap(),
            br#"{"people":{"ex-admin":{"state":"deactivated"}}}"#,
            "untouched"
        );
    }

    #[test]
    fn no_document_is_no_tables() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(read(dir.path()).unwrap(), IdentityTables::default());
    }

    /// ★ RA5-10: on a twin (and a browse replica) the core takes no identity
    /// write — refused by name, identity.json untouched — and the twin's tables
    /// follow the main's document when it arrives.
    #[test]
    fn a_twin_refuses_identity_writes_by_name_and_follows_the_mains_tables() {
        use nornir_identity::{Session, UserStore as _};
        let dir = tempfile::tempdir().unwrap();
        let (stores, control) = plane(dir.path());
        let core = build(
            dir.path(),
            Arc::clone(&control),
            &ServerConfig::default(),
            false,
        );
        let store = core.host().gunnar_store();
        let session = |id: &str| Session {
            id: id.into(),
            user: "alice".into(),
            auth_time: 1,
            expires: i64::MAX,
            stepped_up_at: None,
        };
        store
            .put_session(&session("on-the-main"))
            .expect("a primary writes");

        stores.mark_twin();
        let before = std::fs::read(dir.path().join(FILE)).unwrap();
        let refused = store
            .put_session(&session("on-the-twin"))
            .expect_err("a twin takes no identity write");
        assert!(
            refused.0.contains("[identity-read-only]") && refused.0.contains("twin"),
            "{}",
            refused.0
        );
        let refused = store
            .update_invites(&mut |_| {})
            .expect_err("no invite either");
        assert!(refused.0.contains("[identity-read-only]"), "{}", refused.0);
        assert!(store.delete_session("on-the-main").is_err());
        assert_eq!(
            std::fs::read(dir.path().join(FILE)).unwrap(),
            before,
            "identity.json untouched"
        );

        // The main's document arrives as a File frame: the twin applies it (it stores every
        // other data frame) and the core answers from the main's tables.
        stores.set_identity(&core);
        let mut main = store.tables();
        main.sessions.push(row("from-the-main"));
        let json = serde_json::to_string(&main).unwrap();
        let meta = gunnar_core::txlog::Meta::File {
            path: FILE.into(),
            deleted: false,
        }
        .to_bytes();
        let took = crate::twin::take_raw(
            &stores,
            7,
            gunnar_core::txlog::Kind::File as u16,
            &meta,
            json.as_bytes(),
        )
        .unwrap();
        assert_eq!(took, crate::twin::Applied::Done);
        assert!(
            store.session("from-the-main").unwrap().is_some(),
            "the twin follows the main"
        );
        assert_eq!(
            std::fs::read_to_string(dir.path().join(FILE)).unwrap(),
            json
        );
    }

    fn row(id: &str) -> gunnar_grpc::identity::SessionRow {
        gunnar_grpc::identity::SessionRow {
            id: id.into(),
            user: "alice".into(),
            auth_time: 1,
            expires: i64::MAX,
            stepped_up_at: None,
        }
    }
}