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) = ¤t {
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"
);
}