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

nordisk/gunnar-ui

crates/gunnar-ui-server/tests/contract.rs

//! The drift alarms.
//!
//! Two things in this repo are copies of something that lives somewhere else:
//! gunnar's nine protos, and the handful of constants that make its credential
//! verify. Copies rot silently. Every test here exists because the failure it
//! catches would otherwise show up as a runtime refusal that names neither the
//! copy nor the original.
//!
//! The file lives under the package rather than at the repository root on
//! purpose: the root manifest is a virtual workspace, and cargo never builds a
//! `tests/` directory that does not belong to a package. A contract test that
//! is never compiled is worse than none, because CI stays green while it does
//! nothing.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

/// Where the vendored protos are, relative to this package.
fn proto_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("proto/gunnar/v1")
}

/// gunnar's own checkout: `$GUNNAR_CHECKOUT` when set, else the one beside
/// this repo.
///
/// **A set `GUNNAR_CHECKOUT` cannot skip.** The drift guards below step aside
/// when there is no checkout to compare against — CI clones one repository —
/// and on 2026-09-05 that step-aside swallowed a real drift: the sibling
/// `../gunnar` was a stale worktree at an older commit, and the guard
/// compared against it, green, while the protos here had moved past it. A
/// caller that names its checkout is asking for the comparison, so a wrong name
/// is a failure with the path in it and not a silent pass.
fn gunnar_checkout() -> Option<PathBuf> {
    if let Some(named) = std::env::var_os("GUNNAR_CHECKOUT") {
        let named = PathBuf::from(named);
        assert!(
            named.join("crates/gunnar-proto/proto/gunnar/v1").is_dir(),
            "GUNNAR_CHECKOUT={} is not a gunnar checkout (no crates/gunnar-proto/proto/gunnar/v1 \
             under it); a named checkout that is not there is a failed comparison, not a \
             skipped one",
            named.display()
        );
        return Some(named);
    }
    let candidate = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gunnar");
    candidate.is_dir().then_some(candidate)
}

fn read(path: &Path) -> String {
    std::fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}

/// Every `service` and its `rpc` names, parsed out of the vendored protos.
fn services() -> BTreeMap<String, Vec<String>> {
    let mut found = BTreeMap::new();
    let mut entries: Vec<_> = std::fs::read_dir(proto_dir())
        .expect("the vendored protos are there")
        .filter_map(Result::ok)
        .map(|e| e.path())
        .filter(|p| p.extension().is_some_and(|e| e == "proto"))
        .collect();
    entries.sort();

    for path in entries {
        let text = read(&path);
        let mut current: Option<String> = None;
        for line in text.lines() {
            let line = line.trim();
            if let Some(rest) = line.strip_prefix("service ") {
                let name = rest.split_whitespace().next().unwrap_or_default();
                current = Some(name.to_owned());
                found.entry(name.to_owned()).or_insert_with(Vec::new);
            } else if let Some(rest) = line.strip_prefix("rpc ") {
                let name = rest
                    .split(|c: char| c == '(' || c.is_whitespace())
                    .next()
                    .unwrap_or_default();
                if let Some(service) = &current {
                    found
                        .get_mut(service)
                        .expect("the service was inserted above")
                        .push(name.to_owned());
                }
            }
        }
    }
    found
}

#[test]
fn the_protos_still_describe_sixteen_services_and_a_hundred_and_sixty_six_calls() {
    // Not a style check. Both numbers are load-bearing: `docs/coverage.md`
    // claims to account for every RPC, and a service appearing without anyone
    // noticing would make that claim quietly false — which is exactly what
    // happened to `Forge`: it was not vendored here at all, so the console had
    // no way to record an approval and every merge from the web was refused.
    let services = services();
    let names: Vec<&str> = services.keys().map(String::as_str).collect();
    let rpcs: usize = services.values().map(Vec::len).sum();
    // **Clans** (2026-10-05) are counted on their own — a service of thirteen
    // verbs since round 2 (OfferRepo, AnswerRepoOffer: a repository moved out
    // of a clan), every one used by You → Clans (`docs/coverage.md`,
    // `## Clans`) —
    // and taken out of the list and the total below, so those stay what the
    // rest of the protos make and another service's arrival does not collide
    // with this one's.
    let clans = services.get("Clans").map_or(0, Vec::len);
    assert_eq!(clans, 13, "the Clans service changed shape");
    let names: Vec<&str> = names.into_iter().filter(|name| *name != "Clans").collect();
    let rpcs = rpcs - clans;

    assert_eq!(
        names,
        vec![
            "Access",
            // The per-USER verbs, vendored 2026-09-07. `Repos` acts on a path;
            // this one acts on a name that is at once an identity and a
            // namespace, which is why gunnar gave it a service of its own.
            "Accounts",
            "Admin",
            // Tickets: gunnar vouching that a caller may act for a namespace.
            // Vendored 2026-09-17 for tenant self-service renewal, and the one
            // service here whose ANSWER is meant to leave the box — a customer
            // carries it to monetize, which checks the appliance's signature
            // rather than taking this console's word.
            "Billing",
            "Bootstrap",
            // The owner's enterprise list (2026-10-05): the account admin's
            // audit log, the operator's aggregates and the look the account
            // admin approves, the name filter's lists and review queue —
            // gunnar decides, the console relays (`api::enterprise`).
            "Enterprise",
            // The tenant's own plan — state, `paid_until`, caps — as the
            // appliance holds it. Vendored 2026-09-17 for the account pane:
            // gunnar's `Entitlement.Get` became namespace-scoped, so a
            // customer's own key answers it and gunnar narrows it, which is
            // what lets a console show somebody their renewal date without
            // holding an admin credential to do it with.
            "Entitlement",
            "Events",
            "Forge",
            "Mirror",
            "Policy",
            "PullRequests",
            "Refs",
            "Repos",
            // Company single sign-on (2026-10-05): an account's own identity
            // provider (OIDC, SAML), SCIM and the CSV import — gunnar decides,
            // the console relays (`auth::sso`) and the account admin
            // configures (`api::sso`).
            "Sso",
            "Usage",
        ]
    );
    // 44 → 53 on 2026-08-30, when the console's vendored copy was brought back
    // in step with gunnar: Repos gained Undelete / Purge / ListDeleted (the
    // tombstones), ListOrphans / ReclaimOrphan (the third listing)
    // and ArchiveOut, and Mirror gained SetReplication / RemoveReplication /
    // ReplicationStatus. All nine are additive.
    //
    // 53 → 57 on 2026-08-31: the whole `Forge` service (Append / GetReview /
    // ListReviews), which had never been vendored, plus `Mirror.ResyncReplication`
    // picked up by re-vendoring the three protos that had drifted.
    //
    // 57 → 58 on 2026-08-31, later the same day: `Admin.VerifyChain` — the
    // provenance chain (`gunnar verify`), served, so the console's chain view
    // can exist at all.
    //
    // 58 → 60: `Forge.GetIssue` and `Forge.ListIssues` — issues as signed forge
    // events in the same model as reviews. The issues page folds them the way
    // the pulls page folds a review, and files each action through the existing
    // `Append` signed by the console's key.
    //
    // 60 → 69: the rest of the vendored set was brought back in step with
    // gunnar. `refs.proto` gained `Blame` / `Search` (the walks `gunnar blame` /
    // `gunnar search` run, now over gRPC for the console's blame and search
    // pages); `access.proto` gained the four group-membership RPCs; and
    // `mirror.proto` gained `AbdicateReplication`, `DemoteReplication`,
    // `SetRole`. All nine are additive; only Blame/Search get a console surface,
    // the rest are `not surfaced` in the matrix below.
    //
    // 69 → 71, and 10 services → 11: `usage.proto`, whose `Usage.Read` /
    // `Usage.ReadAll` are the per-namespace meters. gunnar serves them
    // unconditionally — not behind the `metered` feature, deliberately — and
    // the console reads `Read` for its namespace usage page. `ReadAll` is
    // `not used`, with the reason in the matrix.
    //
    // 71 → 72 on 2026-09-05: `Admin.Capacity` — the box's measured disk (free,
    // floor, servable), whether the set can grow, and its cores and RAM. The
    // usage page draws the box line from it. `usage.proto` gained the
    // `Allowance` on `Usage.Read` the same day, which is a message and not an
    // RPC, so it does not count here.
    //
    // 72 → 74 on 2026-09-07: `Repos.Gc` and `Repos.GcStatus`. The verb existed
    // in gunnar-server as a local CLI-only path with no way to reach it from a
    // box whose PID 1 has no shell; it is now on the control plane, enqueued
    // and polled, and the console has a Maintenance panel that calls both.
    // 74 → 79 on 2026-09-07: `accounts.proto`, and 11 services → 12. The
    // per-USER half of what `Repos` does per repository — `Gate` / `Ungate` /
    // `ListAccountGates` lock a user out of everything at once, `Purge` /
    // `PurgeStatus` empty the account for good. `ListAccountGates` is spelt
    // that way and not `ListGates` because `Policy.ListGates` has meant a
    // repository's push gates for longer, and protoc refuses the collision.
    //
    // 79 -> 80 on 2026-09-08: `Mirror.SetCredential`, re-vendored with the rest
    // of `mirror.proto` from gunnar's `23c5b8a` — the commit that made
    // `Mirror.Sync` a real fetch on a real queue. A verb of its own rather than
    // two more fields on `Add`, because proto3 has no absent `string`: a
    // credential riding on `Add` would be ERASED by every later `Add` that
    // meant only to flip `prune`, and nothing in such a message can spell
    // "leave the key alone". It is also the only spelling of "forget it"
    // (`kind = UNSPECIFIED`). The same re-vendor brought `MirrorCredentialKind`,
    // `MirrorJobState` and `MirrorJob`, three new fields on `MirrorConfig`
    // (`credential`, `credential_username`, `job`) and `SyncMirrorResponse.job`
    // — all messages and fields, so only the one RPC moves this number.
    //
    // 80 -> 81 on 2026-09-10: `Usage.Activity` — the FLOW series. Every meter
    // `Usage.Read` carried before it was a STOCK meter and read the same for a
    // namespace pushed to this morning and one last touched in March; `Activity`
    // answers one row per UTC day that moved something. The same re-vendor
    // brought seven flow fields on `NamespaceUsage` and the `DayActivity`,
    // `ActivityRequest` and `ActivityResponse` messages — fields and messages,
    // so only the one RPC moves this number.
    //
    // 81 -> 82 on 2026-09-11: `Refs.ReadLfsObject` — one LFS object by the
    // sha256 its POINTER names. A file kept in LFS is a pointer in the tree, so
    // every read the console had got the pointer: gunnar's own README links
    // `docs/gunnar-fast-setup.pdf`, and the raw route had 131 bytes of text to
    // serve as a PDF. The object's bytes were reachable only through git-lfs's
    // own transfer adapters until this RPC, which answers on the same `Read`
    // grant those adapters decide `download` on.
    //
    // ★ **82 -> 83 on 2026-09-13, and the number moved for a DIFFERENT
    // reason than the one written here the first time.**
    //
    // The entry that stood here said `Admin.Machine` — the machine's own
    // disks, with `InstallRecord`, `Disk`, `Partition`, `DataSet`,
    // `SerialdatArea`, `RejectedDisk`, `Mount`, `AreaUsage`, `Placement`,
    // `PlacementRow` and an `OffStripe` enum behind it. **gunnar never served
    // any of it.** It was a proposal this console vendored into its own copy
    // and then drew a whole page against, and `the_vendored_protos_match_
    // gunnars_own` went red the moment gunnar's `admin.proto` was compared
    // against it.
    //
    // gunnar answered the same need ADDITIVELY instead, on a call that already
    // existed: `StorageInfoResponse.reality` (field 10), carrying the layout
    // config UNPARSED as `repeated InstallRecordField`, the configured
    // filesystems as `FilesystemUse`, and the placement table as
    // `PlacementRow` — messages and one field, so **no RPC at all**. The
    // console was repointed onto it; see `api::machine`.
    //
    // So the count reaching 83 today is an accident of two changes landing
    // together, and saying so is the point of this note: `Admin.Machine`
    // LEFT (-1) when the proto was re-vendored from gunnar's own, and
    // `Access.RenamePrincipal` ARRIVED (+1) in the same re-vendor — a
    // principal's display name made changeable while its id, and with it the
    // account namespace and every grant, key, membership and gate keyed on
    // it, stays put. The same re-vendor also brought `AddPrincipalRequest.id`,
    // `ListPrincipalsRequest.name`, `AddKeyRequest.attestation`,
    // `KeyAttestation`/`SignedKeyAttestation`, and seven fields plus
    // `UnboundCredential` on `HealthResponse` — all fields and messages, so
    // none of them move this number.
    //
    // 83 -> 92 on 2026-09-15: admin.proto, bootstrap.proto and mirror.proto
    // re-vendored from gunnar master `a5db99c`. `Admin.GrowStart` /
    // `GrowStatus` / `GrowResume` and `Bootstrap.GrowStatus` (the runtime
    // data-set growth), `Mirror.FlushReplication` (the replication watermark),
    // `Bootstrap.BecomeTwin` / `Restore` and `Mirror.LogHead` / `LogFetch`
    // (the twin's transaction log). All additive and none surfaced; the
    // console's new data-set panel rides on `Admin.Capacity`, which adds no RPC.
    //
    // 92 -> 94 on 2026-09-15: bootstrap.proto and mirror.proto re-vendored from
    // gunnar master `a3b0ed2` (twin-heartbeat and twin-snapshot-stream merged):
    // `Mirror.SnapshotFetch` and `Mirror.LogRetain`, both machine-to-machine and
    // not surfaced. The heartbeat rows are fields on `Health`/`Status`/`LogHead`.
    //
    // 94 -> 96 on 2026-09-17, and 12 services -> 13: `billing.proto`, whose
    // `Billing.MintActorTicket` and `Billing.ApplianceIdentity` are what let a
    // CUSTOMER renew their own subscription instead of an operator doing it by
    // hand on every lapse. The mint is authorised exactly as every other
    // namespace-scoped call on this plane is — `Access::namespace(name)` at
    // `Admin` — which is the point: this console holds a server-wide admin
    // grant, so a tenant check made HERE would be worth nothing against a
    // routing bug in this crate. gunnar checks the key that signed the call.
    //
    // Metered only, in gunnar and here: the open build sells nothing and does
    // not contain the service, so `build.rs` compiles this file under the same
    // feature as monetize's own protos.
    //
    // 96 -> 100 on 2026-09-17, and 13 services -> 14: `entitlement.proto`, four
    // calls. Only `Get` is used and only for the caller's OWN namespace; `Set`
    // is generated because a file is the unit of vendoring, and generating it
    // is deliberate rather than overlooked — gunnar verifies every `Set`
    // against MONETIZE's Ed25519 key before it writes, and this console holds
    // no such key, so a fact it invented would be refused at the door. See
    // `build.rs` for why that asymmetry is what makes `entitlement.proto` safe
    // to vendor and `ledger.proto` not.
    //
    // 101 -> 107 on 2026-09-28, services unchanged: re-vendored from gunnar
    // a2c3d12. `Admin.AuditTrail`, `Admin.SetTunnelAddress`,
    // `Admin.IssueTwinCertificate`, `Bootstrap.AssignTunnelAddress`,
    // `Bootstrap.TwinCsr`, `Bootstrap.InstallTwinCertificate` — all additive
    // 2026-09-21, none surfaced; `docs/coverage.md` says why for each.
    // 107 -> 108 on 2026-09-28 (release-2, with the re-vendor above): `Usage.AdmitPlan` — the admission plan for a
    // namespace (usage over the rolling window, the budget, per kind of work
    // the class and gunnar's sentence). Same grant as `Usage.Activity`.
    // 108 -> 111 on 2026-10-03 (the owner's account model, gunnar's release-3
    // merge): `Repos.SetNamespaceSettings`, `Accounts.EnrollEmployee`,
    // `Accounts.ListEmployees` — the account admin's settings and employees,
    // surfaced under `/api/account` (`docs/coverage.md`).
    // 111 -> 115 on 2026-10-04, the three lines of that day merged:
    // `Admin.Topology` (feat/ui-cleanup-topology) — the estate's boxes as
    // gunnar knows them, the Overview's Machine card (`GET
    // /api/machine/topology`, `…/topology.svg`); `Access.WhoAmI` and
    // `Access.Check` (thin layer, gunnar feat/thin-ui 5c5200e) — "who am I"
    // and "may I", answered for the caller only, so the console asks gunnar
    // instead of deciding; `Accounts.ClaimOperator` (fix/first-login) — the
    // operator's account, made at install and adopted by the first passkey.
    // 115 -> 125 on 2026-10-04 (thin layer L, gunnar feat/thin-ui 484102a):
    // `Access.AddCredential`, `ListCredentials`, `RevokeCredential`,
    // `RenameCredential` — the people's passkeys, kept by gunnar on their
    // principals; `CreateInvite`, `ListInvites`, `RevokeInvite` — a pending
    // passkey enrolment, gunnar's; `WebChallenge`, `WebLogin`, `WebRegister`
    // — the ceremonies, verified by gunnar with the console as the relay.
    // 125 -> 130 on 2026-10-05 (feat/kit-enrol-approve): `Access.
    // RequestMachineEnrolment`, `MachineEnrolmentStatus`,
    // `ListMachineEnrolments`, `ApproveMachineEnrolment`,
    // `RefuseMachineEnrolment` — an ops kit ASKS for its machine credential
    // and a person approves it with a step-up gunnar verifies (no copied
    // session cookie); the console relays (`auth::machine`).
    // 130 -> 144 on 2026-10-05 (company SSO, gunnar feat/sso-2026-10-05,
    // merged on feat/test2-r2-2026-10-05): the fourteen `Sso.*` verbs — the
    // account admin's provider, SCIM bearers, directory and CSV import, and
    // the relay's Discover / Begin / Finish / SecondFactor / Scim
    // (`docs/coverage.md`, `## Sso`).
    // 144 -> 146 on 2026-10-05 (encrypted repositories, gunnar
    // feat/encrypted-repos-2026-10-05, merged on feat/test2-r2-2026-10-05):
    // `Repos.SetEncrypted` — the account admin's "encrypted" flag — and
    // `Repos.RecipientKeys` — a member's SSH public keys, for wrapping a
    // repository's key ring to them.
    // 146 -> 159 on 2026-10-05 (the owner's enterprise list, gunnar
    // feat/enterprise-audit-2026-10-05, merged on feat/test2-r2-2026-10-05):
    // the thirteen `Enterprise.*` verbs — AuditLog / ExportAuditLog,
    // Aggregates, Request / List / Approve / Refuse / RevokeOperatorAccess,
    // CheckName, Get / SetNameLists, List / DecideNameReview
    // (`docs/coverage.md`, `## Enterprise`).
    // 159 -> 158 on 2026-10-11 (UW-16): `Entitlement.ListExpiring` deleted in gunnar
    // (nothing called it).
    // 158 -> 159 on 2026-10-19: `Access.CheckInvite` — read-only, asked by
    // `register/start` before any challenge (an invite that cannot register a
    // passkey is refused before the browser makes one).
    // 159 -> 161 on 2026-10-26 (owner decision, item 5): `Admin.GetSetting` and
    // `Admin.SetSetting` — gunnar's generic server setting, where a `public-site` or `mcp`
    // console keeps its administrator's switch (`console_setting`).
    // 161 -> 165 on 2026-10-26 (CRYPT-YUBI): `Access.GetEncryptionKey`,
    // `CreateEncryptionKey`, `AddUnlockMethod`, `RemoveUnlockMethod` — the person's
    // one encryption key and its unlock methods (`api::encryption_key`).
    // 165 -> 166 on 2026-10-27 (A34-17): `Sso.ImportStatus`, the CSV import's job.
    // 166 -> 168 on 2026-10-27 (A34 M-4, lane OX-SSO): `Sso.VerifyDomain` (an e-mail
    // domain's DNS proof, A34-5) and `Sso.RebindUsers` (an IdP migration, A34-11), both
    // the account admin's Identity provider page (`api::sso`).
    // 168 -> 163 on 2026-10-28 (IDENTITY-HOST, A34-18/19/21): gunnar's own
    // `Access.CreateInvite`, `ListInvites`, `RevokeInvite`, `CheckInvite`,
    // `WebChallenge`, `WebLogin`, `WebRegister` are gone — the ceremonies,
    // sessions and invites are the identity core's
    // (`nornir.identity.v1.IdentityService`, edda's crate, not vendored here) —
    // and `Access.EnrolmentLink` (a person's first-login link, the server
    // admin's door) and `Access.MachineChallenge` (a machine's creation
    // challenge) are new.
    // 163 -> 164 on 2026-11-01 (GUN-FIELD-TEST): `PullRequests.CheckMerge`, the merge
    // checked ahead (`api::prs::mergeable`).
    // 164 -> 166 on 2026-11-02 (GUN-MERGE-SIGN, GUN-MIRROR-CA): `PullRequests.MergeSigningKey`
    // (the merge-signing key's public half) and `Mirror.SetCa` (a mirror's private CA).
    assert_eq!(rpcs, 166, "gunnar's control plane changed shape");
}

#[test]
fn every_rpc_has_a_row_in_the_coverage_matrix() {
    // The point is not documentation. It is that adding an RPC to the vendored
    // protos forces someone to decide what the console does with it, including
    // deciding to do nothing.
    let matrix = read(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/coverage.md"));

    for (service, rpcs) in services() {
        assert!(
            matrix.contains(&format!("## {service}")),
            "docs/coverage.md has no section for the {service} service"
        );
        for rpc in rpcs {
            assert!(
                matrix.contains(&format!("`{rpc}`")),
                "docs/coverage.md never mentions {service}.{rpc}; decide what the console \
                 does with it, even if the answer is nothing"
            );
        }
    }
}

#[test]
fn every_service_the_console_uses_has_a_client() {
    // The gap this closes was real in the repo this pattern came from: a
    // service existed for weeks with no client, and nothing said so.
    let grpc = read(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src/grpc.rs"));

    for service in [
        "Repos",
        "Refs",
        "Access",
        "Admin",
        "Events",
        "Bootstrap",
        "Forge",
        "Usage",
        // **`Mirror` MOVED into this list on 2026-09-08**, and the assertion
        // that used to hold it out of the list is deleted below rather than
        // relaxed. Its reason — "`Sync` fetches, and fetching needs
        // `gunnar-client`, which a server-side crate may not link" — stopped
        // being true when `gunnar_wire::fetch_pack` landed: a fetch client with no
        // `gix`, no `.git` directory and no worktree, which the appliance can
        // link. `Mirror.Sync` is now an enqueue on the one job runner, and the
        // console has `/mirrors`, `/mirrors/import` and a panel on every
        // repository page that call `Add`, `SetCredential`, `Sync`, `Status`
        // and `Remove`.
        "Mirror",
    ] {
        assert!(
            grpc.contains(&format!("{service}Client")),
            "grpc.rs builds no client for {service}, but the console uses it"
        );
    }
    // `Policy` MOVED. It was in the list below for months on the strength of a
    // `PROTECTION_GAP` constant in gunnar that had already gone dead, and the
    // absence assertion here was one of the three things holding the console's
    // branch-protection panels disabled while the server served all six verbs.
    assert!(
        grpc.contains("PolicyClient"),
        "grpc.rs builds no client for Policy, but a real gunnar serves all six of its \
         RPCs out of `refs/gunnar/policy` — branch protection is reachable from the CLI \
         and would be unreachable from the console"
    );
    // The `MirrorClient` absence assertion that used to stand here said "the
    // day one gets built this assertion is what gets deleted". That day was
    // 2026-09-08; the service is in the list above.
}

/// **One row of `docs/coverage.md`**, parsed rather than substring-matched.
#[derive(Debug)]
struct Row {
    service: String,
    rpc: String,
    status: String,
}

impl Row {
    fn method(&self) -> String {
        format!("{}.{}", self.service, self.rpc)
    }
}

/// Parse the coverage matrix into rows.
///
/// `every_rpc_has_a_row_in_the_coverage_matrix` does not parse anything — it
/// asks whether the file *contains* `## Policy` and *contains* `` `ListGates` ``
/// anywhere at all. That is why six rows could say `refused by gunnar` about a
/// working service for weeks without a test noticing: the row existed, and its
/// existence was the whole assertion.
///
/// This walks `## <Service>` headings and takes `| `Rpc` | Status | … |` rows
/// under them, keeping only rows whose RPC the vendored proto actually declares
/// on that service. The two trailing tables (`| Route | …`, `| Thing | …`) fall
/// out for free, since their first cell is not a known RPC.
fn matrix_rows() -> Vec<Row> {
    let matrix = read(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/coverage.md"));
    let services = services();
    let mut rows = Vec::new();
    let mut current: Option<String> = None;

    for line in matrix.lines() {
        let line = line.trim();
        if let Some(rest) = line.strip_prefix("## ") {
            let name = rest.trim();
            current = services.contains_key(name).then(|| name.to_owned());
            continue;
        }
        let (Some(service), true) = (current.as_ref(), line.starts_with('|')) else {
            continue;
        };
        let cells: Vec<&str> = line.trim_matches('|').split('|').map(str::trim).collect();
        if cells.len() < 2 {
            continue;
        }
        let rpc = cells[0].trim_matches('`');
        if !services[service].iter().any(|r| r == rpc) {
            continue;
        }
        rows.push(Row {
            service: service.clone(),
            rpc: rpc.to_owned(),
            // Bold markers are presentation. `**not surfaced**` and
            // `not surfaced` are one status and must not be two.
            status: cells[1].replace('*', "").trim().to_lowercase(),
        });
    }
    rows
}

/// gunnar's declared refusal set, read out of its source the same way the
/// credential constants are.
fn gunnar_refusals(gunnar: &Path) -> Option<Vec<String>> {
    let auth = gunnar.join("crates/gunnar-grpc/src/auth.rs");
    if !auth.is_file() {
        return None;
    }
    let text = read(&auth);
    let start = text.find("pub const UNIMPLEMENTED_METHODS")?;
    // From the `=`, not from the declaration: the first `[` after the name
    // belongs to the TYPE (`&[&str]`), and reading from there yields the single
    // element `"&str"` — which parses, compares against every row, and fails
    // with a message about a constant that is perfectly correct. Seen exactly
    // that way on this test's first run.
    let body = &text[start..];
    let body = &body[body.find('=')?..];
    let open = body.find('[')?;
    let close = body.find(']')?;
    Some(
        body[open + 1..close]
            .split(',')
            .map(|piece| piece.trim().trim_matches('"').to_owned())
            .filter(|piece| !piece.is_empty())
            .collect(),
    )
}

/// **A refusal gunnar's own constant still declares and gunnar's own server no
/// longer makes**, with the file and the phrase that prove it.
///
/// This is the `PROTECTION_GAP` failure wearing the other hat, and it has to be
/// handled differently *because* it is the other hat. There, the constant said
/// "refused" about something that worked and the console believed the constant;
/// the fix was to hold the matrix to the constant. Here, the constant says
/// "refused" about something that works and the console can SEE that it works —
/// `gunnar-server`'s `CoreControlPlane::mirror_sync` enqueues a real job, and
/// `gunnar-server/tests/mirror_sync.rs` drives it against a real upstream.
/// `UNIMPLEMENTED_METHODS` has simply not caught up: it names the refusal that
/// `MemoryControlPlane` (the in-memory double in `gunnar-grpc/src/seam.rs`)
/// still makes, and the console does not talk to that plane.
///
/// So the exception is narrow, named, and **carries its own evidence**: the
/// entry applies only while the phrase below is present in the file below. If
/// gunnar's real plane goes back to refusing, the phrase disappears and the
/// assertion binds again with no edit here. If gunnar's constant is corrected —
/// which is that repository's work to do, not this one's — the entry is simply
/// never consulted, so this is not a thing to remember to remove.
///
/// It must never grow into a way to silence a row someone did not want to
/// write. The only admissible entry is one where gunnar's SOURCE contradicts
/// gunnar's CONSTANT, and the phrase is what makes that checkable.
const REFUSALS_GUNNARS_OWN_SOURCE_CONTRADICTS: &[(&str, &str, &str)] = &[(
    "Mirror.Sync",
    "crates/gunnar-server/src/control_plane.rs",
    // The enqueue itself. `MemoryControlPlane` answers
    // `Err(ControlError::Unimplemented("Mirror.Sync"))` and has no queue to
    // reach; the real plane has this line, and nothing else in the file does.
    "self.mirror.enqueue(",
)];

/// Drop from gunnar's declared refusal set the ones its own source contradicts.
///
/// Returns the methods a real `gunnar-server` still answers `Unimplemented`
/// for — which is what the coverage matrix is held to, because the console
/// talks to a real `gunnar-server`.
fn refusals_a_real_server_still_makes(gunnar: &Path, declared: Vec<String>) -> Vec<String> {
    declared
        .into_iter()
        .filter(|method| {
            !REFUSALS_GUNNARS_OWN_SOURCE_CONTRADICTS
                .iter()
                .any(|(name, file, phrase)| {
                    if name != method {
                        return false;
                    }
                    let path = gunnar.join(file);
                    let contradicted = path.is_file() && read(&path).contains(phrase);
                    if contradicted {
                        eprintln!(
                            "{method} is in gunnar's UNIMPLEMENTED_METHODS, but {file} contains \
                             {phrase:?} — its real control plane implements the verb. The \
                             coverage matrix is held to the real server, so this row is not \
                             required to claim a refusal."
                        );
                    }
                    contradicted
                })
        })
        .collect()
}

/// **The test that would have caught it.**
///
/// Seven rows of `docs/coverage.md` — all six `Policy.*` and `Refs.Diff` — said
/// `refused by gunnar` about RPCs a real gunnar had been serving for weeks. The
/// console rendered the six as disabled panels on the strength of those rows,
/// so branch protection existed, worked, and was reachable from nothing: a
/// merge needed a verified signed approval naming the exact commit, and a
/// direct push to the same branch was refused by nothing at all.
///
/// Nothing here was lying on purpose. `gunnar-server` held a `PROTECTION_GAP`
/// constant saying exactly this, and this file quoted it faithfully; then
/// `gunnar_core::PolicyStore` landed, the verbs started being served, and the
/// constant became dead code that `cargo check` reported as `never used`.
/// A row's *status* had no test, only its existence did.
///
/// So the status is held to gunnar's own answer. gunnar declares
/// `UNIMPLEMENTED_METHODS` once; its
/// `policy_mirror.rs::the_only_refusal_left_on_these_two_services_is_the_one_that_is_written_down`
/// drives every verb the matrix has ever called refused against a real server
/// and proves that list is the truth; this reads the same list and proves the
/// matrix agrees with it. Both directions, so neither half is vacuous.
///
/// Seen red (measure, never assert) by flipping `Refs.Log`'s status to `refused by gunnar`:
/// *"docs/coverage.md says Refs.Log is `refused by gunnar`. A real gunnar
/// SERVES it"*.
///
/// The `Mirror.Sync` half of that pair is gone, and its going is the reason
/// [`REFUSALS_GUNNARS_OWN_SOURCE_CONTRADICTS`] exists: gunnar's constant still
/// names it, gunnar's real control plane enqueues a job for it, and the console
/// now calls it. The direction that still binds is the other one — a row that
/// claims a refusal gunnar makes and no longer makes fails by name.
#[test]
fn every_refused_row_is_a_refusal_gunnar_still_makes() {
    let Some(gunnar) = gunnar_checkout() else {
        eprintln!("gunnar is not checked out beside this repo; row statuses not checked");
        return;
    };
    let Some(declared) = gunnar_refusals(&gunnar) else {
        eprintln!(
            "gunnar has no `UNIMPLEMENTED_METHODS` in gunnar-grpc/src/auth.rs; row statuses \
             not checked"
        );
        return;
    };
    // Held to what a real `gunnar-server` refuses, not to what the constant
    // says, where the two disagree and gunnar's own source settles it. See
    // `REFUSALS_GUNNARS_OWN_SOURCE_CONTRADICTS`.
    let declared = refusals_a_real_server_still_makes(&gunnar, declared);

    let rows = matrix_rows();
    assert!(
        rows.len() > 40,
        "the coverage matrix parsed as {} rows, which means the table shape changed and this \
         test is now checking almost nothing — the parser is the thing to fix, not this \
         number",
        rows.len()
    );

    let claimed: Vec<String> = rows
        .iter()
        .filter(|row| row.status.starts_with("refused by gunnar"))
        .map(Row::method)
        .collect();

    for method in &claimed {
        assert!(
            declared.contains(method),
            "docs/coverage.md says {method} is `refused by gunnar`. A real gunnar SERVES it \
             — it is not in `gunnar_grpc::UNIMPLEMENTED_METHODS`, and gunnar's own census \
             test drives it to prove that list. This is the `PROTECTION_GAP` failure again: \
             the row is stale, and the console is hiding a feature that works. Declared: \
             {declared:?}"
        );
    }
    for method in &declared {
        assert!(
            claimed.contains(method),
            "gunnar still answers `Unimplemented` for {method} and no row of \
             docs/coverage.md says so, so the console will offer an action that cannot \
             work. Claimed refused here: {claimed:?}"
        );
    }
}

/// A `used` row must name a service the console can actually reach, and no row
/// may be `used` and `refused by gunnar` at once.
///
/// The cheap half of the check above, and the half that never skips: it needs
/// no gunnar checkout, only this repo's own `grpc.rs`.
#[test]
fn every_used_row_names_a_service_the_console_has_a_client_for() {
    let grpc = read(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src/grpc.rs"));
    for row in matrix_rows() {
        if !row.status.starts_with("used") {
            continue;
        }
        assert!(
            grpc.contains(&format!("{}Client", row.service)),
            "docs/coverage.md says the console USES {}, but grpc.rs builds no {}Client, so \
             nothing can call it",
            row.method(),
            row.service
        );
    }
}

#[test]
fn the_vendored_protos_match_gunnars_own() {
    // Skipped rather than failed when gunnar is not checked out beside this
    // repo: CI clones one repository, and a test that cannot run must not be a
    // test that fails.
    let Some(gunnar) = gunnar_checkout() else {
        eprintln!("gunnar is not checked out beside this repo; proto drift not checked");
        return;
    };
    let theirs = gunnar.join("crates/gunnar-proto/proto/gunnar/v1");
    if !theirs.is_dir() {
        eprintln!("gunnar's protos are not where they used to be; drift not checked");
        return;
    }

    for entry in std::fs::read_dir(proto_dir()).expect("our protos") {
        let ours = entry.expect("a directory entry").path();
        if ours.extension().is_none_or(|e| e != "proto") {
            continue;
        }
        let name = ours.file_name().expect("a file name");
        let theirs = theirs.join(name);
        assert!(
            theirs.is_file(),
            "{} is vendored here but gone from gunnar",
            name.to_string_lossy()
        );
        assert_eq!(
            read(&ours),
            read(&theirs),
            "{} has drifted from gunnar's copy; re-vendor it",
            name.to_string_lossy()
        );
    }
}

#[test]
fn the_credential_constants_still_match_gunnars() {
    // These cannot be depended on: gunnar-grpc pulls in gunnar-proto, whose
    // build script requires protoc and pins tonic 0.13. So they are copied, and
    // this is the only thing standing between a copy and a console that cannot
    // authenticate for a reason no error message names.
    let Some(gunnar) = gunnar_checkout() else {
        eprintln!("gunnar is not checked out beside this repo; constants not checked");
        return;
    };
    let auth = gunnar.join("crates/gunnar-grpc/src/auth.rs");
    if !auth.is_file() {
        eprintln!("gunnar's auth.rs moved; constants not checked");
        return;
    }
    let mut theirs = read(&auth);
    // release-3: gunnar money moved the SSHSIG credential into edda's
    // product-neutral `nornir-auth-sshsig` and re-exports it
    // (`pub const MIN_NONCE_LEN: usize = nornir_auth_sshsig::MIN_NONCE_LEN;`), so the
    // literal values live in the sibling edda that gunnar builds against.
    let shared = gunnar.join("../edda/crates/nornir-auth-sshsig/src/lib.rs");
    if theirs.contains("nornir_auth_sshsig::") && shared.is_file() {
        theirs.push_str(&read(&shared));
    }

    for expected in [
        r#"CREDENTIAL_METADATA_KEY: &str = "gunnar-authorization""#,
        // v2 since gunnar's GUN-001 fix: the method is in the signed bytes.
        // `signer.rs` sends v2 and keeps v1 only to prove it can tell them apart.
        r#"CREDENTIAL_SCHEME: &str = "gunnar-sshsig-v2""#,
        r#"CREDENTIAL_SCHEME_V1: &str = "gunnar-sshsig-v1""#,
        r#"SSHSIG_NAMESPACE: &str = "gunnar-grpc""#,
        r#"CHALLENGE_PREAMBLE: &str = "gunnar-grpc-auth-v1""#,
        r#"CHALLENGE_PREAMBLE_V2: &str = "gunnar-grpc-auth-v2""#,
        "MIN_NONCE_LEN: usize = 16",
        "MAX_CLOCK_SKEW_MS: i64 = 5 * 60 * 1000",
    ] {
        assert!(
            theirs.contains(expected),
            "gunnar no longer declares `{expected}`; src/signer.rs copies it and will \
             now be refused on every call"
        );
    }

    // And the armour mangling, which is the part that actually bites.
    assert!(
        theirs.contains(r#"replace('\n', "|").replace(' ', "~")"#),
        "gunnar changed how it packs the sshsig armour into a header; src/signer.rs \
         mirrors the old mapping and every call will be refused with \"trailing data \
         after the signature\""
    );
}

#[test]
fn the_forge_signing_format_still_matches_gunnars() {
    // `src/forge.rs` is a copy of gunnar's canonical signing bytes, for the
    // same reason the credential constants are: the crate that owns them pulls
    // in `gunnar-proto`, whose build script requires `protoc`.
    //
    // Drift here does not fail loudly. It produces an approval gunnar rejects
    // with "the event's signature does not verify" — a message that reads like
    // a key problem and is not — or, worse, one it accepts that says something
    // other than what the console meant.
    let Some(gunnar) = gunnar_checkout() else {
        eprintln!("gunnar is not checked out beside this repo; forge format not checked");
        return;
    };
    let forge = gunnar.join("crates/gunnar-core/src/forge.rs");
    if !forge.is_file() {
        eprintln!("gunnar's forge.rs moved; format not checked");
        return;
    }
    let theirs = read(&forge);

    for expected in [
        // The namespace. A signature under any other one is refused, which is
        // what stops a commit signature or a control-plane credential being
        // presented as an approval.
        r#"FORGE_NAMESPACE: &str = "gunnar-forge""#,
        // The preamble and every line of the canonical form, in order.
        r#""gunnar-forge-v1\n""#,
        r#""repo {}\n""#,
        r#""pull {}\n""#,
        r#""actor {}\n""#,
        r#""at {}\n""#,
        r#""kind {}\n""#,
        // The length prefix, which is what stops a comment body forging a
        // field.
        r#""{name} {} {value}\n""#,
        // The two tags the console writes.
        r#"Event::Commented { .. } => "commented""#,
        r#"Event::Approved { .. } => "approved""#,
        // And the field names those two tags carry. A comment signs `body`
        // always, and — only when it is an INLINE comment — the anchor that
        // makes it follow the code: `path`, `line`, `blob`, each length-prefixed
        // and bound, appended only when `path` is non-empty so a per-pull
        // comment signs exactly the bytes it always did.
        r#"field("body", body);"#,
        r#"if !path.is_empty() {"#,
        r#"field("path", path);"#,
        r#"field("line", &line.to_string());"#,
        r#"field("blob", blob_oid);"#,
        r#"Event::Approved { commit } => field("commit", commit)"#,
        // The three issue tags the console also writes, and the fields they
        // carry — mirrored in `src/forge.rs` so an issue opened, commented or
        // closed from the console verifies against gunnar's own writer.
        r#"Event::IssueOpened { .. } => "issue_opened""#,
        r#"Event::IssueCommented { .. } => "issue_commented""#,
        r#"Event::IssueClosed { .. } => "issue_closed""#,
        r#"Event::IssueCommented { body } => field("body", body)"#,
        r#"Event::IssueClosed { reason } => field("reason", reason)"#,
    ] {
        assert!(
            theirs.contains(expected),
            "gunnar no longer writes `{expected}` in its forge signing bytes; \
             src/forge.rs mirrors the old form and every approval the console files \
             will be refused as unverifiable"
        );
    }

    // The hash algorithm is inside the sshsig and is not part of the bytes
    // above, so it is checked separately: signing under SHA-256 produces a
    // perfectly good signature gunnar's verifier will not match.
    assert!(
        theirs.contains("ssh_key::HashAlg::Sha512"),
        "gunnar changed the hash it signs forge events under; src/signer.rs still \
         uses Sha512"
    );
}