Skip to main content

gunnar_proto/
lib.rs

1//! # gunnar-proto: the control-plane surface
2//!
3//! The `.proto` files in `proto/gunnar/v1/` and their `tonic-build` output.
4//! This crate is generated code plus a small amount of hand-written glue; it
5//! holds no behaviour. The service implementations live in `gunnar-grpc`.
6//!
7//! ## What is here
8//!
9//! Seven services:
10//!
11//! | service | what it owns |
12//! |---|---|
13//! | [`repos_client`] / [`repos_server`] | namespaces and repositories, i.e. the catalogue |
14//! | [`refs_client`] / [`refs_server`] | refs, commit log, object reads, diffs. READ ONLY |
15//! | [`access_client`] / [`access_server`] | principals, public keys, grants |
16//! | [`policy_client`] / [`policy_server`] | branch protection, ref-name policy, gates |
17//! | [`events_client`] / [`events_server`] | `Watch`, the server-streaming event feed |
18//! | [`admin_client`] / [`admin_server`] | health, stats, storage backend info |
19//! | [`mirror_client`] / [`mirror_server`] | pull-mirroring a remote forge |
20//! | [`bootstrap_client`] / [`bootstrap_server`] | first-boot enrolment; the ONE unauthenticated service |
21//! | [`forge_client`] / [`forge_server`] | issues, change proposals and review, as signed events |
22//! | [`webhooks_client`] / [`webhooks_server`] | outbound webhook delivery to external URLs |
23//!
24//! ## Two invariants that hold across the whole surface
25//!
26//! **Namespaces are first class.** Every repo-scoped request and every
27//! repo-scoped event carries a [`RepoPath`] of `<namespace>/<repo>`, matching
28//! the SSH path `git@host:<namespace>/<repo>.git`. Multi-tenancy is in from
29//! day 1 because adding it later would mean migrating every stored path and
30//! every ACL row; adding it now costs one field.
31//!
32//! **No `google.protobuf.Any`.** [`Event`] carries an explicit `oneof` of named
33//! payloads. A UI must be able to render an event without a type registry.
34//!
35//! ## What is deliberately absent
36//!
37//! There is no RPC that moves a ref. Refs move through `git-receive-pack` on
38//! the SSH listener, behind the policy gate. A `Refs.Set` would route a
39//! push around every gate, so it does not exist — see [`refs_server`].
40#![doc(
41    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
42    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
43)]
44
45// Generated by `build.rs`. Both halves are built: dwarves and korp consume the
46// client, `gunnar-grpc` implements the server.
47#[allow(clippy::doc_overindented_list_items, clippy::doc_lazy_continuation)]
48mod generated {
49    tonic::include_proto!("gunnar.v1");
50}
51
52pub use generated::*;
53
54/// `gunnar/<version>`, the agent string reported by `Admin.Health`, written
55/// into every [`PushAttestation`], and advertised as `agent=` on the git wire.
56pub const AGENT: &str = concat!("gunnar/", env!("CARGO_PKG_VERSION"));
57
58/// `HealthResponse.status` of a box with nothing to act on.
59pub const HEALTH_SERVING: &str = "serving";
60/// `HealthResponse.status` of a box that serves and needs an operator (see its alarms).
61pub const HEALTH_MAINTENANCE: &str = "maintenance";
62
63/// The all-zeroes oid: git's "this ref did not exist" / "this ref is being
64/// deleted" sentinel. Length differs per hash kind, so use [`null_oid`].
65pub fn null_oid(kind: HashKind) -> &'static str {
66    match kind {
67        HashKind::Sha256 => "0000000000000000000000000000000000000000000000000000000000000000",
68        // Unspecified is treated as sha1 here on purpose: an unspecified hash
69        // kind on the wire comes from an older peer, and sha1 is what those
70        // speak. Nothing writes an unspecified kind.
71        HashKind::Sha1 | HashKind::Unspecified => "0000000000000000000000000000000000000000",
72    }
73}
74
75/// Most components a repository path may have: up to three namespace levels
76/// plus the repository name.
77///
78/// **This must equal `gunnar_core::sandbox::MAX_COMPONENTS`**, and there is no
79/// construction that keeps it so: the two crates cannot depend on each other
80/// (see `tests/nested_namespace.rs`). `the_component_bound_is_the_sandboxs_and_
81/// not_a_second_number` is the guard that says they still agree; it reads the
82/// real constant, so it goes red the day either side moves.
83pub const MAX_COMPONENTS: usize = 4;
84
85/// Why a `<namespace>/<repo>` string was rejected.
86///
87/// This is a *shape* check, not the SSH path sandbox. That one guards a
88/// resolved filesystem-ish path and lives in `gunnar-core`. This one exists so
89/// a malformed path is rejected at the edge of the control plane with a
90/// specific reason rather than becoming a confusing NotFound. It is
91/// deliberately the **laxer** of the two: everything the sandbox accepts must
92/// pass here, and the sandbox itself is what refuses the rest one layer
93/// down.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum RepoPathError {
96    Empty,
97    /// No `/` at all, or a `/` in the repository half, which is one component
98    /// by definition however deeply the namespace nests.
99    NotTwoSegments,
100    EmptyNamespace,
101    EmptyRepo,
102    /// `.`, `..`, a NUL, a control character, a backslash or a colon.
103    IllegalCharacter(&'static str),
104    /// More than [`MAX_COMPONENTS`] components: namespace levels plus the
105    /// repository name.
106    TooManyComponents,
107}
108
109impl std::fmt::Display for RepoPathError {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Self::Empty => f.write_str("repository path is empty"),
113            Self::NotTwoSegments => {
114                f.write_str("repository path must be exactly `<namespace>/<repo>`")
115            }
116            Self::EmptyNamespace => f.write_str("namespace is empty"),
117            Self::EmptyRepo => f.write_str("repository name is empty"),
118            Self::IllegalCharacter(what) => write!(f, "illegal in a repository path: {what}"),
119            Self::TooManyComponents => write!(
120                f,
121                "repository path has more than {MAX_COMPONENTS} components"
122            ),
123        }
124    }
125}
126
127impl std::error::Error for RepoPathError {}
128
129/// **The one renderer of `gunnar issuer show`.**
130///
131/// Both halves of the verb print through this: the offline one, which builds
132/// this message out of the data root under its feet, and the `--at` one, which
133/// receives it over `Admin.IssuerInfo`. The lines are therefore identical by
134/// construction rather than by two people keeping two `println!` blocks in
135/// step — and "the box answers something different over the wire than it says
136/// on its own disk" is not a state this verb can reach.
137///
138/// It lives in `gunnar-proto` because that is the one crate both the server
139/// and the control client already depend on, and because the wire message IS
140/// the shape of the answer; a renderer beside it in either crate would make
141/// the other one the copy.
142impl IssuerInfoResponse {
143    /// The `issuer show` report, ending in a newline.
144    ///
145    /// `pem` additionally prints the issuer certificate itself, so it can be
146    /// piped straight to a client that needs to pin it.
147    pub fn render(&self, pem: bool) -> String {
148        let mut out = String::new();
149        if self.has_issuer {
150            out.push_str(&format!(
151                "issuer\t{}\t{}\t{}\n",
152                self.subject, self.fingerprint, self.origin
153            ));
154            if pem {
155                out.push_str(&self.cert_pem);
156                if !self.cert_pem.ends_with('\n') {
157                    out.push('\n');
158                }
159            }
160        } else {
161            out.push_str("no issuer. `gunnar issuer create` mints one\n");
162        }
163        // Printed even when it holds only the issuer, because "which CAs does
164        // this server verify against" is a different question from "which one
165        // does it mint with", and an operator mid-rotation is asking the first.
166        for ca in &self.trusted {
167            out.push_str(&format!(
168                "trusted\t{}\t{}\t{}\n",
169                ca.fingerprint,
170                if ca.is_issuer { "issuer" } else { "ca" },
171                ca.label
172            ));
173        }
174        out
175    }
176}
177
178impl RepoPath {
179    pub fn new(namespace: impl Into<String>, repo: impl Into<String>) -> Self {
180        Self {
181            namespace: namespace.into(),
182            repo: repo.into(),
183        }
184    }
185
186    /// Parse `"<namespace>/<repo>"`, tolerating a trailing `.git` because that
187    /// is how the same repo is spelled over SSH.
188    ///
189    /// **The split is at the LAST `/`.** A namespace may itself be nested, so
190    /// `team/sub/repo.git` is `team/sub` and `repo`. Nested
191    /// namespaces are first class and the SSH sandbox already serves them. A
192    /// `RepoPath` that could not spell one made those repositories invisible to
193    /// every gRPC consumer while `git` cloned them happily.
194    ///
195    /// Rejects: empty halves, no `/` at all, more than [`MAX_COMPONENTS`]
196    /// components, `.`/`..`, NUL, control characters, `\` and `:`.
197    pub fn parse(s: &str) -> Result<Self, RepoPathError> {
198        let s = s.strip_suffix(".git").unwrap_or(s);
199        if s.is_empty() {
200            return Err(RepoPathError::Empty);
201        }
202        let Some((namespace, repo)) = s.rsplit_once('/') else {
203            return Err(RepoPathError::NotTwoSegments);
204        };
205        if namespace.is_empty() {
206            return Err(RepoPathError::EmptyNamespace);
207        }
208        if repo.is_empty() {
209            return Err(RepoPathError::EmptyRepo);
210        }
211        let path = Self::new(namespace, repo);
212        path.validate()?;
213        Ok(path)
214    }
215
216    /// The same checks [`RepoPath::parse`] applies, against an already-split
217    /// path, which is the form every request carries.
218    pub fn validate(&self) -> Result<(), RepoPathError> {
219        if self.namespace.is_empty() {
220            return Err(RepoPathError::EmptyNamespace);
221        }
222        if self.repo.is_empty() {
223            return Err(RepoPathError::EmptyRepo);
224        }
225        // The repository half is exactly one component, however deeply the
226        // namespace nests. A `/` here is a path that was split in the wrong
227        // place; `parse` splits at the last one for precisely this reason.
228        if self.repo.contains('/') {
229            return Err(RepoPathError::NotTwoSegments);
230        }
231
232        // Content before count, matching `gunnar_core::sandbox`: a traversal is
233        // reported as a traversal rather than as "too many components", because
234        // the reason ends up in a log line an operator has to act on.
235        let mut components = 0usize;
236        for segment in self.namespace.split('/').chain([self.repo.as_str()]) {
237            components += 1;
238            // A leading, trailing or doubled `/` in the namespace. The repo
239            // half was ruled out above, so this can only be a namespace level.
240            if segment.is_empty() {
241                return Err(RepoPathError::EmptyNamespace);
242            }
243            if segment == "." || segment == ".." {
244                return Err(RepoPathError::IllegalCharacter("`.` or `..`"));
245            }
246            if segment.contains('\0') {
247                return Err(RepoPathError::IllegalCharacter("a NUL byte"));
248            }
249            if segment.chars().any(char::is_control) {
250                return Err(RepoPathError::IllegalCharacter("a control character"));
251            }
252            if segment.contains('\\') {
253                return Err(RepoPathError::IllegalCharacter("a backslash"));
254            }
255            if segment.contains(':') {
256                return Err(RepoPathError::IllegalCharacter("a colon"));
257            }
258        }
259        if components > MAX_COMPONENTS {
260            return Err(RepoPathError::TooManyComponents);
261        }
262        Ok(())
263    }
264}
265
266impl std::fmt::Display for RepoPath {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        write!(f, "{}/{}", self.namespace, self.repo)
269    }
270}
271
272impl Event {
273    /// The [`EventKind`] a payload belongs to.
274    ///
275    /// `Event.kind` is a separate field from the `oneof` so that a subscriber
276    /// can filter without matching, but two sources of truth is exactly the
277    /// shape that drifts. This is the one writer both go through (one
278    /// implementation), and [`Event::kind_matches_payload`] is the guard that
279    /// says they agree.
280    pub fn kind_of(payload: &event::Payload) -> EventKind {
281        match payload {
282            event::Payload::RefUpdated(_) => EventKind::RefUpdated,
283            event::Payload::RefDeleted(_) => EventKind::RefDeleted,
284            event::Payload::PushRejected(_) => EventKind::PushRejected,
285            event::Payload::RepoCreated(_) => EventKind::RepoCreated,
286            event::Payload::RepoDeleted(_) => EventKind::RepoDeleted,
287            event::Payload::MirrorSynced(_) => EventKind::MirrorSynced,
288            event::Payload::KeyUsed(_) => EventKind::KeyUsed,
289            event::Payload::PushAttestation(_) => EventKind::PushAttestation,
290            event::Payload::Gap(_) => EventKind::Gap,
291            event::Payload::ReplicationFailed(_) => EventKind::ReplicationFailed,
292            event::Payload::PullRequestOpened(_) => EventKind::PullRequestOpened,
293            event::Payload::PullRequestMerged(_) => EventKind::PullRequestMerged,
294            event::Payload::PullRequestClosed(_) => EventKind::PullRequestClosed,
295            event::Payload::StreamOpened(_) => EventKind::StreamOpened,
296            event::Payload::VisibilityChanged(_) => EventKind::VisibilityChanged,
297            event::Payload::EncryptedChanged(_) => EventKind::EncryptedChanged,
298        }
299    }
300
301    /// Build an event whose `kind` cannot disagree with its payload.
302    pub fn new(seq: u64, at_unix_ms: i64, payload: event::Payload) -> Self {
303        Self {
304            seq,
305            at_unix_ms,
306            kind: Self::kind_of(&payload) as i32,
307            payload: Some(payload),
308        }
309    }
310
311    /// False when `kind` and the payload variant disagree, or when the payload
312    /// is missing. Used by tests and by the bus as a cheap self-check.
313    pub fn kind_matches_payload(&self) -> bool {
314        match &self.payload {
315            Some(payload) => self.kind == Self::kind_of(payload) as i32,
316            None => false,
317        }
318    }
319
320    /// The repository this event is about, when it is about one. `KeyUsed` may
321    /// have none (an auth event is not repo-scoped), and neither `EventGap` nor
322    /// `StreamOpened` ever has: both are facts about the STREAM, not about a
323    /// repository.
324    pub fn repo_path(&self) -> Option<&RepoPath> {
325        match self.payload.as_ref()? {
326            event::Payload::RefUpdated(e) => e.repo.as_ref(),
327            event::Payload::RefDeleted(e) => e.repo.as_ref(),
328            event::Payload::PushRejected(e) => e.repo.as_ref(),
329            event::Payload::RepoCreated(e) => e.repo.as_ref()?.path.as_ref(),
330            event::Payload::RepoDeleted(e) => e.repo.as_ref(),
331            event::Payload::MirrorSynced(e) => e.repo.as_ref(),
332            event::Payload::KeyUsed(e) => e.repo.as_ref(),
333            event::Payload::PushAttestation(e) => e.repo.as_ref(),
334            event::Payload::Gap(_) => None,
335            event::Payload::ReplicationFailed(e) => e.repo.as_ref(),
336            event::Payload::PullRequestOpened(e) => e.repo.as_ref(),
337            event::Payload::PullRequestMerged(e) => e.repo.as_ref(),
338            event::Payload::PullRequestClosed(e) => e.repo.as_ref(),
339            event::Payload::StreamOpened(_) => None,
340            event::Payload::VisibilityChanged(e) => e.repo.as_ref(),
341            event::Payload::EncryptedChanged(e) => e.repo.as_ref(),
342        }
343    }
344}
345
346/// **The most of a peer's CLAIMED length this process reserves before the bytes arrive**
347/// (2026-09-16, lane AUDIT-TWIN).
348///
349/// `LogChunk.blob_len` is a number the other end sent. Every reassembler in the tree used it
350/// directly — `Vec::with_capacity(chunk.blob_len as usize)` — in `Bootstrap.Restore`'s handler,
351/// in the twin's `Mirror.LogFetch` client and in the CLI's replay pump. A frame's blob is a
352/// whole pack and may legitimately be gigabytes, so there is no honest ceiling on the VALUE;
353/// there is one on what may be reserved on the strength of it, because the claim is checked
354/// only after the item ends (`{} of {} blob bytes arrived`) — which is after the allocator has
355/// already been asked.
356///
357/// What that cost, on the two shapes that matter:
358///
359/// * `u64::MAX` — `Vec::with_capacity` panics `capacity overflow` (over `isize::MAX`), and the
360///   panic is inside a tonic handler, so the call dies and takes its connection with it;
361/// * a merely impossible number, say 1 TiB on a 1 GiB appliance — no overflow, so the allocator
362///   is asked for it, Linux's default overcommit heuristic refuses an obviously impossible
363///   request, and a Rust allocation failure is not catchable: the PROCESS ABORTS.
364///
365/// `Bootstrap.Restore` is served WITHOUT a credential while a box is fresh — which is exactly
366/// when it is sitting on the network waiting to be enrolled — so that abort is unauthenticated.
367///
368/// 8 MiB is eight `CHUNK_BYTES` pieces: the common case still reserves its blob in one go, and
369/// a lie costs 8 MiB and a few `Vec` growths before the length check refuses it by name.
370pub const TRUSTED_PREALLOC_BYTES: u64 = 8 << 20;
371
372/// The tunnel answer as `key value` lines — what the SSH door prints and
373/// `gunnar control tunnel` parses back ([`parse_tunnel_answer`]): the output of the
374/// SSH door's `gunnar-tunnel-address` (2026-09-21, T17). One writer, one reader, both here.
375pub fn render_tunnel_answer(a: &AssignTunnelAddressResponse) -> String {
376    format!(
377        "wg_public_key {}\naddress {}\nchanged {}\ntunnel_up {}\ndetail {}\n",
378        a.wg_public_key,
379        if a.address.is_empty() {
380            "-"
381        } else {
382            &a.address
383        },
384        a.changed,
385        a.tunnel_up,
386        a.detail.replace('\n', " ")
387    )
388}
389
390/// The inverse of [`render_tunnel_answer`]; `error: …` lines are the door's
391/// refusal and come back as `Err`.
392pub fn parse_tunnel_answer(text: &str) -> std::result::Result<AssignTunnelAddressResponse, String> {
393    if let Some(why) = text.lines().find_map(|l| l.strip_prefix("error: ")) {
394        return Err(why.to_string());
395    }
396    let mut a = AssignTunnelAddressResponse::default();
397    for line in text.lines() {
398        let (k, v) = line.split_once(' ').unwrap_or((line, ""));
399        match k {
400            "wg_public_key" => a.wg_public_key = v.to_string(),
401            "address" => {
402                a.address = if v == "-" {
403                    String::new()
404                } else {
405                    v.to_string()
406                }
407            }
408            "changed" => a.changed = v == "true",
409            "tunnel_up" => a.tunnel_up = v == "true",
410            "detail" => a.detail = v.to_string(),
411            _ => {}
412        }
413    }
414    if a.wg_public_key.is_empty() {
415        return Err(format!("not a tunnel answer: {text:?}"));
416    }
417    Ok(a)
418}
419
420/// How much to reserve for a blob the other end says is `claimed` bytes long. Never more than
421/// [`TRUSTED_PREALLOC_BYTES`]; the rest is grown as the bytes actually arrive.
422pub fn reserve_for(claimed: u64) -> usize {
423    claimed.min(TRUSTED_PREALLOC_BYTES) as usize
424}
425
426/// `812 MB/s` / `1.9 GB/s` — a `disk_seq_*_bps` field read as a human reads
427/// it, in decimal units, as disks are sold.
428///
429/// It lives HERE, beside the fields, because two callers need the same
430/// spelling and they are in different crates: the server's own
431/// `disktest::fmt_rate` writes the console row, and `gunnar control capacity`
432/// prints the wire field. A second spelling of a unit is a drift with no
433/// symptom until two numbers that are equal read differently.
434pub fn fmt_rate_bps(bps: u64) -> String {
435    let v = bps as f64;
436    if v >= 1e9 {
437        format!("{:.1} GB/s", v / 1e9)
438    } else if v >= 1e6 {
439        format!("{:.0} MB/s", v / 1e6)
440    } else {
441        format!("{:.0} kB/s", v / 1e3)
442    }
443}
444
445#[cfg(test)]
446mod rate_tests {
447    /// The same three cases `gunnar_server::disktest::fmt_rate` is asserted
448    /// on, so the two spellings cannot drift apart unnoticed.
449    #[test]
450    fn a_rate_reads_in_the_units_disks_are_sold_in() {
451        assert_eq!(super::fmt_rate_bps(812_000_000), "812 MB/s");
452        assert_eq!(super::fmt_rate_bps(1_900_000_000), "1.9 GB/s");
453        assert_eq!(super::fmt_rate_bps(512_000), "512 kB/s");
454    }
455}
456
457#[cfg(test)]
458mod prealloc_tests {
459    use super::*;
460
461    /// **A length a peer sent is not a length to allocate.**
462    ///
463    /// Red with the old expression (`claimed as usize`): `u64::MAX` reaches
464    /// `Vec::with_capacity` and panics `capacity overflow` inside whatever handler is
465    /// reassembling — and `Bootstrap.Restore`'s handler needs no credential on a fresh box.
466    #[test]
467    fn a_claimed_length_never_becomes_a_reservation_on_its_own() {
468        for claimed in [u64::MAX, u64::MAX / 2, 1 << 40, (8 << 20) + 1] {
469            let reserved = reserve_for(claimed);
470            assert!(
471                reserved as u64 <= TRUSTED_PREALLOC_BYTES,
472                "{claimed} reserved {reserved}"
473            );
474            // The reservation is one the allocator will actually take.
475            let v: Vec<u8> = Vec::with_capacity(reserved);
476            assert!(v.capacity() >= reserved);
477        }
478        // An honest small length is still reserved exactly, so the common case allocates once.
479        for claimed in [0u64, 1, 4096, 8 << 20] {
480            assert_eq!(reserve_for(claimed) as u64, claimed);
481        }
482    }
483}