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

nordisk/gunnar

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"
    );
}