crates/gunnar-cli/tests/bootstrap.rs
//! **Trust on first use, proved on served behaviour.**
//!
//! Every claim in here is asserted against a real `gunnar serve` on a real data
//! path, over the real gRPC transport and the real SSH transport, with the
//! **stock `git` binary** as the client. Nothing inspects the identity table to
//! decide whether a test passed: a test that only read the table would prove
//! nothing about the door, which is the whole point of the door.
//!
//! ```text
//! 1 a fresh data path, no identities → Bootstrap.Status says window_open
//! 2 Bootstrap.Enroll, no credential → one gunnar-admin, window shut
//! 3 that user pushes to <name>/<repo> → accepted
//! 4 …and to somebody else's account → refused
//! 5 a second Enroll → FAILED_PRECONDITION
//! 6 delete every identity, Enroll → STILL FAILED_PRECONDITION
//! 7 SIGKILL the process, restart → all of it survives
//! ```
//!
//! The two ed25519 keys below are **fixture data**. They are the real public
//! halves of two working keys, carried here because a test that enrolled a
//! freshly minted key would never notice a bug in how a real one is parsed and
//! fingerprinted. They authorise nothing anywhere: this file only ever puts
//! them into a temp directory it deletes.
//!
//! `clippy::result_large_err` is allowed for the reason `gunnar-grpc`'s own
//! modules allow it: `tonic::Status` is a large error type by design, and this
//! file's job is to return the server's real status codes rather than to map
//! them into something smaller and lose the distinction it is testing.
#![allow(clippy::result_large_err)]
mod common;
use std::path::Path;
use std::sync::Arc;
use gunnar_cli::server::{ServeConfig, Server, ServerStores};
use gunnar_core::{HashKind, Level, Principal, PrincipalKind, RepoRef, Visibility};
use gunnar_proto::bootstrap_client::BootstrapClient;
use gunnar_proto::{BootstrapStatusRequest, EnrollRequest};
/// The first fixture user's laptop key. Public half; a public key is not a secret.
const ALICE_PUB: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN/xwBtUndMAcVY53UIcZacYaiZR3Ij62q0Gc2w1hcuj user@host";
/// The second fixture user's key, the second identity in the same story.
const BOB_PUB: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN9ZtPXJv8cvroO3mfgbSGCZUaLMBF8+w9t28ACtdXrt bob";
// ───────────────────────────────────────────────────────────────────────────
// A server with NOTHING in it
// ───────────────────────────────────────────────────────────────────────────
/// A running gunnar on a data path the caller owns, with **no identity at
/// all**.
///
/// Deliberately not `common::Harness`: that one provisions a principal, a key
/// and two grants before it binds, which is exactly the state this file has to
/// start without. A server that had been provisioned would report the window
/// shut for the wrong reason and every assertion below would be vacuous.
struct Fresh {
runtime: tokio::runtime::Runtime,
server: Option<Server>,
stores: Arc<ServerStores>,
grpc: std::net::SocketAddr,
ssh_port: u16,
}
impl Fresh {
fn start(root: &Path) -> Fresh {
Fresh::start_as(root, false)
}
/// `twin_role`: started `--role twin`.
fn start_as(root: &Path, twin_role: bool) -> Fresh {
Fresh::start_on(root, twin_role, None)
}
/// `ssh_bind`: a NAMED git port to ask for, rather than "any port".
///
/// It matters for one assertion and only that one: `serving_git` compares
/// the address the listener was asked for with the address it took, and
/// `ServeConfig::ephemeral`'s `127.0.0.1:0` is a request a twin's
/// loopback-only bind satisfies by accident. An operator's appliance asks
/// for `0.0.0.0:2222`; a test that asks for nothing in particular cannot
/// observe being given something else.
fn start_on(root: &Path, twin_role: bool, ssh_bind: Option<std::net::SocketAddr>) -> Fresh {
if std::env::var_os("RUST_LOG").is_some() {
gunnar_cli::init_tracing(false);
}
let stores = Arc::new(ServerStores::open(root).expect("open the stores"));
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("a tokio runtime");
let ephemeral = ServeConfig::ephemeral();
let server = runtime
.block_on(Server::start(
Arc::clone(&stores),
ServeConfig {
// The listener that matters here. `Bootstrap` is on it.
no_grpc: false,
twin_role,
ssh_bind: ssh_bind.unwrap_or(ephemeral.ssh_bind),
..ephemeral
},
))
.expect("a server with no identities must START; refusing to start is the defect");
let grpc = server
.grpc_addr()
.expect("the control plane bound, or nobody could ever enrol");
let ssh_port = server.ssh_addr().port();
Fresh {
runtime,
server: Some(server),
stores,
grpc,
ssh_port,
}
}
fn client(&self) -> BootstrapClient<tonic::transport::Channel> {
let url = format!("http://{}", self.grpc);
let channel = self
.runtime
.block_on(
tonic::transport::Channel::from_shared(url)
.expect("a well-formed endpoint")
.connect(),
)
.expect("connect to the control plane");
BootstrapClient::new(channel)
}
/// `Bootstrap.Status`, over the wire, with **no credential at all**.
fn status(&self) -> gunnar_proto::BootstrapStatusResponse {
let mut client = self.client();
self.runtime
.block_on(client.status(BootstrapStatusRequest {}))
.expect("Status is servable without a credential")
.into_inner()
}
/// `Bootstrap.Enroll`, over the wire, with **no credential at all**.
fn enroll(&self, name: &str, key: &str) -> Result<gunnar_proto::EnrollResponse, tonic::Status> {
let mut client = self.client();
self.runtime
.block_on(client.enroll(EnrollRequest {
name: name.to_owned(),
openssh_public_key: key.to_owned(),
}))
.map(|r| r.into_inner())
}
fn shutdown(mut self) {
if let Some(server) = self.server.take() {
self.runtime.block_on(server.shutdown()).expect("shutdown");
}
}
}
impl Drop for Fresh {
fn drop(&mut self) {
if let Some(server) = self.server.take() {
let _ = self.runtime.block_on(server.shutdown());
}
}
}
// ───────────────────────────────────────────────────────────────────────────
// 1 + 2: the exact call a first-time user makes
// ───────────────────────────────────────────────────────────────────────────
#[test]
fn an_unenrolled_server_starts_says_so_and_enrols_without_a_credential() {
let dir = tempfile::tempdir().expect("a temp dir");
let fresh = Fresh::start(&dir.path().join("data"));
// ── it SAYS SO, over the wire, before anybody has a credential ─────────
let before = fresh.status();
assert!(
before.window_open,
"a server nothing has ever been enrolled on must report window_open, or an operator has \
to guess whether their box is standing wide open"
);
assert!(!before.bootstrapped);
assert_eq!((before.principals, before.keys), (0, 0));
assert!(
before.detail.contains("NO IDENTITY HAS EVER BEEN ENROLLED"),
"the status has to say it in words a human reads, not only in a bool: {:?}",
before.detail
);
// ── the call ───────────────────────────────────────────────────────────
let enrolled = fresh
.enroll("alice", ALICE_PUB)
.expect("the first enrolment needs no credential");
let principal = enrolled.principal.expect("a principal came back");
assert_eq!(principal.id, "alice");
assert_eq!(
enrolled.account, "alice",
"the user owns the account named after them: <host>/<account>/<repo>"
);
let key = enrolled.key.expect("a key came back");
assert!(
key.fingerprint.starts_with("SHA256:"),
"the server derives the fingerprint itself: {:?}",
key.fingerprint
);
assert!(
enrolled.bootstrapped_at_unix_ms > 0,
"the moment the door shut is recorded, not left at zero"
);
// ── and the window is shut, over the wire, immediately ─────────────────
let after = fresh.status();
assert!(
!after.window_open,
"the window must close on the first success"
);
assert!(after.bootstrapped);
assert_eq!(after.bootstrapped_principal, "alice");
// Two credentials, one principal: the enrolled ssh key AND the client
// certificate the appliance mints for the bootstrap admin (c25257f9). Both
// land in the key table, which is what `keys` counts.
assert_eq!((after.principals, after.keys), (1, 2));
// ── 5: a second call is refused ────────────────────────────────────────
let second = fresh
.enroll("bob", BOB_PUB)
.expect_err("a second enrolment must be refused");
assert_eq!(second.code(), tonic::Code::FailedPrecondition);
assert!(
second.message().contains("bootstrapped"),
"and it must say why: {:?}",
second.message()
);
assert_eq!(
fresh.status().keys,
2,
"the refused call must not have installed anything on its way out; the two credentials \
are the winner's ssh key and bootstrap certificate"
);
// The gunnar-admin flag is a real Server/Admin grant, reachable by the
// ordinary ACL, not a special case.
assert!(
fresh
.stores
.auth()
.may_server("alice", Level::Admin)
.expect("acl"),
"the first user is gunnar-admin"
);
fresh.shutdown();
}
/// **Enrolment attempted with an identity already present is refused**, even
/// when no bootstrap record exists — the upgrade case, which is a different
/// refusal and has to be seen separately.
#[test]
fn a_server_that_already_holds_identities_refuses_to_be_bootstrapped() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("data");
{
// Provision a key the way `gunnar key add` does, offline, and write no
// bootstrap record at all.
let stores = ServerStores::open(root.clone()).expect("open");
stores
.auth()
.add_principal(Principal::new("existing", "existing", PrincipalKind::User).expect("ok"))
.expect("add");
stores
.auth()
.register_key("existing", BOB_PUB, None)
.expect("register");
}
let fresh = Fresh::start(&root);
let status = fresh.status();
assert!(
!status.window_open,
"a table with identities in it is not a fresh server, record or no record"
);
assert!(
!status.bootstrapped,
"…and it honestly reports that no bootstrap record exists, rather than inventing one"
);
let refused = fresh
.enroll("alice", ALICE_PUB)
.expect_err("enrolment against a provisioned table must be refused");
assert_eq!(refused.code(), tonic::Code::FailedPrecondition);
assert!(
refused.message().contains("not a fresh server"),
"the two refusals must be distinguishable: {:?}",
refused.message()
);
fresh.shutdown();
}
// ───────────────────────────────────────────────────────────────────────────
// The concurrency boundary
// ───────────────────────────────────────────────────────────────────────────
/// **Two enrolments racing: exactly one wins.**
///
/// Driven, not reasoned about. Both calls go over the real transport from real
/// OS threads onto a server whose control plane has its own runtime, so they
/// genuinely overlap.
#[test]
fn two_concurrent_enrolments_produce_exactly_one_admin() {
let dir = tempfile::tempdir().expect("a temp dir");
let fresh = Fresh::start(&dir.path().join("data"));
let url = format!("http://{}", fresh.grpc);
let barrier = Arc::new(std::sync::Barrier::new(2));
let mut handles = Vec::new();
for (name, key) in [("alice", ALICE_PUB), ("bob", BOB_PUB)] {
let url = url.clone();
let barrier = Arc::clone(&barrier);
handles.push(std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime");
rt.block_on(async move {
let channel = tonic::transport::Channel::from_shared(url)
.expect("a well-formed endpoint")
.connect()
.await
.expect("connect");
let mut client = BootstrapClient::new(channel);
// Connected first, so the barrier releases into the RPC itself
// and not into a TCP handshake.
barrier.wait();
client
.enroll(EnrollRequest {
name: name.to_owned(),
openssh_public_key: key.to_owned(),
})
.await
.map(|r| r.into_inner().account)
.map_err(|s| (s.code(), s.message().to_owned()))
})
}));
}
let outcomes: Vec<_> = handles
.into_iter()
.map(|h| h.join().expect("joined"))
.collect();
let winners: Vec<_> = outcomes.iter().filter_map(|o| o.as_ref().ok()).collect();
let losers: Vec<_> = outcomes.iter().filter_map(|o| o.as_ref().err()).collect();
assert_eq!(
winners.len(),
1,
"exactly one of two concurrent enrolments must win; got {outcomes:?}"
);
assert_eq!(losers.len(), 1);
assert_eq!(
losers[0].0,
tonic::Code::FailedPrecondition,
"the loser must be refused as a closed window, not as an internal error: {:?}",
losers[0]
);
// And the SERVER agrees: one principal, one admin, holding the winner's
// ssh key plus the bootstrap certificate.
let status = fresh.status();
assert_eq!((status.principals, status.keys), (1, 2));
assert_eq!(&status.bootstrapped_principal, winners[0]);
assert_eq!(
fresh.stores.auth().principals().expect("read").len(),
1,
"a second principal would mean both calls partly landed, which is the whole failure"
);
fresh.shutdown();
}
// ───────────────────────────────────────────────────────────────────────────
// Persistence: the process is KILLED, not asked nicely
// ───────────────────────────────────────────────────────────────────────────
/// **Everything the ACL knows survives, and it survives a SIGKILL.**
///
/// The three facts, each read back from a *different process's* stores after
/// the first one was killed: the user and its key, the account, and the
/// gunnar-admin flag. Plus the fourth, which is the one this design turns on:
/// bootstrap-has-happened.
///
/// `SIGKILL` and not a clean shutdown: `Drop` does not run, no shutdown hook
/// runs, and nothing gets a chance to flush. If any of this depended on a
/// graceful exit it would not be persistence.
#[test]
fn every_acl_fact_survives_a_sigkill_and_a_restart() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("data");
// ── boot 1: enrol, add a second user, hand out gunnar-admin ───────────
{
let fresh = Fresh::start(&root);
fresh.enroll("alice", ALICE_PUB).expect("enrol");
// Ordinary authenticated administration, through the SAME verbs an
// operator uses. Driven in-process against the live stores, which is
// what `Access.AddPrincipal` reaches.
let plane = gunnar_cli::server::CoreControlPlane::new(Arc::clone(&fresh.stores));
use gunnar_grpc::ControlPlane as _;
plane
.add_principal("bob", "bob", gunnar_proto::PrincipalKind::User)
.expect("the gunnar-admin creates a second user");
plane
.add_key("bob", BOB_PUB, "", None, "", None)
.expect("with a key");
plane
.grant("bob", "", "", gunnar_proto::GrantLevel::Admin)
.expect("and gunnar-admin, which any gunnar-admin may hand to anybody");
fresh.shutdown();
}
// ── the kill ───────────────────────────────────────────────────────────
//
// A whole process, so there is a real SIGKILL and a real un-run `Drop`.
// `gunnar serve --check` would exit cleanly; this one is killed mid-serve.
let killed = kill_a_serving_process(&root);
assert!(
killed,
"the test must actually have SIGKILLed a serving gunnar; a skipped kill would make \
every assertion below prove only that a clean shutdown works"
);
// ── boot 2: a brand-new process against the same data path ────────────
let fresh = Fresh::start(&root);
let status = fresh.status();
assert!(
!status.window_open,
"the door is still shut after a SIGKILL"
);
assert!(status.bootstrapped);
assert_eq!(status.bootstrapped_principal, "alice");
assert_eq!(
(status.principals, status.keys),
(2, 3),
"both users and all three credentials survived: alice's ssh key, alice's bootstrap \
certificate, bob's ssh key"
);
let auth = fresh.stores.auth();
// users + keys
assert!(auth.principal("alice").expect("read").is_some());
let bobs = auth.keys_of("bob").expect("read");
assert_eq!(bobs.len(), 1, "bob's key survived");
// accounts — intrinsic, and it is a namespace that really exists
assert!(auth.may_namespace("bob", "bob", Level::Admin).expect("acl"));
// alice is the box's first identity (bootstrap enrolled her): her
// namespace is the estate's own, not a customer's account, so the
// 2026-10-05 shield does not stand between it and a server grant.
assert!(
auth.may_namespace("bob", "alice", Level::Admin)
.expect("acl"),
"bob reaches alice's account because the gunnar-admin grant SURVIVED, not because \
of the account rule"
);
assert!(
!auth
.may_namespace("stranger", "alice", Level::Read)
.expect("acl"),
"…and an unknown principal reaches nothing, which is what keeps the line above from being an ACL that says yes to everybody"
);
assert!(
fresh
.stores
.catalog()
.namespaces()
.expect("read")
.iter()
.any(|ns| ns.name == "bob"),
"the account namespace was created with the user and survived"
);
// the gunnar-admin flag, on BOTH
assert!(auth.may_server("alice", Level::Admin).expect("acl"));
assert!(auth.may_server("bob", Level::Admin).expect("acl"));
// and the record itself
let record = auth.bootstrap_record().expect("read").expect("a record");
assert_eq!(record.principal_id, "alice");
assert_eq!(record.account, "alice");
let refused = fresh
.enroll("mallory", BOB_PUB)
.expect_err("and the window is still shut across the restart");
assert_eq!(refused.code(), tonic::Code::FailedPrecondition);
fresh.shutdown();
}
/// **The attack, against the real files.** Delete every identity on disk,
/// restart, and try again.
#[test]
fn deleting_every_identity_on_disk_does_not_reopen_the_door() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("data");
{
let fresh = Fresh::start(&root);
fresh.enroll("alice", ALICE_PUB).expect("enrol");
fresh.shutdown();
}
// Offline, with the server down: exactly what an attacker with the data
// path, or an operator with `gunnar principal remove`, can do.
{
let stores = ServerStores::open(root.clone()).expect("open");
stores.auth().remove_principal("alice").expect("remove");
assert!(
stores.auth().is_empty().expect("read"),
"the identity table really is empty on disk now"
);
}
let fresh = Fresh::start(&root);
let status = fresh.status();
assert!(
!status.window_open,
"wiping the identity table must NOT reopen the enrolment window"
);
assert!(status.bootstrapped, "the record outlived the identities");
assert_eq!((status.principals, status.keys), (0, 0));
assert!(
status.detail.contains("nobody can authenticate"),
"and the server says what state it is in: {:?}",
status.detail
);
let refused = fresh
.enroll("mallory", BOB_PUB)
.expect_err("and enrolment is still refused");
assert_eq!(refused.code(), tonic::Code::FailedPrecondition);
fresh.shutdown();
}
/// **A data path that cannot be read is not an empty one.**
///
/// The single worst failure this design can have is a corrupt or unreadable
/// identity document being served as "nobody has enrolled here yet". Corrupt
/// the blob the auth reference points at and assert the server says
/// `UNAVAILABLE` rather than opening the door.
#[test]
fn an_unreadable_identity_table_is_never_served_as_an_open_window() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("data");
{
let fresh = Fresh::start(&root);
fresh.enroll("alice", ALICE_PUB).expect("enrol");
fresh.shutdown();
}
// Point the identity document at bytes that are not a document. The
// document exists, so this is "unreadable", not "absent".
//
// Through the control store's own compare-and-swap (certificate identity moved this
// document out of `control.git`), so it is a write another writer could
// have made rather than a hand-edited file the code never produces.
let db = root.join(gunnar_cli::server::CONTROL_DB);
assert!(
db.exists(),
"the control store must be a real file on the data path: {}",
db.display()
);
{
let store = gunnar_core::open_control_store(&db).expect("open the control store");
let before = store
.read_doc(gunnar_core::AUTH_REF)
.expect("read the identity document");
assert!(
before.is_some(),
"the identity table must have been written by the enrolment above"
);
let junk = store
.put_blob(b"this is not an identity table")
.expect("store the junk");
assert!(store
.commit_doc(gunnar_core::AUTH_REF, before, junk)
.expect("commit the junk"));
}
let stores = ServerStores::open(root.clone()).expect("the stores still open");
let err = stores
.auth()
.bootstrap_window_open()
.expect_err("an unreadable identity table must be an ERROR, never `open`");
let text = format!("{err}");
assert!(
!text.is_empty(),
"and it must name what went wrong rather than being a bare unit"
);
// And the server refuses to start rather than starting with the door open.
let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("runtime");
rt.block_on(Server::start(
Arc::new(stores),
ServeConfig {
no_grpc: false,
..ServeConfig::ephemeral()
},
))
.is_ok()
}));
assert!(
matches!(started, Ok(false)),
"a server whose identity table cannot be read must refuse to start, not start with the \
enrolment window open"
);
}
// ───────────────────────────────────────────────────────────────────────────
// The proof that matters: real git, real SSH, real accounts
// ───────────────────────────────────────────────────────────────────────────
/// **The enrolled user pushes to their own account and is refused on
/// somebody else's.**
///
/// Over stock `git` and the real SSH listener, with the key that was enrolled
/// over gRPC. A test that read the grant table instead would prove nothing
/// about what the server serves.
#[test]
fn the_enrolled_user_pushes_to_their_own_account_and_not_to_another() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("data");
// The private half of a key we then enrol the public half of. Minted, so
// this test carries no private material.
let private = gunnar_core::mint_ed25519().expect("mint");
let parsed =
gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("the minted key parses");
let public = parsed.public_key().to_openssh().expect("render");
let key_path = dir.path().join("id_ed25519");
std::fs::write(&key_path, &private).expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
.expect("ssh refuses a world-readable private key");
}
let fresh = Fresh::start(&root);
fresh.enroll("alice", &public).expect("enrol over gRPC");
// alice creates a repository IN HIS OWN ACCOUNT. He is admin of it by the
// intrinsic rule; the namespace was created with him.
let own = RepoRef::new("alice", "hello").expect("path");
let created = fresh
.stores
.catalog()
.create(
gunnar_core::NewRepo::new(own.clone())
.hash_kind(HashKind::Sha256)
.visibility(Visibility::Private),
)
.expect("create in his own account");
fresh.stores.repo_store(&created).expect("materialise");
// Somebody else's account, which he must not reach. Created directly, the
// way a second enrolled user's would be.
fresh
.stores
.auth()
.add_principal(Principal::new("bob", "bob", PrincipalKind::User).expect("ok"))
.expect("add");
fresh
.stores
.catalog()
.create_namespace("bob")
.expect("bob's account");
let theirs = RepoRef::new("bob", "hello").expect("path");
let other = fresh
.stores
.catalog()
.create(
gunnar_core::NewRepo::new(theirs)
.hash_kind(HashKind::Sha256)
.visibility(Visibility::Private),
)
.expect("create in bob's account");
fresh.stores.repo_store(&other).expect("materialise");
// alice is gunnar-admin, which WOULD let him reach bob's namespace.
// Take that away, so what is being measured is the ACCOUNT rule and not the
// server grant. Without this the "refused" assertion below could never fire
// and the whole test would be an identity value.
fresh
.stores
.auth()
.revoke_grant("alice", &gunnar_core::Scope::Server)
.expect("revoke the server grant");
let work = dir.path().join("work");
std::fs::create_dir_all(&work).expect("mkdir");
let ssh = format!(
"ssh -i {} -o IdentitiesOnly=yes -o IdentityAgent=none -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o BatchMode=yes -o LogLevel=ERROR",
key_path.display()
);
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&work)
.env("GIT_SSH_COMMAND", &ssh)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("HOME", dir.path())
.env("GIT_AUTHOR_NAME", "Gunnar Test")
.env("GIT_AUTHOR_EMAIL", "gunnar@example.invalid")
.env("GIT_COMMITTER_NAME", "Gunnar Test")
.env("GIT_COMMITTER_EMAIL", "gunnar@example.invalid")
.args(args)
.output()
.expect("git is on PATH");
(
out.status.success(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
};
// sha256, because a new gunnar repository is sha256.
assert!(git(&["init", "--object-format=sha256", "."]).0);
std::fs::write(work.join("README"), "gunnar\n").expect("write");
assert!(git(&["add", "README"]).0);
assert!(git(&["commit", "-m", "first"]).0);
let mine = format!("ssh://git@127.0.0.1:{}/alice/hello.git", fresh.ssh_port);
let theirs = format!("ssh://git@127.0.0.1:{}/bob/hello.git", fresh.ssh_port);
// ── his own account: ACCEPTED ─────────────────────────────────────────
let (ok, err) = git(&["push", &mine, "HEAD:refs/heads/main"]);
assert!(
ok,
"the enrolled user must be able to push to their own account with no grant at all, \
because account admin is intrinsic: {err}"
);
// ── somebody else's account: REFUSED ──────────────────────────────────
let (ok, err) = git(&["push", &theirs, "HEAD:refs/heads/main"]);
assert!(
!ok,
"the enrolled user must NOT be able to push into another account's namespace; the push \
was accepted"
);
assert!(
err.to_lowercase().contains("denied")
|| err.to_lowercase().contains("permission")
|| err.to_lowercase().contains("access"),
"and the refusal must say so: {err}"
);
fresh.shutdown();
}
// ───────────────────────────────────────────────────────────────────────────
// The SIGKILL, as a real process
// ───────────────────────────────────────────────────────────────────────────
/// Start `gunnar serve` on `root` as a **child process**, wait until it is
/// listening, then `SIGKILL` it. `true` when a process really was killed.
///
/// Not `Server::shutdown`: this file's whole claim is that persistence does not
/// depend on anything running at exit, and a graceful stop cannot show that.
/// `kill -9` runs no `Drop`, no shutdown hook and no flush.
fn kill_a_serving_process(root: &Path) -> bool {
let Some(bin) = gunnar_binary() else {
// Under `cargo test` the binary is always built beside the test, so
// this is a hard failure and never a silent skip: a skipped kill would
// leave the caller asserting that a CLEAN shutdown persists, which is
// the thing this is written not to prove.
panic!(
"the `gunnar` binary was not found beside this test; the SIGKILL leg cannot be \
skipped, because a skipped kill turns the persistence assertions into a test of a \
graceful shutdown"
);
};
let mut child = std::process::Command::new(&bin)
.arg("--data-root")
.arg(root)
.arg("serve")
.arg("--ssh-bind")
.arg("127.0.0.1:0")
.arg("--grpc-bind")
.arg("127.0.0.1:0")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn gunnar serve");
// Wait for the banner on stdout: `serve` prints `ssh <addr>` once both
// listeners are bound. Polling `/proc` for liveness would only prove the
// process exists, not that it got as far as opening its data path.
use std::io::BufRead as _;
let stdout = child.stdout.take().expect("piped");
let mut reader = std::io::BufReader::new(stdout);
let mut line = String::new();
let bound = {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut seen = false;
while std::time::Instant::now() < deadline {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) if line.starts_with("ssh ") => {
seen = true;
break;
}
Ok(_) => continue,
Err(_) => break,
}
}
seen
};
assert!(
bound,
"the child gunnar never announced its listeners, so there was nothing to kill"
);
// SIGKILL. `Child::kill` is exactly that on unix.
child.kill().expect("SIGKILL the serving gunnar");
let status = child.wait().expect("reap");
assert!(
!status.success(),
"a SIGKILLed process must not report a clean exit; got {status:?}"
);
true
}
/// The `gunnar` binary cargo built beside this test.
fn gunnar_binary() -> Option<std::path::PathBuf> {
let mut dir = std::env::current_exe().ok()?;
dir.pop(); // …/deps
dir.pop(); // …/debug
let candidate = dir.join("gunnar-cli");
candidate.is_file().then_some(candidate)
}
// ───────────────────────────────────────────────────────────────────────────
// Everything after the first user is ORDINARY AUTHENTICATED administration
// ───────────────────────────────────────────────────────────────────────────
/// **A non-admin user may not create users and may not hand out gunnar-admin.**
///
/// Driven through the real `Access` handlers against the real `AuthTable`, with
/// a verified caller attached exactly as `AuthGate` attaches one, so what is
/// being measured is the grant decision and not a missing credential.
///
/// Both directions: the gunnar-admin succeeds at the same two calls the
/// ordinary user is refused. A suite that only asserted the refusals would pass
/// just as well against a control plane that refuses everybody.
#[test]
fn an_ordinary_user_cannot_create_users_or_grant_gunnar_admin() {
use gunnar_grpc::{AuthMode, Caller, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::access_server::Access as _;
let dir = tempfile::tempdir().expect("a temp dir");
let fresh = Fresh::start(&dir.path().join("data"));
fresh.enroll("alice", ALICE_PUB).expect("enrol");
let plane = Arc::new(gunnar_cli::server::CoreControlPlane::new(Arc::clone(
&fresh.stores,
)));
{
use gunnar_grpc::ControlPlane as _;
// alice, the gunnar-admin, creates bob. This is the second step of
// the model and it is the ORDINARY verb, not a bootstrap path.
plane
.add_principal("bob", "bob", gunnar_proto::PrincipalKind::User)
.expect("a gunnar-admin creates a user");
plane
.add_key("bob", BOB_PUB, "", None, "", None)
.expect("key");
}
let control: Arc<dyn gunnar_grpc::ControlPlane> = plane;
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig::default(),
);
assert_eq!(
ctx.config.auth,
AuthMode::Required,
"the escape hatch would serve every call to everybody and make this whole test vacuous"
);
let access = gunnar_grpc::AccessService::new(ctx);
let as_caller = |id: &str, message: gunnar_proto::AddPrincipalRequest| {
let mut request = tonic::Request::new(message);
request.extensions_mut().insert(Caller {
principal_id: id.to_owned(),
fingerprint: "SHA256:test".to_owned(),
machine: false,
authenticated: true,
});
request
};
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
// ── bob is NOT gunnar-admin, so he may not create a user ───────────
let refused = rt
.block_on(access.add_principal(as_caller(
"bob",
gunnar_proto::AddPrincipalRequest {
name: "mallory".into(),
kind: gunnar_proto::PrincipalKind::User as i32,
id: String::new(),
},
)))
.expect_err("an ordinary user must not be able to create users");
assert_eq!(refused.code(), tonic::Code::PermissionDenied);
// …nor hand out gunnar-admin, which is a Server-scoped Admin grant.
let mut grant = tonic::Request::new(gunnar_proto::GrantRequest {
principal_id: "bob".into(),
namespace: String::new(),
repo: String::new(),
level: gunnar_proto::GrantLevel::Admin as i32,
});
grant.extensions_mut().insert(Caller {
principal_id: "bob".to_owned(),
fingerprint: "SHA256:test".to_owned(),
machine: false,
authenticated: true,
});
let refused = rt
.block_on(access.grant(grant))
.expect_err("an ordinary user must not be able to grant gunnar-admin to himself");
assert_eq!(refused.code(), tonic::Code::PermissionDenied);
// ── owning an account does NOT make you a server admin ────────────────
assert!(
fresh
.stores
.auth()
.may_namespace("bob", "bob", Level::Admin)
.expect("acl"),
"bob is admin of his own account…"
);
assert!(
!fresh
.stores
.auth()
.may_server("bob", Level::Admin)
.expect("acl"),
"…and that must NOT leak into gunnar-admin"
);
// ── the other direction: alice succeeds at both ─────────────────────
rt.block_on(access.add_principal(as_caller(
"alice",
gunnar_proto::AddPrincipalRequest {
name: "mallory".into(),
kind: gunnar_proto::PrincipalKind::User as i32,
id: String::new(),
},
)))
.expect("the gunnar-admin may create users");
let mut grant = tonic::Request::new(gunnar_proto::GrantRequest {
principal_id: "bob".into(),
namespace: String::new(),
repo: String::new(),
level: gunnar_proto::GrantLevel::Admin as i32,
});
grant.extensions_mut().insert(Caller {
principal_id: "alice".to_owned(),
fingerprint: "SHA256:test".to_owned(),
machine: false,
authenticated: true,
});
rt.block_on(access.grant(grant))
.expect("any gunnar-admin may hand gunnar-admin to anybody");
assert!(
fresh
.stores
.auth()
.may_server("bob", Level::Admin)
.expect("acl"),
"and bob is now gunnar-admin too — it is not a singleton and not the first user's \
private property"
);
fresh.shutdown();
}
/// **The unauthenticated surface is exactly three RPCs, and they are not in
/// the authenticated list.**
///
/// A guard on the shape rather than on a comment: adding a door means editing
/// `UNAUTHENTICATED_METHODS`, which is where the reason for each is written
/// down — and this list, in the same commit.
#[test]
fn exactly_three_rpcs_are_served_without_a_credential() {
let open: std::collections::BTreeSet<_> = gunnar_grpc::UNAUTHENTICATED_METHODS
.iter()
.copied()
.collect();
assert_eq!(
open,
// `GrowStatus` (2026-09-14): the runtime data-set growth's phase, for
// the actor OUTSIDE the box that owns the volumes and has nothing to
// sign with while /data is unmounted (T6 in DATA-SET-GROWTH-FLOW.md).
//
// `TwinCsr` / `InstallTwinCertificate` (2026-09-21, lane T9): a twin from
// the one medium holds nothing to present when it is paired, exactly as
// `BecomeTwin`'s box does. Guarded in the seam instead — refused unless
// the box IS a twin, the certificate is for its own key, and a pinned
// issuer is never swapped. Reasons in `auth.rs`, beside the list.
[
"Bootstrap.Enroll",
"Bootstrap.Status",
"Bootstrap.GrowStatus",
"Bootstrap.BecomeTwin",
"Bootstrap.Restore",
"Bootstrap.AssignTunnelAddress",
"Bootstrap.TwinCsr",
"Bootstrap.InstallTwinCertificate",
]
.into_iter()
.collect::<std::collections::BTreeSet<_>>(),
"the unauthenticated surface changed; every entry is a door onto an unenrolled server \
and needs its own reason in auth.rs"
);
let audited = gunnar_grpc::auth::audited_methods();
assert!(
audited.is_disjoint(&open),
"a method cannot be both audited-as-authenticated and served without a credential: {:?}",
audited.intersection(&open).collect::<Vec<_>>()
);
assert!(
open.iter().all(|m| m.starts_with("Bootstrap.")),
"only the Bootstrap service may be unauthenticated"
);
}
/// ★ **A wrong clock is diagnosable without a credential.** The appliance
/// booted two hours fast on 2026-09-14 and refused every credential as stale —
/// `Admin.Health` included — so the only call that could say which clock was
/// wrong is the one that needs no credential. It must carry the server's own
/// clock and the last sync report, over the wire, to a caller holding nothing.
#[test]
fn bootstrap_status_serves_the_server_clock_and_the_last_clock_sync_without_a_credential() {
use estate_clock::report::{Outcome, Report, SourceReport, Trigger};
let dir = tempfile::tempdir().expect("a temp dir");
let server = Fresh::start(dir.path());
estate_clock::report::record(Report {
trigger: Trigger::Boot,
at_unix_ms: estate_clock::now_unix_ms(),
outcome: Outcome::NoQuorum,
offset_ms: 0,
detail: "clock unchanged: 0 usable answer(s) from 1 configured source(s), 1 needed".into(),
sources: vec![SourceReport {
source: "peer://10.13.8.99:50123".into(),
offset_ms: None,
error: Some("peer://10.13.8.99:50123 did not accept a connection: timed out".into()),
}],
});
let before = estate_clock::now_unix_ms();
let status = server.status();
let after = estate_clock::now_unix_ms();
assert!(
(before - 1_000..=after + 1_000).contains(&status.server_unix_ms),
"server_unix_ms must be the server's clock at answer time: {} not in {before}..{after}",
status.server_unix_ms
);
let sync = status
.clock_last
.expect("the last clock report rides the credential-free call");
assert_eq!(
(sync.trigger.as_str(), sync.outcome.as_str()),
("boot", "no-quorum")
);
assert_eq!(sync.sources.len(), 1);
assert!(!sync.sources[0].answered);
assert!(sync.sources[0]
.error
.contains("did not accept a connection"));
server.shutdown();
}
// ───────────────────────────────────────────────────────────────────────────
// WHO answers: the build stamp and the role, without a credential
// ───────────────────────────────────────────────────────────────────────────
/// A twin binds its forge listener to loopback and so has no SSH banner to
/// name its build. The installer door (`--grpc-only`) and the re-image verify
/// (`--verify-control`) read the build and the role off `Bootstrap.Status`
/// instead — red-first: before the fields existed both read as empty.
#[test]
fn status_names_the_build_and_the_role_with_no_credential() {
let dir = tempfile::tempdir().expect("a temp dir");
let primary = Fresh::start(&dir.path().join("primary"));
let s = primary.status();
assert_eq!(
s.build,
gunnar_cli::BUILD_REF,
"the banner's own stamp, not a second one"
);
assert!(!s.build.is_empty());
assert_eq!(s.role, "primary");
primary.shutdown();
let twin = Fresh::start_as(&dir.path().join("twin"), true);
let t = twin.status();
assert_eq!(
t.role, "twin",
"a --role twin box says so BEFORE the join, when twin.json does not exist yet"
);
assert_eq!(t.build, gunnar_cli::BUILD_REF);
twin.shutdown();
}
/// ★ **A FRESH box hands the twin election its hardware facts without a credential; an
/// enrolled box does not** (T17, 2026-09-21 — T15's TD8).
///
/// `xtask mock elect twin` stopped with `elect/box-silent`: the facts it ranks by live on
/// `Admin.Capacity`, which is authenticated, and a fresh box accepts nobody's key. Enrolling
/// an admin first is not a way round it — `BecomeTwin` requires a FRESH box. So while the
/// bootstrap window is open, `Bootstrap.Status` carries `facts`: cores, RAM, the data root's
/// size and the start-up disk test with its honesty flag — read-only, nothing that names a
/// tenant, and the SAME numbers `Admin.Capacity` answers (one source). Once the window shuts
/// they are withheld and `facts_detail` sends the caller to `Admin.Capacity`.
///
/// **Fails before the fix**: `BootstrapStatusResponse` has no `facts` / `facts_detail`.
#[test]
fn a_fresh_box_answers_the_election_facts_unauthenticated_and_an_enrolled_box_does_not() {
let dir = tempfile::tempdir().expect("a temp dir");
let fresh = Fresh::start(&dir.path().join("data"));
let before = fresh.status();
assert!(before.window_open);
let facts = before.facts.as_ref().unwrap_or_else(|| {
panic!(
"a fresh box must hand out its facts: {:?}",
before.facts_detail
)
});
let cpus = std::thread::available_parallelism()
.expect("this test box has CPUs")
.get() as u64;
assert_eq!(
facts.cpu_millicores_total,
Some(cpus * 1000),
"the same cores Admin.Capacity counts"
);
assert!(
facts.ram_bytes_total.is_some_and(|b| b > 0),
"RAM measured: {}",
facts.machine_reason
);
assert!(
facts.data_total_bytes.is_some_and(|b| b > 0),
"the data root's size, never a 0 for unknown"
);
// No start-up disk test runs outside the appliance's PID 1: the flag says so by name,
// and no speed field pretends to be a measurement.
assert_eq!(
facts.disk_test,
gunnar_proto::DiskTest::DisktestNotRun as i32,
"{}",
facts.disk_reason
);
assert_eq!(
(
facts.disk_seq_write_bps,
facts.disk_seq_read_bps,
facts.disk_sync_milliops_per_s
),
(None, None, None)
);
assert!(!facts.disk_reason.is_empty() && !facts.machine_reason.is_empty());
assert!(
before.facts_detail.contains("fresh"),
"{}",
before.facts_detail
);
fresh
.enroll("alice", ALICE_PUB)
.expect("the first enrolment needs no credential");
let after = fresh.status();
assert!(!after.window_open);
assert!(
after.facts.is_none(),
"an ENROLLED box must not hand out its machine without a credential"
);
assert!(
after.facts_detail.contains("Admin.Capacity"),
"and says where they are: {}",
after.facts_detail
);
fresh.shutdown();
}
/// **`serving_git` is a reading of the git listener, and a twin has none.**
///
/// 🔴 MEASURED 2026-09-20 on the live estate, and it is the eighth health line
/// of this shape found in a day: `gunnar control status --at 10.13.8.101:50051`
/// answered
///
/// ```text
/// build v1.4.0-362-g7e15069
/// role twin
/// git serving
/// ```
///
/// while every replication push to that box failed
/// `ssh connect 10.13.8.101:2222 … Connection refused (os error 111)` — an RST,
/// so nothing was bound there at all. The field was wired to
/// `readiness::is_ready()`, a **boot latch** that starts `true` and is closed
/// only by a boot task that opens a window: a twin opens none, so the flag was
/// `true` from its first instruction to its last and could not have said
/// anything else. The listener itself was never consulted.
///
/// A twin does not serve git ON PURPOSE (`serve.rs`: "**No forge listener on a
/// twin.** It serves no git to anyone: a read would grow the caches the twin
/// must not hold"), so this assertion is not that the twin is broken — it is
/// that the status door must say what the twin IS.
#[test]
fn serving_git_reports_the_listener_and_a_twin_reports_no() {
let dir = tempfile::tempdir().expect("a temp dir");
// A free port to ASK for, so "what was asked" and "what was taken" are
// different questions. The listener is dropped before the servers start.
let port = {
let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port");
probe.local_addr().expect("its address").port()
};
let asked = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let primary = Fresh::start_on(&dir.path().join("primary"), false, Some(asked));
let s = primary.status();
assert_eq!(s.role, "primary");
assert_eq!(
primary.ssh_port, port,
"a primary takes the port it was asked for"
);
assert!(
s.serving_git,
"a primary whose listener is on the asked-for port serves git: {}",
s.readiness
);
assert!(s.readiness.contains("serving git on"), "{}", s.readiness);
primary.shutdown();
let twin = Fresh::start_on(&dir.path().join("twin"), true, Some(asked));
let t = twin.status();
assert_eq!(t.role, "twin");
assert_ne!(
twin.ssh_port, port,
"serve.rs binds a twin's git listener to loopback:0 whatever was asked for"
);
assert!(
!t.serving_git,
"THE DEFECT: the twin said `git serving` while its git port refused every connection"
);
assert!(
t.readiness.contains("NOT serving git") && t.readiness.contains("TWIN"),
"the twin must say what it is, not merely no: {}",
t.readiness
);
assert!(
!t.readiness.contains("starting"),
"a twin is not STARTING, it is never going to serve git: {}",
t.readiness
);
twin.shutdown();
}