crates/gunnar-proto/tests/field_numbers.rs
//! **A field number is a promise to every box already in the field.**
//!
//! A protobuf field number is the only thing on the wire; the name is not sent. Two builds that
//! disagree about what a number means do not fail loudly — they decode each other's bytes as
//! something else. This file is the guard, and it exists because it has already happened here.
//!
//! # What happened, 2026-09-15
//!
//! `922cb2d` (15:09) added `string build = 14` and `string role = 15` to
//! `BootstrapStatusResponse`, and gave `reinstall-appliance --verify-control --expect-role`
//! something to read. `b8f4e41` (16:34), on a branch that did not have it yet, added
//! `PrimaryHeartbeat primary = 14` to the SAME message. Both are ancestors of master; the merge
//! (`aa97c85`) resolved it by moving `primary` to 16, and nothing said a word.
//!
//! A binary built from `b8f4e41` answers `Bootstrap.Status` with a length-delimited submessage
//! on field 14. `string` is length-delimited too, so nothing about the framing looks wrong, and
//! today's client parses those bytes as UTF-8 and fails — not the field, the WHOLE response:
//! `InvalidString` at `BootstrapStatusResponse.build`
//! ([`a_box_that_answers_field_14_with_the_other_schema_kills_the_whole_status`], measured).
//!
//! `Bootstrap.Status` is the one call a box nobody holds a key for can answer. It is what a boot
//! script waits on (`serving_git`), what an installer door reads to prove which medium came up,
//! and what `--verify-control` checks. A box on the wrong side of this collision is not "an old
//! build" to its operator — it is a box whose only unauthenticated door answers with a decode
//! error, and there is no credential that gets him past it.
//!
//! Nothing can fix such a box from this side. So this file keeps it from happening twice:
//! numbers are unique within a message, and the numbers of the messages the appliance answers
//! WITHOUT a credential are frozen by value, so moving one is a red test and a decision.
use gunnar_proto::*;
use prost::Message;
/// Every `(number, name)` a message declares, in file order. `oneof` fields count — they share
/// the message's number space — and `enum` blocks do not, so they are skipped whole.
fn fields_of(proto: &str, message: &str) -> Vec<(u32, String)> {
let mut out = Vec::new();
let mut depth = 0usize;
let mut inside = false;
let mut in_enum: Option<usize> = None;
for line in proto.lines() {
let t = line.trim();
if !inside {
if t.starts_with("message ") && t.split_whitespace().nth(1) == Some(message) {
inside = true;
depth = t.matches('{').count();
}
continue;
}
let before = depth;
depth += t.matches('{').count();
depth = depth.saturating_sub(t.matches('}').count());
if depth == 0 {
break;
}
// A nested `enum` block is skipped whole: its values are not field numbers.
if let Some(enum_at) = in_enum {
if depth <= enum_at {
in_enum = None;
}
continue;
}
if t.starts_with("enum ") {
in_enum = Some(before);
continue;
}
if t.starts_with("//") || t.starts_with("reserved") || t.starts_with("oneof") {
continue;
}
let Some((decl, rest)) = t.split_once('=') else {
continue;
};
let Some(number) = rest.trim().trim_end_matches(';').trim().parse::<u32>().ok() else {
continue;
};
let Some(name) = decl.split_whitespace().last() else {
continue;
};
out.push((number, name.to_string()));
}
out
}
/// Every message in one `.proto`, with its fields.
fn messages_of(proto: &str) -> Vec<(String, Vec<(u32, String)>)> {
proto
.lines()
.filter_map(|l| {
let t = l.trim();
(t.starts_with("message ") && t.ends_with('{'))
.then(|| t.split_whitespace().nth(1).unwrap_or_default().to_string())
})
.filter(|name| !name.is_empty())
.map(|name| {
let fields = fields_of(proto, &name);
(name, fields)
})
.collect()
}
const PROTOS: &[(&str, &str)] = &[
(
"access.proto",
include_str!("../proto/gunnar/v1/access.proto"),
),
(
"accounts.proto",
include_str!("../proto/gunnar/v1/accounts.proto"),
),
(
"admin.proto",
include_str!("../proto/gunnar/v1/admin.proto"),
),
(
"bootstrap.proto",
include_str!("../proto/gunnar/v1/bootstrap.proto"),
),
(
"clans.proto",
include_str!("../proto/gunnar/v1/clans.proto"),
),
(
"common.proto",
include_str!("../proto/gunnar/v1/common.proto"),
),
(
"entitlement.proto",
include_str!("../proto/gunnar/v1/entitlement.proto"),
),
(
"events.proto",
include_str!("../proto/gunnar/v1/events.proto"),
),
(
"forge.proto",
include_str!("../proto/gunnar/v1/forge.proto"),
),
(
"mirror.proto",
include_str!("../proto/gunnar/v1/mirror.proto"),
),
(
"policy.proto",
include_str!("../proto/gunnar/v1/policy.proto"),
),
(
"pulls.proto",
include_str!("../proto/gunnar/v1/pulls.proto"),
),
("refs.proto", include_str!("../proto/gunnar/v1/refs.proto")),
(
"repos.proto",
include_str!("../proto/gunnar/v1/repos.proto"),
),
(
"usage.proto",
include_str!("../proto/gunnar/v1/usage.proto"),
),
(
"webhooks.proto",
include_str!("../proto/gunnar/v1/webhooks.proto"),
),
];
#[test]
fn no_message_gives_two_fields_the_same_number() {
let mut scanned = 0usize;
for (file, proto) in PROTOS {
for (message, fields) in messages_of(proto) {
scanned += fields.len();
let mut seen: std::collections::BTreeMap<u32, String> =
std::collections::BTreeMap::new();
for (number, name) in fields {
if let Some(first) = seen.insert(number, name.clone()) {
panic!("{file}: {message} gives field {number} to both `{first}` and `{name}` — a number is what goes on the wire, and two meanings for one number is two builds decoding each other as something else");
}
}
}
}
// A guard that scanned nothing would pass for ever.
assert!(
scanned > 500,
"the scanner found only {scanned} fields across {} protos",
PROTOS.len()
);
// And one that cannot SEE a duplicate would pass for ever too. `protoc` refuses a duplicate
// inside one file (measured: `build.rs` panics before this test runs), so the real files can
// never exercise this arm — the scanner is checked against a synthetic message instead.
let synthetic = "message Two {\n string a = 1;\n oneof pick {\n string b = 2;\n }\n string c = 1;\n}\n";
let fields = fields_of(synthetic, "Two");
assert_eq!(
fields,
[
(1, "a".to_string()),
(2, "b".to_string()),
(1, "c".to_string())
],
"the scanner must read oneof members and must not stop at the oneof's closing brace"
);
let mut seen = std::collections::BTreeSet::new();
assert!(
!fields.iter().all(|(n, _)| seen.insert(*n)),
"the detector missed a duplicate it was handed"
);
}
/// **The numbers the appliance answers WITHOUT a credential, frozen by value.**
///
/// `BootstrapStatusResponse` is the message field 14 was taken twice for. Freezing the list is
/// not pinning for its own sake: a renumber here cannot be caught by any other test, because
/// every build in the tree agrees with itself — only a build in the FIELD disagrees, and there
/// is no such build in CI. Adding a field is adding a line at the end; moving one is red.
#[test]
fn the_unauthenticated_status_numbers_are_frozen() {
let fields = fields_of(
include_str!("../proto/gunnar/v1/bootstrap.proto"),
"BootstrapStatusResponse",
);
assert_eq!(
fields,
[
(1, "window_open"),
(2, "bootstrapped"),
(3, "bootstrapped_at_unix_ms"),
(4, "bootstrapped_principal"),
(5, "bootstrapped_fingerprint"),
(6, "principals"),
(7, "keys"),
(8, "detail"),
(9, "serving_git"),
(10, "readiness"),
(11, "server_unix_ms"),
(12, "clock_last"),
(13, "clock_skew_syncs_suppressed"),
// 14 and 15 arrived on 922cb2d; 16 arrived on b8f4e41 as 14 and was moved by the
// merge.
(14, "build"),
(15, "role"),
(16, "primary"),
// T17, 2026-09-21: the fresh box's machine for the twin election. Next went at 19.
(17, "facts"),
(18, "facts_detail"),
// recharge 5aaa69c (2026-09-29): a recharged main says, without a credential,
// that it awaits its twin, whether a restore is half-way, and its log head.
(19, "recharge_from"),
(20, "restoring"),
(21, "log_head_seq"),
// restore-large-entry 05bf5ad (r5.1, 2026-10-07): an entry larger than one call is
// staged on the box and resumed at its byte — which entry, and how much of it is held.
(22, "restore_partial_seq"),
(23, "restore_partial_bytes"),
// restore-after-snapshot (r5.1, 2026-10-07): the twin's snapshot staged across calls —
// whether the box can, which snapshot, how many items and bytes of the next it holds.
(24, "restore_snapshot_resumable"),
(25, "restore_snapshot_id"),
(26, "restore_snapshot_items"),
(27, "restore_snapshot_bytes"),
(28, "restore_snapshot_whole"),
// RA5-14 (2026-10-28): whether the break-glass token is still the first admin's way in.
(29, "break_glass_open"),
]
.map(|(n, s): (u32, &str)| (n, s.to_string())),
);
}
/// `LogChunk.restore_head` is the additive field the restore's short-stream refusal rests on: a
/// sender that announces nothing leaves `0`, and `restore_finish` then cannot tell a whole
/// stream from a truncated one. Moving it would make every sender silently stop announcing.
#[test]
fn the_restore_announcement_keeps_its_number() {
let fields = fields_of(include_str!("../proto/gunnar/v1/mirror.proto"), "LogChunk");
assert_eq!(
fields.iter().find(|(_, n)| n == "restore_head"),
Some(&(7u32, "restore_head".to_string()))
);
}
/// **What a box built from `b8f4e41` does to a client of today.** See this file's header.
///
/// Kept as a test rather than a note because it is the only statement of the COST: not a lost
/// field, the whole response — so the one door that needs no credential answers nothing an
/// operator can act on.
#[test]
fn a_box_that_answers_field_14_with_the_other_schema_kills_the_whole_status() {
let heartbeat = PrimaryHeartbeat {
address: "10.13.8.101:50051".into(),
last_answer_unix_ms: 1_726_000_000_000,
answer_ago_ms: 2_000,
primary_head_seq: 41,
applied_seq: 41,
state: "ok".into(),
why: String::new(),
silent_after_ms: 60_000,
};
let inner = heartbeat.encode_to_vec();
let mut wire = Vec::new();
prost::encoding::encode_key(14, prost::encoding::WireType::LengthDelimited, &mut wire);
prost::encoding::encode_varint(inner.len() as u64, &mut wire);
wire.extend_from_slice(&inner);
let err = BootstrapStatusResponse::decode(&wire[..]).expect_err("field 14 cannot be both");
let say = format!("{err:?}");
assert!(
say.contains("InvalidString") && say.contains("build"),
"the collision must stay demonstrated, in the words a reader of a support case would see: {say}"
);
}
/// **The twin's identity and proof fields keep their numbers, and moving one is
/// silent** (2026-09-21, lane T4).
///
/// This is the worst shape a field number can have. `LogHeadResponse.log_id`
/// empty does not mean "error", it means **"this box carries no identity"**, and
/// a twin answers that by following on the numbers alone — which is right for a
/// primary older than the field and catastrophic for a primary that has one and
/// whose answer landed in the wrong slot. The same is true of `answer_sig`:
/// empty means "unsigned", and an unpinned twin accepts it. So a moved number
/// does not break a build or fail a test anywhere else; it turns both checks off
/// and everything stays green.
///
/// `LogChunk.log_id` is the restore's half: empty there means the restored box
/// mints an identity of its own instead of keeping the history's, and the twin
/// that fed it then diverges from the box it just rescued.
#[test]
fn the_twin_identity_and_proof_fields_keep_their_numbers() {
let mirror = include_str!("../proto/gunnar/v1/mirror.proto");
let head = fields_of(mirror, "LogHeadResponse");
for (number, name) in [
(12u32, "log_id"),
(13, "answer_key"),
(14, "answer_sig"),
(15, "last_level_unix_ms"),
] {
assert_eq!(
head.iter().find(|(_, n)| n == name),
Some(&(number, name.to_string())),
"LogHeadResponse.{name} must stay field {number}: a twin reads an absent one as \
'this box carries no identity / no proof' and follows anyway, so moving it disables \
the check instead of breaking anything"
);
}
let request = fields_of(mirror, "LogHeadRequest");
assert_eq!(
request.iter().find(|(_, n)| n == "nonce"),
Some(&(2u32, "nonce".to_string())),
"a nonce that does not arrive is a primary that signs nothing, and an unpinned twin takes it"
);
assert_eq!(
request.iter().find(|(_, n)| n == "generation_proof"),
Some(&(3u32, "generation_proof".to_string())),
"a proof that does not arrive is an old main that never learns it was replaced, and keeps taking writes (stinky row 18)"
);
let retain = fields_of(mirror, "LogRetainRequest");
assert_eq!(
retain,
vec![
(1u32, "from".to_string()),
(2, "allow_past_twin".to_string())
],
"LogRetainRequest.allow_past_twin must stay field 2 (2026-09-22): an override that does \
not arrive is a drill that can no longer open a hole, and one that lands on another \
number is an override nobody asked for"
);
let chunk = fields_of(mirror, "LogChunk");
assert_eq!(
chunk.iter().find(|(_, n)| n == "log_id"),
Some(&(9u32, "log_id".to_string())),
"LogChunk.log_id must stay field 9: an absent one makes a restored box mint its own \
identity instead of keeping the history's, and its twin then diverges from the box it \
was just built out of"
);
}