Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-grpc/src/service/admin.rs

//! `Admin`. Health, stats, storage info, and the capacity question.
//!
//! `Health` reports the v1 auth hole **on the wire**: `grpc_authentication_
//! enabled` is `false` and `grpc_bind` says exactly where the unauthenticated
//! surface is listening. A hole documented only in prose is a hole nobody
//! checks; a field a UI can render is one somebody notices.

use gunnar_proto::admin_server::Admin;
use gunnar_proto::{
    CapacityRequest, CapacityResponse, GrowResumeRequest, GrowStartRequest, GrowStatusRequest,
    GrowStatusResponse, HealthRequest, HealthResponse, IndexProgress, RepoPath, StatsRequest,
    StatsResponse, StorageInfoRequest, StorageInfoResponse, VerifyChainRequest,
    VerifyChainResponse,
};
use tonic::{Request, Response, Status};

use crate::auth::Access;
use crate::service::AdminService;
use crate::status::{opt_repo_path, repo_path};
use gunnar_proto::GrantLevel;

#[tonic::async_trait]
impl Admin for AdminService {
    async fn health(
        &self,
        _request: Request<HealthRequest>,
    ) -> Result<Response<HealthResponse>, Status> {
        // Health is authenticated too. It reports the bind addresses of both
        // listeners and whether authentication is on, which is precisely the
        // reconnaissance an unauthenticated probe wants, and a liveness check
        // on a loopback control plane can hold a key like anything else.
        self.ctx
            .authorize(&_request, Access::Server, GrantLevel::Read, "Admin.Health")?;
        // Taken once, so every field below describes the same instant. Two
        // `report()` calls could straddle an incoming v1 credential and
        // report a count that no roster explains.
        let unbound = self.ctx.unbound.report();
        Ok(Response::new(HealthResponse {
            serving: true,
            agent: gunnar_proto::AGENT.to_string(),
            // ★ ACCT-2: the Gunnar RELEASE (the workspace's version, `v1.7.0`), never this
            // crate's own semver (4.0.0 since Gunnar 1.7.0), which the console's
            // header showed as "Gunnar 2.0.0".
            version: gunnar_common::RELEASE.to_string(),
            // **What is actually running**, beside the crate version that is
            // pinned in a manifest. `gunnar control status` has printed this as
            // `build` since it existed; Health never carried it, so every
            // consumer that had only this message — the console's header band
            // among them — could show `1.5.0` for a box 228 commits past the
            // tag and be believed. `unknown` is normalised to empty so a reader
            // gets "not stamped" rather than a word that looks like a ref.
            build_ref: match gunnar_common::BUILD_REF {
                "unknown" => String::new(),
                stamped => stamped.to_string(),
            },
            uptime_seconds: self.ctx.started.elapsed().as_secs(),
            // The RUNTIME answer, not the build-time constant: a build that
            // has authentication and a listener serving allow-all are
            // different facts, and an operator needs the second one.
            grpc_authentication_enabled: self.ctx.config.authentication_enabled(),
            grpc_bind: self.ctx.config.bind.to_string(),
            ssh_bind: self.ctx.config.ssh_bind_hint.clone(),
            grpc_mutual_tls: self.ctx.config.mutual_tls,
            // Every gate refuses the method-blind credential (5.3.3); only a
            // listener with no gate at all (allow-all) does not.
            grpc_require_bound_credentials: self.ctx.config.authentication_enabled(),
            // Read out of the SAME log the gate writes — `router_with_trust`
            // hands the gate this context's `Arc`. A second counter here
            // would be a second opinion about which clients still sign v1.
            unbound_credentials_accepted: unbound.accepted,
            unbound_credentials_refused: unbound.refused,
            unbound_credential_last_seen_unix_ms: unbound.last_seen_unix_ms,
            unbound_credential_callers_dropped: unbound.distinct_dropped,
            unbound_credential_callers: unbound
                .callers
                .into_iter()
                .map(|row| gunnar_proto::UnboundCredential {
                    fingerprint: row.fingerprint,
                    principal_id: row.principal_id,
                    method: row.method,
                    first_seen_unix_ms: row.first_seen_unix_ms,
                    last_seen_unix_ms: row.last_seen_unix_ms,
                    count: row.count,
                    refused: row.refused,
                })
                .collect(),
            server_unix_ms: estate_clock::now_unix_ms(),
            clock_boot: estate_clock::report::boot().map(clock_sync),
            clock_last: estate_clock::report::last().map(clock_sync),
            clock_skew_syncs_suppressed: estate_clock::trigger::suppressed(),
            // Read off the disk at each call: the journal is the boot stack's
            // and changes only across a boot, so there is nothing to cache.
            grow_state: crate::grow_journal::Summary::measure().map(|s| s.to_wire()),
            twins: self.twin_rows(),
            primary: self
                .ctx
                .control
                .primary_heartbeat(self.ctx.heartbeats.bounds().silent_after_ms),
            alarms: self.ctx.control.health_alarms(),
            status: if self.ctx.control.maintenance_reasons().is_empty() {
                gunnar_proto::HEALTH_SERVING.to_string()
            } else {
                gunnar_proto::HEALTH_MAINTENANCE.to_string()
            },
        }))
    }

    async fn stats(
        &self,
        _request: Request<StatsRequest>,
    ) -> Result<Response<StatsResponse>, Status> {
        self.ctx
            .authorize(&_request, Access::Server, GrantLevel::Read, "Admin.Stats")?;
        let counts = self.ctx.control.counts()?;
        let extras = self.ctx.control.stats_extras()?;
        let bus = self.ctx.bus.stats();
        let index = self
            .ctx
            .control
            .index_progress()?
            .into_iter()
            .map(|r| IndexProgress {
                repo: Some(RepoPath::new(&r.namespace, &r.name)),
                pack_id: r.pack_id,
                objects_done: r.objects_done,
                objects_total: r.objects_total,
                bytes_done: r.bytes_done,
                bytes_total: r.bytes_total,
                phase: r.phase.clone(),
                started_at_unix_ms: r.started_at_unix_ms,
                resumed: r.resumed,
                percent: r.percent(),
                reinflations: r.reinflations,
                pauses: r.pauses,
            })
            .collect();
        Ok(Response::new(StatsResponse {
            namespaces: counts.namespaces,
            repos: counts.repos,
            principals: counts.principals,
            keys: counts.keys,
            grants: counts.grants,
            event_subscribers: bus.subscribers,
            events_published: bus.published,
            events_dropped: bus.dropped,
            event_buffer_capacity: bus.capacity,
            index,
            tombstoned_repos: extras.tombstoned_repos,
            tombstoned_bytes: extras.tombstoned_bytes,
            replication_configured: extras.replication_configured,
            replication_synced: extras.replication_synced,
            replication_failed: extras.replication_failed,
            replication_enqueued: extras.replication_enqueued,
            replication_bus_gaps: extras.replication_bus_gaps,
            replication_refs_total: extras.replication_refs_total,
            replication_refs_unmirrored: extras.replication_refs_unmirrored,
            standbys: extras.standbys,
            tenants_paid: extras.tenants_paid,
            tenants_grace: extras.tenants_grace,
            tenants_suspended: extras.tenants_suspended,
        }))
    }

    async fn storage_info(
        &self,
        request: Request<StorageInfoRequest>,
    ) -> Result<Response<StorageInfoResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let path = opt_repo_path(request.into_inner().repo, "repo")?;
        // A whole-server storage report spans tenants; a single repo's does
        // not.
        let access = match &path {
            Some(path) => Access::repo(path),
            None => Access::Server,
        };
        self.ctx
            .require(&caller, access, GrantLevel::Read, "Admin.StorageInfo")?;
        let info = self.ctx.control.storage_info(path.as_ref())?;
        Ok(Response::new(info))
    }

    async fn verify_chain(
        &self,
        request: Request<VerifyChainRequest>,
    ) -> Result<Response<VerifyChainResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let inner = request.into_inner();
        // A question about one repository is that repository's business.
        let path = repo_path(inner.repo.clone(), "repo")?;
        self.ctx.require(
            &caller,
            Access::repo(&path),
            GrantLevel::Read,
            "Admin.VerifyChain",
        )?;
        let chain = self.ctx.control.verify_chain(&inner)?;
        Ok(Response::new(chain))
    }

    /// **"If a tenant's cap rose to N bytes, could you serve it?"**
    ///
    /// Server-wide Read, the same as `Stats` and a repo-less `StorageInfo`:
    /// the answer is about the whole filesystem every tenant shares, so it is
    /// not one namespace's business even when a namespace is named — a
    /// namespace-scoped grant here would let one tenant read the box's free
    /// space by asking about itself.
    async fn capacity(
        &self,
        request: Request<CapacityRequest>,
    ) -> Result<Response<CapacityResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require(&caller, Access::Server, GrantLevel::Read, "Admin.Capacity")?;
        let answer = self.ctx.control.capacity(&request.into_inner())?;
        Ok(Response::new(answer))
    }

    // ── the runtime data-set growth (`DATA-SET-GROWTH-FLOW.md` §3) ────────

    /// **Begin a growth.** gunnar-admin: it drains the box, unmounts `/data`
    /// and hands the members to an outside actor — the one verb here whose
    /// failure mode is minutes of refused pushes (T12), so it is held to the
    /// highest level and to a go-ahead the control plane verifies (T14).
    async fn grow_start(
        &self,
        request: Request<GrowStartRequest>,
    ) -> Result<Response<GrowStatusResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx.require(
            &caller,
            Access::Server,
            GrantLevel::Admin,
            "Admin.GrowStart",
        )?;
        let request = request.into_inner();
        if request.go_ahead.is_empty() {
            return Err(Status::invalid_argument(
                "`go_ahead` is required: the signed go-ahead the growth is started on (D3). \
                 `gunnar control grow start` mints an operator one",
            ));
        }
        Ok(Response::new(self.ctx.control.grow_start(&request)?))
    }

    /// The growth's phase, with a credential. The SAME answer is served
    /// without one on `Bootstrap.GrowStatus` (see there for why); this arm
    /// exists so a credentialled operator script does not have to know that.
    async fn grow_status(
        &self,
        request: Request<GrowStatusRequest>,
    ) -> Result<Response<GrowStatusResponse>, Status> {
        self.ctx.authorize(
            &request,
            Access::Server,
            GrantLevel::Read,
            "Admin.GrowStatus",
        )?;
        Ok(Response::new(self.ctx.control.grow_status()?))
    }

    /// **Look again** — the outside actor says the members are back and
    /// bigger; or, with `give_up`, the operator says stop waiting and serve
    /// what is there. gunnar-admin, like `GrowStart`.
    async fn grow_resume(
        &self,
        request: Request<GrowResumeRequest>,
    ) -> Result<Response<GrowStatusResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx.require(
            &caller,
            Access::Server,
            GrantLevel::Admin,
            "Admin.GrowResume",
        )?;
        Ok(Response::new(
            self.ctx.control.grow_resume(request.into_inner().give_up)?,
        ))
    }

    /// **The tunnel address of a box past its bootstrap** (2026-09-21, T17).
    /// Server-wide Admin: the address decides what the hub routes to this
    /// box, the bar `GrowStart` takes for the same kind of reason. A READ
    /// (empty address) is held to the same bar — the unauthenticated read is
    /// `Bootstrap.AssignTunnelAddress` with an empty address.
    async fn set_tunnel_address(
        &self,
        request: Request<gunnar_proto::AssignTunnelAddressRequest>,
    ) -> Result<Response<gunnar_proto::AssignTunnelAddressResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx.require(
            &caller,
            Access::Server,
            GrantLevel::Admin,
            "Admin.SetTunnelAddress",
        )?;
        let address = request.into_inner().address;
        let answer = self.ctx.control.set_tunnel_address(address.trim())?;
        if answer.changed {
            tracing::warn!(target: crate::auth::AUDIT_TARGET, event = "grpc.call", method = "Admin.SetTunnelAddress", address = %answer.address, wg_public_key = %answer.wg_public_key, tunnel_up = answer.tunnel_up, decision = "allow", "an enrolled box was given its tunnel address: register its key on the hub at this address");
        }
        Ok(Response::new(answer))
    }

    /// **Which CA does this box mint with** (2026-09-17).
    ///
    /// # Why this verb exists at all
    ///
    /// `gunnar issuer show` answers this from the data root, and the appliance
    /// has no shell — that is the product's whole shape. The issuer
    /// certificate went out over exactly one RPC, `Bootstrap.Enroll`, and that
    /// window closes permanently on the first enrol. So on a RESTORED box, the
    /// one place the question actually bites, the certificate every client
    /// pins was unreadable from outside by any means at all. The first-start
    /// log line even tells the operator to run `gunnar issuer show --pem` on a
    /// machine where there is nothing to run it with.
    ///
    /// # The access level, and the one that was rejected
    ///
    /// **Server-wide `Read`** — the bar `Health`, `Stats` and `StorageInfo`
    /// take, and for their reason.
    ///
    /// The tempting argument is that a CA certificate is public by
    /// construction: every client holds a copy, it is presented in every
    /// handshake, and its secrecy protects nothing. True of the certificate —
    /// and this answer is more than the certificate. It enumerates the trusted
    /// CA list, says whether the key was minted here or IMPORTED (so whether a
    /// copy exists elsewhere), and dates the adoption: a rotation in progress
    /// is legible in it. That is reconnaissance of exactly the kind
    /// `Admin.Health` is authenticated for — it reports where the listeners
    /// are — and the same answer applies: a caller who cannot present any
    /// credential has no business enumerating this box's PKI state.
    ///
    /// So **`UNAUTHENTICATED_METHODS` was rejected**, and deliberately: that
    /// list has one reason, and it is that the identity table cannot answer
    /// during first boot or a data-set growth. Nothing here needs that. A
    /// restored box has a working identity table BY DEFINITION — restoring it
    /// is what the restore did — so the operator asking this question is an
    /// operator who can sign for it.
    ///
    /// **`Admin` was rejected too**, in the other direction. This is a read
    /// with no side effect on a document whose payload is public; putting it
    /// behind the level that mints principals would mean the person verifying
    /// a pin must hold the credential that can rewrite the ACL, which is a
    /// worse outcome than the one it guards against. `Read` at `Server` scope
    /// is the level for a server-wide fact, and this is one.
    async fn issuer_info(
        &self,
        request: Request<gunnar_proto::IssuerInfoRequest>,
    ) -> Result<Response<gunnar_proto::IssuerInfoResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx.require(
            &caller,
            Access::Server,
            GrantLevel::Read,
            "Admin.IssuerInfo",
        )?;
        // Read-only all the way down: the seam's one implementation opens
        // `ServerStores::pki_readonly()`, whose secret store refuses every
        // call. There is no branch here that could mint or rotate, and there
        // is no value in scope that could carry a private key.
        Ok(Response::new(self.ctx.control.issuer_info()?))
    }

    /// **`Admin.GetSetting`** (owner decision 2026-10-26): a generic server setting, bytes
    /// under a name the CLIENT chooses (a console's own switches, `console.<switch>`). The
    /// server knows no setting name. Server-wide `Admin`: the console that keeps its switches
    /// here holds it, and a setting is nobody else's business.
    async fn get_setting(
        &self,
        request: Request<gunnar_proto::GetSettingRequest>,
    ) -> Result<Response<gunnar_proto::GetSettingResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx.require(
            &caller,
            Access::Server,
            GrantLevel::Admin,
            "Admin.GetSetting",
        )?;
        let name = request.into_inner().name;
        let value = self.ctx.control.setting(&name)?;
        Ok(Response::new(gunnar_proto::GetSettingResponse {
            present: value.is_some(),
            value: value.unwrap_or_default(),
        }))
    }

    /// **`Admin.SetSetting`**: store a generic server setting, server-wide `Admin`, bounded
    /// (gunnar-core `settings`: the refusal names the bound). Every write is audited BEFORE it
    /// is applied (RA2-9): who, the name, and old → new (as text where it is UTF-8, else its
    /// SHA-256) go to the hash-chained ledger as a change record (`SETTING <name>: old -> new`),
    /// and a write the store then refuses is recorded as refused.
    async fn set_setting(
        &self,
        request: Request<gunnar_proto::SetSettingRequest>,
    ) -> Result<Response<gunnar_proto::SetSettingResponse>, Status> {
        const METHOD: &str = "Admin.SetSetting";
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require(&caller, Access::Server, GrantLevel::Admin, METHOD)?;
        let gunnar_proto::SetSettingRequest { name, value } = request.into_inner();
        // The name's own bound is refused here, before anything is recorded as a change.
        let current = self.ctx.control.setting(&name)?;
        let old = current
            .as_deref()
            .map(setting_words)
            .unwrap_or_else(|| "(unset)".to_owned());
        // RA4-8: no durable record, no change (refused by name, audit.unavailable).
        self.ctx.audit_change_durable(
            &caller,
            Access::Server,
            GrantLevel::Admin,
            METHOD,
            &format!("SETTING {name}: {old} -> {}", setting_words(&value)),
        )?;
        let previous = match self.ctx.control.set_setting(&name, &value) {
            Ok(previous) => previous,
            Err(e) => {
                let status = Status::from(e);
                self.ctx.audit_change(
                    &caller,
                    Access::Server,
                    GrantLevel::Admin,
                    METHOD,
                    &format!("SETTING {name}: refused: {}", status.message()),
                );
                return Err(status);
            }
        };
        tracing::warn!(
            target: crate::auth::AUDIT_TARGET,
            event = "admin.setting_set",
            principal = %caller.principal_id,
            name = %name,
            old = %previous.as_deref().map(setting_words).unwrap_or_else(|| "(unset)".to_owned()),
            new = %setting_words(&value),
            "a server setting was written"
        );
        Ok(Response::new(gunnar_proto::SetSettingResponse {
            had: previous.is_some(),
            previous: previous.unwrap_or_default(),
        }))
    }

    /// **`Admin.AuditTrail`** (lane T11): the audit chain's shape, so that "is
    /// the record still complete, and is it the same record" can be asked of a
    /// running box — in particular of a RESTORED one, which is the only place
    /// the question has ever really mattered.
    ///
    /// `Read` at `Server` scope, the level for a server-wide fact, and the
    /// same level as `Health` and `IssuerInfo`. It is deliberately not higher:
    /// the person checking that a restore kept the trail is exactly the person
    /// who must not have to hold a credential that can rewrite the ACL. It is
    /// deliberately not lower either — the response is a count and a hash and
    /// carries no entry, but who-audits-whom is still a server-wide fact.
    ///
    /// **This call is itself audited**, like every other authorised RPC, so
    /// the tip it reports is the tip as of the decision that let it read —
    /// which is what makes two boxes comparable at a named `at_seq` and not at
    /// a tip that the act of asking has already moved.
    async fn audit_trail(
        &self,
        request: Request<gunnar_proto::AuditTrailRequest>,
    ) -> Result<Response<gunnar_proto::AuditTrailResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx.require(
            &caller,
            Access::Server,
            GrantLevel::Read,
            "Admin.AuditTrail",
        )?;
        let at_seq = request.into_inner().at_seq;
        Ok(Response::new(self.ctx.control.audit_trail(at_seq)?))
    }

    /// **`Admin.Topology`** (2026-10-04): the estate's boxes, as this gunnar
    /// knows them — see [`crate::topology`], where every decision is.
    ///
    /// Server-wide `Read`, the level of `Health`, whose twin rows it reuses:
    /// the rows name addresses and principals, never a secret. A MACHINE that
    /// asks is marked in the caller log as one that draws the picture — a
    /// console — so the console that is asking is on the picture it gets.
    async fn topology(
        &self,
        request: Request<gunnar_proto::TopologyRequest>,
    ) -> Result<Response<gunnar_proto::TopologyResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require(&caller, Access::Server, GrantLevel::Read, "Admin.Topology")?;
        let now = estate_clock::now_unix_ms();
        if caller.machine && caller.authenticated {
            let address = request
                .remote_addr()
                .map(|a| a.to_string())
                .unwrap_or_default();
            self.ctx
                .callers
                .note(&caller.principal_id, &address, now, true);
        }
        let silent_after_ms = self.ctx.heartbeats.bounds().silent_after_ms;
        // A plane that keeps no replication counters still has a topology:
        // its standbys are simply none.
        let standbys = self
            .ctx
            .control
            .stats_extras()
            .map(|e| e.standbys)
            .unwrap_or_default();
        let mut facts = self.ctx.control.topology_facts();
        if facts.address.is_empty() {
            // Unpaired: where this listener is bound is where this box is.
            facts.address = self.ctx.config.bind.to_string();
        }
        let input = crate::topology::Input {
            facts,
            is_twin: self.ctx.control.is_twin(),
            twins: self.twin_rows(),
            primary: self.ctx.control.primary_heartbeat(silent_after_ms),
            standbys,
            callers: self.ctx.callers.sightings(),
            now_ms: now,
            silent_after_ms,
        };
        Ok(Response::new(crate::topology::assemble(&input)))
    }

    /// **Sign a twin's replication certificate** (lane T9).
    ///
    /// Server-wide `Admin`, deliberately the level that mints principals and
    /// not the `Read` its neighbour `IssuerInfo` takes: this verb puts a
    /// credential into the stream that carries every sealed secret on the box.
    /// The seam adds the two refusals that are about THIS box rather than the
    /// caller — no mutual replication listener, principal not in group `twin`.
    async fn issue_twin_certificate(
        &self,
        request: Request<gunnar_proto::IssueTwinCertificateRequest>,
    ) -> Result<Response<gunnar_proto::IssueTwinCertificateResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx.require(
            &caller,
            Access::Server,
            GrantLevel::Admin,
            "Admin.IssueTwinCertificate",
        )?;
        let r = request.into_inner();
        let (cert_pem, issuer_pem, port, host) = self
            .ctx
            .control
            .issue_twin_certificate(&r.principal, &r.csr_pem)?;
        tracing::warn!(
            target: crate::auth::AUDIT_TARGET,
            event = "grpc.call",
            method = "Admin.IssueTwinCertificate",
            principal = %caller.principal_id,
            twin = %r.principal,
            replication_port = port,
            replication_host = %host,
            decision = "allow",
            "a twin replication certificate was issued and registered"
        );
        Ok(Response::new(gunnar_proto::IssueTwinCertificateResponse {
            cert_pem,
            issuer_pem,
            replication_port: u32::from(port),
            replication_host: host,
        }))
    }
}

/// A clock-sync report as it goes on the wire. One conversion, used by both
/// `Admin.Health` and `Bootstrap.Status`, so the two can never describe the
/// same attempt differently.
pub(crate) fn clock_sync(report: estate_clock::Report) -> gunnar_proto::ClockSync {
    gunnar_proto::ClockSync {
        trigger: report.trigger.as_str().to_owned(),
        at_unix_ms: report.at_unix_ms,
        outcome: report.outcome.as_str().to_owned(),
        offset_ms: report.offset_ms,
        detail: report.detail,
        sources: report
            .sources
            .into_iter()
            .map(|row| gunnar_proto::ClockSource {
                source: row.source,
                answered: row.offset_ms.is_some() && row.error.is_none(),
                offset_ms: row.offset_ms.unwrap_or(0),
                error: row.error.unwrap_or_default(),
            })
            .collect(),
    }
}

impl AdminService {
    /// The primary's twin rows: every member of group `twin` plus every
    /// caller that reported as a twin. A group that cannot be listed (a plane
    /// with no groups, or no `twin` group yet) contributes no names — the
    /// callers that polled still show.
    fn twin_rows(&self) -> Vec<gunnar_proto::TwinHeartbeat> {
        let members: Vec<String> = self
            .ctx
            .control
            .list_group_members(crate::TWIN_GROUP)
            .map(|ps| ps.into_iter().map(|p| p.id.to_string()).collect())
            .unwrap_or_default();
        self.ctx
            .heartbeats
            .view(&members, estate_clock::now_unix_ms())
    }
}

#[cfg(test)]
mod clock_tests {
    use super::*;
    use estate_clock::report::{Outcome, Report, SourceReport, Trigger};

    #[test]
    fn a_failed_source_goes_on_the_wire_as_not_answered_with_its_reason() {
        let wire = clock_sync(Report {
            trigger: Trigger::Boot,
            at_unix_ms: 1_789_394_521_628,
            outcome: Outcome::NoQuorum,
            offset_ms: 0,
            detail: "clock unchanged".into(),
            sources: vec![
                SourceReport {
                    source: "peer://10.13.8.99:50123".into(),
                    offset_ms: None,
                    error: Some("did not accept a connection".into()),
                },
                SourceReport {
                    source: "https://api.stripe.com/".into(),
                    offset_ms: Some(-7_199_500),
                    error: None,
                },
            ],
        });
        assert_eq!(
            (wire.trigger.as_str(), wire.outcome.as_str()),
            ("boot", "no-quorum")
        );
        assert!(!wire.sources[0].answered);
        assert_eq!(wire.sources[0].error, "did not accept a connection");
        assert!(wire.sources[1].answered);
        assert_eq!(wire.sources[1].offset_ms, -7_199_500);
    }
}

/// The longest value an audit line quotes as text; a longer one is named by its hash.
pub(crate) const SETTING_WORDS_MAX: usize = 128;

/// Pure: **a setting's value for the audit line**: the text where it is UTF-8 with no control
/// character and at most [`SETTING_WORDS_MAX`] bytes (quoted), else `sha256:<hex>` — never raw
/// bytes in a log, and never 4 KiB of text in the audit chain.
pub(crate) fn setting_words(value: &[u8]) -> String {
    match std::str::from_utf8(value) {
        Ok(text) if value.len() <= SETTING_WORDS_MAX && !text.chars().any(char::is_control) => {
            format!("{text:?}")
        }
        _ => {
            use sha2::Digest as _;
            let digest = sha2::Sha256::digest(value);
            format!(
                "sha256:{}",
                digest
                    .iter()
                    .map(|b| format!("{b:02x}"))
                    .collect::<String>()
            )
        }
    }
}

#[cfg(test)]
mod setting_tests {
    #[test]
    fn a_settings_value_is_audited_as_text_or_as_its_hash() {
        assert_eq!(super::setting_words(b"on"), "\"on\"");
        assert_eq!(super::setting_words(b""), "\"\"");
        let hashed = super::setting_words(&[0xff, 0x00]);
        assert!(
            hashed.starts_with("sha256:") && hashed.len() == 7 + 64,
            "{hashed}"
        );
        assert!(
            super::setting_words(b"a\nb").starts_with("sha256:"),
            "no line break reaches the log"
        );
        assert_eq!(
            super::setting_words(&[b'x'; 128]),
            format!("{:?}", "x".repeat(128))
        );
        assert!(
            super::setting_words(&[b'x'; 129]).starts_with("sha256:"),
            "a long value is named by its hash"
        );
    }
}