gunnar-proto 3.0.0

The .proto files for gunnar's gRPC control-plane surface, and their tonic-build output.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
//! # gunnar-proto: the control-plane surface
//!
//! The `.proto` files in `proto/gunnar/v1/` and their `tonic-build` output.
//! This crate is generated code plus a small amount of hand-written glue; it
//! holds no behaviour. The service implementations live in `gunnar-grpc`.
//!
//! ## What is here
//!
//! Seven services:
//!
//! | service | what it owns |
//! |---|---|
//! | [`repos_client`] / [`repos_server`] | namespaces and repositories, i.e. the catalogue |
//! | [`refs_client`] / [`refs_server`] | refs, commit log, object reads, diffs. READ ONLY |
//! | [`access_client`] / [`access_server`] | principals, public keys, grants |
//! | [`policy_client`] / [`policy_server`] | branch protection, ref-name policy, gates |
//! | [`events_client`] / [`events_server`] | `Watch`, the server-streaming event feed |
//! | [`admin_client`] / [`admin_server`] | health, stats, storage backend info |
//! | [`mirror_client`] / [`mirror_server`] | pull-mirroring a remote forge |
//! | [`bootstrap_client`] / [`bootstrap_server`] | first-boot enrolment; the ONE unauthenticated service |
//! | [`forge_client`] / [`forge_server`] | issues, change proposals and review, as signed events |
//! | [`webhooks_client`] / [`webhooks_server`] | outbound webhook delivery to external URLs |
//!
//! ## Two invariants that hold across the whole surface
//!
//! **Namespaces are first class.** Every repo-scoped request and every
//! repo-scoped event carries a [`RepoPath`] of `<namespace>/<repo>`, matching
//! the SSH path `git@host:<namespace>/<repo>.git`. Multi-tenancy is in from
//! day 1 because adding it later would mean migrating every stored path and
//! every ACL row; adding it now costs one field.
//!
//! **No `google.protobuf.Any`.** [`Event`] carries an explicit `oneof` of named
//! payloads. A UI must be able to render an event without a type registry.
//!
//! ## What is deliberately absent
//!
//! There is no RPC that moves a ref. Refs move through `git-receive-pack` on
//! the SSH listener, behind the policy gate. A `Refs.Set` would route a
//! push around every gate, so it does not exist — see [`refs_server`].
#![doc(
    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]

// Generated by `build.rs`. Both halves are built: dwarves and korp consume the
// client, `gunnar-grpc` implements the server.
#[allow(clippy::doc_overindented_list_items, clippy::doc_lazy_continuation)]
mod generated {
    tonic::include_proto!("gunnar.v1");
}

pub use generated::*;

/// `gunnar/<version>`, the agent string reported by `Admin.Health`, written
/// into every [`PushAttestation`], and advertised as `agent=` on the git wire.
pub const AGENT: &str = concat!("gunnar/", env!("CARGO_PKG_VERSION"));

/// `HealthResponse.status` of a box with nothing to act on.
pub const HEALTH_SERVING: &str = "serving";
/// `HealthResponse.status` of a box that serves and needs an operator (see its alarms).
pub const HEALTH_MAINTENANCE: &str = "maintenance";

/// The all-zeroes oid: git's "this ref did not exist" / "this ref is being
/// deleted" sentinel. Length differs per hash kind, so use [`null_oid`].
pub fn null_oid(kind: HashKind) -> &'static str {
    match kind {
        HashKind::Sha256 => "0000000000000000000000000000000000000000000000000000000000000000",
        // Unspecified is treated as sha1 here on purpose: an unspecified hash
        // kind on the wire comes from an older peer, and sha1 is what those
        // speak. Nothing writes an unspecified kind.
        HashKind::Sha1 | HashKind::Unspecified => "0000000000000000000000000000000000000000",
    }
}

/// Most components a repository path may have: up to three namespace levels
/// plus the repository name.
///
/// **This must equal `gunnar_core::sandbox::MAX_COMPONENTS`**, and there is no
/// construction that keeps it so: the two crates cannot depend on each other
/// (see `tests/nested_namespace.rs`). `the_component_bound_is_the_sandboxs_and_
/// not_a_second_number` is the guard that says they still agree; it reads the
/// real constant, so it goes red the day either side moves.
pub const MAX_COMPONENTS: usize = 4;

/// Why a `<namespace>/<repo>` string was rejected.
///
/// This is a *shape* check, not the SSH path sandbox. That one guards a
/// resolved filesystem-ish path and lives in `gunnar-core`. This one exists so
/// a malformed path is rejected at the edge of the control plane with a
/// specific reason rather than becoming a confusing NotFound. It is
/// deliberately the **laxer** of the two: everything the sandbox accepts must
/// pass here, and the sandbox itself is what refuses the rest one layer
/// down.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepoPathError {
    Empty,
    /// No `/` at all, or a `/` in the repository half, which is one component
    /// by definition however deeply the namespace nests.
    NotTwoSegments,
    EmptyNamespace,
    EmptyRepo,
    /// `.`, `..`, a NUL, a control character, a backslash or a colon.
    IllegalCharacter(&'static str),
    /// More than [`MAX_COMPONENTS`] components: namespace levels plus the
    /// repository name.
    TooManyComponents,
}

impl std::fmt::Display for RepoPathError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Empty => f.write_str("repository path is empty"),
            Self::NotTwoSegments => {
                f.write_str("repository path must be exactly `<namespace>/<repo>`")
            }
            Self::EmptyNamespace => f.write_str("namespace is empty"),
            Self::EmptyRepo => f.write_str("repository name is empty"),
            Self::IllegalCharacter(what) => write!(f, "illegal in a repository path: {what}"),
            Self::TooManyComponents => write!(
                f,
                "repository path has more than {MAX_COMPONENTS} components"
            ),
        }
    }
}

impl std::error::Error for RepoPathError {}

/// **The one renderer of `gunnar issuer show`.**
///
/// Both halves of the verb print through this: the offline one, which builds
/// this message out of the data root under its feet, and the `--at` one, which
/// receives it over `Admin.IssuerInfo`. The lines are therefore identical by
/// construction rather than by two people keeping two `println!` blocks in
/// step — and "the box answers something different over the wire than it says
/// on its own disk" is not a state this verb can reach.
///
/// It lives in `gunnar-proto` because that is the one crate both the server
/// and the control client already depend on, and because the wire message IS
/// the shape of the answer; a renderer beside it in either crate would make
/// the other one the copy.
impl IssuerInfoResponse {
    /// The `issuer show` report, ending in a newline.
    ///
    /// `pem` additionally prints the issuer certificate itself, so it can be
    /// piped straight to a client that needs to pin it.
    pub fn render(&self, pem: bool) -> String {
        let mut out = String::new();
        if self.has_issuer {
            out.push_str(&format!(
                "issuer\t{}\t{}\t{}\n",
                self.subject, self.fingerprint, self.origin
            ));
            if pem {
                out.push_str(&self.cert_pem);
                if !self.cert_pem.ends_with('\n') {
                    out.push('\n');
                }
            }
        } else {
            out.push_str("no issuer. `gunnar issuer create` mints one\n");
        }
        // Printed even when it holds only the issuer, because "which CAs does
        // this server verify against" is a different question from "which one
        // does it mint with", and an operator mid-rotation is asking the first.
        for ca in &self.trusted {
            out.push_str(&format!(
                "trusted\t{}\t{}\t{}\n",
                ca.fingerprint,
                if ca.is_issuer { "issuer" } else { "ca" },
                ca.label
            ));
        }
        out
    }
}

impl RepoPath {
    pub fn new(namespace: impl Into<String>, repo: impl Into<String>) -> Self {
        Self {
            namespace: namespace.into(),
            repo: repo.into(),
        }
    }

    /// Parse `"<namespace>/<repo>"`, tolerating a trailing `.git` because that
    /// is how the same repo is spelled over SSH.
    ///
    /// **The split is at the LAST `/`.** A namespace may itself be nested, so
    /// `team/sub/repo.git` is `team/sub` and `repo`. Nested
    /// namespaces are first class and the SSH sandbox already serves them. A
    /// `RepoPath` that could not spell one made those repositories invisible to
    /// every gRPC consumer while `git` cloned them happily.
    ///
    /// Rejects: empty halves, no `/` at all, more than [`MAX_COMPONENTS`]
    /// components, `.`/`..`, NUL, control characters, `\` and `:`.
    pub fn parse(s: &str) -> Result<Self, RepoPathError> {
        let s = s.strip_suffix(".git").unwrap_or(s);
        if s.is_empty() {
            return Err(RepoPathError::Empty);
        }
        let Some((namespace, repo)) = s.rsplit_once('/') else {
            return Err(RepoPathError::NotTwoSegments);
        };
        if namespace.is_empty() {
            return Err(RepoPathError::EmptyNamespace);
        }
        if repo.is_empty() {
            return Err(RepoPathError::EmptyRepo);
        }
        let path = Self::new(namespace, repo);
        path.validate()?;
        Ok(path)
    }

    /// The same checks [`RepoPath::parse`] applies, against an already-split
    /// path, which is the form every request carries.
    pub fn validate(&self) -> Result<(), RepoPathError> {
        if self.namespace.is_empty() {
            return Err(RepoPathError::EmptyNamespace);
        }
        if self.repo.is_empty() {
            return Err(RepoPathError::EmptyRepo);
        }
        // The repository half is exactly one component, however deeply the
        // namespace nests. A `/` here is a path that was split in the wrong
        // place; `parse` splits at the last one for precisely this reason.
        if self.repo.contains('/') {
            return Err(RepoPathError::NotTwoSegments);
        }

        // Content before count, matching `gunnar_core::sandbox`: a traversal is
        // reported as a traversal rather than as "too many components", because
        // the reason ends up in a log line an operator has to act on.
        let mut components = 0usize;
        for segment in self.namespace.split('/').chain([self.repo.as_str()]) {
            components += 1;
            // A leading, trailing or doubled `/` in the namespace. The repo
            // half was ruled out above, so this can only be a namespace level.
            if segment.is_empty() {
                return Err(RepoPathError::EmptyNamespace);
            }
            if segment == "." || segment == ".." {
                return Err(RepoPathError::IllegalCharacter("`.` or `..`"));
            }
            if segment.contains('\0') {
                return Err(RepoPathError::IllegalCharacter("a NUL byte"));
            }
            if segment.chars().any(char::is_control) {
                return Err(RepoPathError::IllegalCharacter("a control character"));
            }
            if segment.contains('\\') {
                return Err(RepoPathError::IllegalCharacter("a backslash"));
            }
            if segment.contains(':') {
                return Err(RepoPathError::IllegalCharacter("a colon"));
            }
        }
        if components > MAX_COMPONENTS {
            return Err(RepoPathError::TooManyComponents);
        }
        Ok(())
    }
}

impl std::fmt::Display for RepoPath {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}/{}", self.namespace, self.repo)
    }
}

impl Event {
    /// The [`EventKind`] a payload belongs to.
    ///
    /// `Event.kind` is a separate field from the `oneof` so that a subscriber
    /// can filter without matching, but two sources of truth is exactly the
    /// shape that drifts. This is the one writer both go through (one
    /// implementation), and [`Event::kind_matches_payload`] is the guard that
    /// says they agree.
    pub fn kind_of(payload: &event::Payload) -> EventKind {
        match payload {
            event::Payload::RefUpdated(_) => EventKind::RefUpdated,
            event::Payload::RefDeleted(_) => EventKind::RefDeleted,
            event::Payload::PushRejected(_) => EventKind::PushRejected,
            event::Payload::RepoCreated(_) => EventKind::RepoCreated,
            event::Payload::RepoDeleted(_) => EventKind::RepoDeleted,
            event::Payload::MirrorSynced(_) => EventKind::MirrorSynced,
            event::Payload::KeyUsed(_) => EventKind::KeyUsed,
            event::Payload::PushAttestation(_) => EventKind::PushAttestation,
            event::Payload::Gap(_) => EventKind::Gap,
            event::Payload::ReplicationFailed(_) => EventKind::ReplicationFailed,
            event::Payload::PullRequestOpened(_) => EventKind::PullRequestOpened,
            event::Payload::PullRequestMerged(_) => EventKind::PullRequestMerged,
            event::Payload::PullRequestClosed(_) => EventKind::PullRequestClosed,
            event::Payload::StreamOpened(_) => EventKind::StreamOpened,
            event::Payload::VisibilityChanged(_) => EventKind::VisibilityChanged,
            event::Payload::EncryptedChanged(_) => EventKind::EncryptedChanged,
        }
    }

    /// Build an event whose `kind` cannot disagree with its payload.
    pub fn new(seq: u64, at_unix_ms: i64, payload: event::Payload) -> Self {
        Self {
            seq,
            at_unix_ms,
            kind: Self::kind_of(&payload) as i32,
            payload: Some(payload),
        }
    }

    /// False when `kind` and the payload variant disagree, or when the payload
    /// is missing. Used by tests and by the bus as a cheap self-check.
    pub fn kind_matches_payload(&self) -> bool {
        match &self.payload {
            Some(payload) => self.kind == Self::kind_of(payload) as i32,
            None => false,
        }
    }

    /// The repository this event is about, when it is about one. `KeyUsed` may
    /// have none (an auth event is not repo-scoped), and neither `EventGap` nor
    /// `StreamOpened` ever has: both are facts about the STREAM, not about a
    /// repository.
    pub fn repo_path(&self) -> Option<&RepoPath> {
        match self.payload.as_ref()? {
            event::Payload::RefUpdated(e) => e.repo.as_ref(),
            event::Payload::RefDeleted(e) => e.repo.as_ref(),
            event::Payload::PushRejected(e) => e.repo.as_ref(),
            event::Payload::RepoCreated(e) => e.repo.as_ref()?.path.as_ref(),
            event::Payload::RepoDeleted(e) => e.repo.as_ref(),
            event::Payload::MirrorSynced(e) => e.repo.as_ref(),
            event::Payload::KeyUsed(e) => e.repo.as_ref(),
            event::Payload::PushAttestation(e) => e.repo.as_ref(),
            event::Payload::Gap(_) => None,
            event::Payload::ReplicationFailed(e) => e.repo.as_ref(),
            event::Payload::PullRequestOpened(e) => e.repo.as_ref(),
            event::Payload::PullRequestMerged(e) => e.repo.as_ref(),
            event::Payload::PullRequestClosed(e) => e.repo.as_ref(),
            event::Payload::StreamOpened(_) => None,
            event::Payload::VisibilityChanged(e) => e.repo.as_ref(),
            event::Payload::EncryptedChanged(e) => e.repo.as_ref(),
        }
    }
}

/// **The most of a peer's CLAIMED length this process reserves before the bytes arrive**
/// (2026-09-16, lane AUDIT-TWIN).
///
/// `LogChunk.blob_len` is a number the other end sent. Every reassembler in the tree used it
/// directly — `Vec::with_capacity(chunk.blob_len as usize)` — in `Bootstrap.Restore`'s handler,
/// in the twin's `Mirror.LogFetch` client and in the CLI's replay pump. A frame's blob is a
/// whole pack and may legitimately be gigabytes, so there is no honest ceiling on the VALUE;
/// there is one on what may be reserved on the strength of it, because the claim is checked
/// only after the item ends (`{} of {} blob bytes arrived`) — which is after the allocator has
/// already been asked.
///
/// What that cost, on the two shapes that matter:
///
/// * `u64::MAX` — `Vec::with_capacity` panics `capacity overflow` (over `isize::MAX`), and the
///   panic is inside a tonic handler, so the call dies and takes its connection with it;
/// * a merely impossible number, say 1 TiB on a 1 GiB appliance — no overflow, so the allocator
///   is asked for it, Linux's default overcommit heuristic refuses an obviously impossible
///   request, and a Rust allocation failure is not catchable: the PROCESS ABORTS.
///
/// `Bootstrap.Restore` is served WITHOUT a credential while a box is fresh — which is exactly
/// when it is sitting on the network waiting to be enrolled — so that abort is unauthenticated.
///
/// 8 MiB is eight `CHUNK_BYTES` pieces: the common case still reserves its blob in one go, and
/// a lie costs 8 MiB and a few `Vec` growths before the length check refuses it by name.
pub const TRUSTED_PREALLOC_BYTES: u64 = 8 << 20;

/// The tunnel answer as `key value` lines — what the SSH door prints and
/// `gunnar control tunnel` parses back ([`parse_tunnel_answer`]): the output of the
/// SSH door's `gunnar-tunnel-address` (2026-09-21, T17). One writer, one reader, both here.
pub fn render_tunnel_answer(a: &AssignTunnelAddressResponse) -> String {
    format!(
        "wg_public_key {}\naddress {}\nchanged {}\ntunnel_up {}\ndetail {}\n",
        a.wg_public_key,
        if a.address.is_empty() {
            "-"
        } else {
            &a.address
        },
        a.changed,
        a.tunnel_up,
        a.detail.replace('\n', " ")
    )
}

/// The inverse of [`render_tunnel_answer`]; `error: …` lines are the door's
/// refusal and come back as `Err`.
pub fn parse_tunnel_answer(text: &str) -> std::result::Result<AssignTunnelAddressResponse, String> {
    if let Some(why) = text.lines().find_map(|l| l.strip_prefix("error: ")) {
        return Err(why.to_string());
    }
    let mut a = AssignTunnelAddressResponse::default();
    for line in text.lines() {
        let (k, v) = line.split_once(' ').unwrap_or((line, ""));
        match k {
            "wg_public_key" => a.wg_public_key = v.to_string(),
            "address" => {
                a.address = if v == "-" {
                    String::new()
                } else {
                    v.to_string()
                }
            }
            "changed" => a.changed = v == "true",
            "tunnel_up" => a.tunnel_up = v == "true",
            "detail" => a.detail = v.to_string(),
            _ => {}
        }
    }
    if a.wg_public_key.is_empty() {
        return Err(format!("not a tunnel answer: {text:?}"));
    }
    Ok(a)
}

/// How much to reserve for a blob the other end says is `claimed` bytes long. Never more than
/// [`TRUSTED_PREALLOC_BYTES`]; the rest is grown as the bytes actually arrive.
pub fn reserve_for(claimed: u64) -> usize {
    claimed.min(TRUSTED_PREALLOC_BYTES) as usize
}

/// `812 MB/s` / `1.9 GB/s` — a `disk_seq_*_bps` field read as a human reads
/// it, in decimal units, as disks are sold.
///
/// It lives HERE, beside the fields, because two callers need the same
/// spelling and they are in different crates: the server's own
/// `disktest::fmt_rate` writes the console row, and `gunnar control capacity`
/// prints the wire field. A second spelling of a unit is a drift with no
/// symptom until two numbers that are equal read differently.
pub fn fmt_rate_bps(bps: u64) -> String {
    let v = bps as f64;
    if v >= 1e9 {
        format!("{:.1} GB/s", v / 1e9)
    } else if v >= 1e6 {
        format!("{:.0} MB/s", v / 1e6)
    } else {
        format!("{:.0} kB/s", v / 1e3)
    }
}

#[cfg(test)]
mod rate_tests {
    /// The same three cases `gunnar_server::disktest::fmt_rate` is asserted
    /// on, so the two spellings cannot drift apart unnoticed.
    #[test]
    fn a_rate_reads_in_the_units_disks_are_sold_in() {
        assert_eq!(super::fmt_rate_bps(812_000_000), "812 MB/s");
        assert_eq!(super::fmt_rate_bps(1_900_000_000), "1.9 GB/s");
        assert_eq!(super::fmt_rate_bps(512_000), "512 kB/s");
    }
}

#[cfg(test)]
mod prealloc_tests {
    use super::*;

    /// **A length a peer sent is not a length to allocate.**
    ///
    /// Red with the old expression (`claimed as usize`): `u64::MAX` reaches
    /// `Vec::with_capacity` and panics `capacity overflow` inside whatever handler is
    /// reassembling — and `Bootstrap.Restore`'s handler needs no credential on a fresh box.
    #[test]
    fn a_claimed_length_never_becomes_a_reservation_on_its_own() {
        for claimed in [u64::MAX, u64::MAX / 2, 1 << 40, (8 << 20) + 1] {
            let reserved = reserve_for(claimed);
            assert!(
                reserved as u64 <= TRUSTED_PREALLOC_BYTES,
                "{claimed} reserved {reserved}"
            );
            // The reservation is one the allocator will actually take.
            let v: Vec<u8> = Vec::with_capacity(reserved);
            assert!(v.capacity() >= reserved);
        }
        // An honest small length is still reserved exactly, so the common case allocates once.
        for claimed in [0u64, 1, 4096, 8 << 20] {
            assert_eq!(reserve_for(claimed) as u64, claimed);
        }
    }
}