Skip to main content

mnem_transport/
protocol.rs

1//! Wire-level protocol constants and capability vocabulary for mnem's
2//! remote transport.
3//!
4//! This module is the freeze-point for the on-wire handshake. Nothing
5//! here knows about HTTP, TLS, tokens, or framing; those live in the
6//! server crate. The types here are the common language two mnem peers
7//! agree on before the first byte of a CAR body hits the socket.
8//!
9//! ## The five primitives frozen in PR 2
10//!
11//! | Name | Purpose |
12//! |---|---|
13//! | [`PROTOCOL_VERSION`] | Single `u32` that every request and response MUST advertise. Bumping this is a breaking change. |
14//! | [`PROTOCOL_HEADER`] | The HTTP header name (`mnem-protocol`) carrying the version. |
15//! | [`CAPABILITIES_HEADER`] | The HTTP header name (`mnem-capabilities`) echoing the agreed capability set. |
16//! | [`Capability`] | Enum of every capability string this codebase knows. Unknown capabilities MUST be tolerated on read. |
17//! | [`parse_capabilities`] / [`serialize_capabilities`] | Free functions for the comma-separated wire form. |
18//!
19//! Capabilities are the exact Git-v2 lesson: if you ship a protocol
20//! without a capability ad, every feature flag becomes a version bump.
21//! With an ad, v0 servers and v1 servers can share a wire as long as
22//! they agree on the intersection of their advertised sets.
23
24// Pedantic doc-length warnings on the module-level doc paragraphs
25// are opinionated; the design prose is deliberately verbose.
26#![allow(clippy::too_long_first_doc_paragraph, clippy::missing_const_for_fn)]
27
28use std::collections::BTreeSet;
29use std::fmt;
30use std::str::FromStr;
31
32/// Frozen wire-protocol version for the mnem remote transport.
33///
34/// Every request and every response MUST carry a [`PROTOCOL_HEADER`]
35/// header whose value parses to this integer. A server that receives a
36/// version it does not implement MUST reject with HTTP 400 and JSON
37/// `{"error": "protocol-version"}`; a client that receives an
38/// unexpected version in a response MUST treat the response as
39/// malformed.
40///
41/// Bumping this constant is a breaking change.
42pub const PROTOCOL_VERSION: u32 = 1;
43
44/// Canonical HTTP header name carrying the protocol version on every
45/// request and response of the remote transport.
46pub const PROTOCOL_HEADER: &str = "mnem-protocol";
47
48/// Canonical HTTP header name carrying the comma-separated list of
49/// capabilities a response was produced under. Clients echo this on
50/// requests that want to force a downgrade (e.g. ignore `delta-fetch`)
51/// and servers echo it on responses so clients can detect a silent
52/// downgrade mid-session.
53pub const CAPABILITIES_HEADER: &str = "mnem-capabilities";
54
55/// Capabilities the mnem remote transport knows about.
56///
57/// Capabilities are stable kebab-case strings on the wire
58/// (`have-set-bloom`, `push-negotiate`, ...). New variants MAY be added
59/// in minor versions; they MUST be additive. Unknown capability
60/// strings MUST be tolerated on read - every parser here and
61/// downstream returns `None` rather than failing on an unrecognised
62/// value.
63///
64/// Roadmap capabilities (documented but not yet used by any code path)
65/// are included so that clients can begin advertising them before the
66/// server-side implementation lands.
67#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
68#[non_exhaustive]
69pub enum Capability {
70    /// Client advertises a bloom-filter have-set. Gates the
71    /// `have-set` field in `fetch-blocks` and `push-blocks` requests.
72    /// PR 2 ships the [`crate::have_set::BloomHaveSet`] reference
73    /// implementation of the serialised shape.
74    HaveSetBloom,
75    /// Client advertises a range-based set-reconciliation have-set.
76    /// Reserved for v0.2; mnem-transport v0.1.0 does not emit these.
77    /// §"Roadmap".
78    HaveSetRbsr,
79    /// Client and server support the push-side have-set negotiation
80    /// (`POST /remote/v1/push-negotiate`). Without this capability,
81    /// `push-blocks` ships every reachable block from the new head.
82    /// Reserved for v0.2.
83    PushNegotiate,
84    /// Client and server support the partial-fetch filter language
85    /// (`filter: { "embed": "omit" }` etc.) in `fetch-blocks` and
86    /// `push-blocks`. Reserved for v0.2. §"Roadmap".
87    FilterSpec,
88    /// Client and server support batching `push-blocks` +
89    /// `advance-head` in a single all-or-nothing request. Lands with
90    /// PR 3.
91    AtomicPush,
92    /// Server requires Ed25519-signed commits on every push and will
93    /// return `signature-invalid` / `signer-revoked` / `policy-require`
94    /// rejection reasons. Lands with PR 4.
95    SignedPush,
96    /// Server publishes a self-certifying repo identifier
97    /// (BLAKE3 of the root-op signing key) on `GET /remote/v1/refs`,
98    /// so that deep links from signed commits do not need DNS trust.
99    /// Reserved for v0.1.0.
100    SelfCertifyingRepoId,
101}
102
103impl Capability {
104    /// Stable kebab-case wire name for this capability.
105    ///
106    /// This is the single source of truth for what goes on the wire;
107    /// [`Capability::from_str`] is the inverse.
108    #[must_use]
109    pub const fn as_wire_str(&self) -> &'static str {
110        match self {
111            Self::HaveSetBloom => "have-set-bloom",
112            Self::HaveSetRbsr => "have-set-rbsr",
113            Self::PushNegotiate => "push-negotiate",
114            Self::FilterSpec => "filter-spec",
115            Self::AtomicPush => "atomic-push",
116            Self::SignedPush => "signed-push",
117            Self::SelfCertifyingRepoId => "self-certifying-repo-id",
118        }
119    }
120
121    /// Every capability known to this build, in a stable order
122    /// (wire-string ascending). Useful for tests and for server-side
123    /// capability ads when the operator has not opted out of any.
124    #[must_use]
125    pub fn all() -> &'static [Self] {
126        // Keep this list in sync with `as_wire_str`. Ordered
127        // wire-string ascending so comparisons against sorted wire
128        // output are trivially stable.
129        const ALL: &[Capability] = &[
130            Capability::AtomicPush,
131            Capability::FilterSpec,
132            Capability::HaveSetBloom,
133            Capability::HaveSetRbsr,
134            Capability::PushNegotiate,
135            Capability::SelfCertifyingRepoId,
136            Capability::SignedPush,
137        ];
138        ALL
139    }
140}
141
142impl fmt::Display for Capability {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.write_str(self.as_wire_str())
145    }
146}
147
148impl FromStr for Capability {
149    type Err = UnknownCapability;
150
151    fn from_str(s: &str) -> Result<Self, Self::Err> {
152        match s {
153            "have-set-bloom" => Ok(Self::HaveSetBloom),
154            "have-set-rbsr" => Ok(Self::HaveSetRbsr),
155            "push-negotiate" => Ok(Self::PushNegotiate),
156            "filter-spec" => Ok(Self::FilterSpec),
157            "atomic-push" => Ok(Self::AtomicPush),
158            "signed-push" => Ok(Self::SignedPush),
159            "self-certifying-repo-id" => Ok(Self::SelfCertifyingRepoId),
160            _ => Err(UnknownCapability(s.to_owned())),
161        }
162    }
163}
164
165/// Serde support: capabilities serialise as their stable kebab-case
166/// wire string; unknown strings produce a deserialisation error, so
167/// untyped consumers that want forward-compat MUST carry the raw string
168/// alongside (see [`parse_capabilities`] which tolerates unknowns).
169impl serde::Serialize for Capability {
170    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
171        s.serialize_str(self.as_wire_str())
172    }
173}
174
175impl<'de> serde::Deserialize<'de> for Capability {
176    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
177        let s = <&str as serde::Deserialize>::deserialize(d)?;
178        s.parse().map_err(serde::de::Error::custom)
179    }
180}
181
182/// Raised by [`Capability::from_str`] when the wire string is not in
183/// this build's vocabulary. Callers that want forward-compat use
184/// [`parse_capabilities`] instead, which silently drops unknowns.
185#[derive(Debug, thiserror::Error)]
186#[error("unknown capability: {0}")]
187pub struct UnknownCapability(pub String);
188
189/// Parse a comma-separated capability list off the wire, discarding
190/// any unknown capabilities (forward-compat). Whitespace around
191/// commas is tolerated. Empty entries are skipped.
192///
193/// Returns a sorted-unique [`BTreeSet`] so intersection and
194/// set-difference against the server's capability ad are cheap.
195#[must_use]
196pub fn parse_capabilities(s: &str) -> BTreeSet<Capability> {
197    s.split(',')
198        .map(str::trim)
199        .filter(|tok| !tok.is_empty())
200        .filter_map(|tok| tok.parse::<Capability>().ok())
201        .collect()
202}
203
204/// Serialize a capability set to the comma-separated wire form. The
205/// output is deterministic: capabilities are emitted in the `all()`
206/// order, i.e. wire-string ascending.
207#[must_use]
208pub fn serialize_capabilities<I>(caps: I) -> String
209where
210    I: IntoIterator<Item = Capability>,
211{
212    let set: BTreeSet<Capability> = caps.into_iter().collect();
213    let mut out = String::new();
214    let mut first = true;
215    // Iterate in ascending wire-string order for determinism.
216    let mut sorted: Vec<Capability> = set.into_iter().collect();
217    sorted.sort_by_key(Capability::as_wire_str);
218    for c in sorted {
219        if !first {
220            out.push(',');
221        }
222        first = false;
223        out.push_str(c.as_wire_str());
224    }
225    out
226}
227
228/// A set of capabilities agreed between two peers.
229///
230/// Thin wrapper around `BTreeSet<Capability>` whose only job is to
231/// name the [`CapabilitySet::intersect`] operation that both clients
232/// and servers perform on handshake: each side advertises the
233/// capabilities it supports, and the *intersection* is the set both
234/// agree to use for the rest of the session.
235///
236/// This type stays pure-data (no network, no HTTP) so both
237/// `mnem http` and `mnem-transport::client` can consume it.
238#[derive(Clone, Debug, Default, Eq, PartialEq)]
239pub struct CapabilitySet(BTreeSet<Capability>);
240
241impl CapabilitySet {
242    /// Empty capability set.
243    #[must_use]
244    pub fn new() -> Self {
245        Self(BTreeSet::new())
246    }
247
248    /// Build from any capability iterator. Duplicates are collapsed.
249    #[must_use]
250    pub fn with_caps<I: IntoIterator<Item = Capability>>(caps: I) -> Self {
251        Self(caps.into_iter().collect())
252    }
253
254    /// Every capability this build knows about. Equivalent to
255    /// `with_caps(Capability::all().iter().copied())` but allocates
256    /// once rather than walking a slice.
257    #[must_use]
258    pub fn all_known() -> Self {
259        Self(Capability::all().iter().copied().collect())
260    }
261
262    /// Parse from the comma-separated wire form; unknown entries are
263    /// dropped (forward-compat, same rule as [`parse_capabilities`]).
264    #[must_use]
265    pub fn parse(s: &str) -> Self {
266        Self(parse_capabilities(s))
267    }
268
269    /// Serialize to the comma-separated wire form, ascending order.
270    #[must_use]
271    pub fn serialize(&self) -> String {
272        serialize_capabilities(self.0.iter().copied())
273    }
274
275    /// Capability intersection: the set of capabilities both peers
276    /// advertised. This is the agreed-upon feature set for the
277    /// remainder of the session.
278    ///
279    /// ```text
280    /// intersect(A, B) = { c | c in A and c in B }
281    /// ```
282    #[must_use]
283    pub fn intersect(&self, other: &Self) -> Self {
284        Self(self.0.intersection(&other.0).copied().collect())
285    }
286
287    /// True if the given capability is in this set.
288    #[must_use]
289    pub fn contains(&self, cap: Capability) -> bool {
290        self.0.contains(&cap)
291    }
292
293    /// Number of capabilities in the set.
294    #[must_use]
295    pub fn len(&self) -> usize {
296        self.0.len()
297    }
298
299    /// True iff the set has no capabilities.
300    #[must_use]
301    pub fn is_empty(&self) -> bool {
302        self.0.is_empty()
303    }
304
305    /// Borrow the underlying sorted set.
306    #[must_use]
307    pub const fn as_set(&self) -> &BTreeSet<Capability> {
308        &self.0
309    }
310}
311
312impl From<BTreeSet<Capability>> for CapabilitySet {
313    fn from(s: BTreeSet<Capability>) -> Self {
314        Self(s)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn protocol_version_is_frozen() {
324        assert_eq!(PROTOCOL_VERSION, 1);
325        assert_eq!(PROTOCOL_HEADER, "mnem-protocol");
326        assert_eq!(CAPABILITIES_HEADER, "mnem-capabilities");
327    }
328
329    #[test]
330    fn capability_wire_strings_are_stable_kebab_case() {
331        // This test pins the wire format. Changing any string here is a
332        // breaking change and requires bumping PROTOCOL_VERSION.
333        assert_eq!(Capability::HaveSetBloom.as_wire_str(), "have-set-bloom");
334        assert_eq!(Capability::HaveSetRbsr.as_wire_str(), "have-set-rbsr");
335        assert_eq!(Capability::PushNegotiate.as_wire_str(), "push-negotiate");
336        assert_eq!(Capability::FilterSpec.as_wire_str(), "filter-spec");
337        assert_eq!(Capability::AtomicPush.as_wire_str(), "atomic-push");
338        assert_eq!(Capability::SignedPush.as_wire_str(), "signed-push");
339        assert_eq!(
340            Capability::SelfCertifyingRepoId.as_wire_str(),
341            "self-certifying-repo-id",
342        );
343    }
344
345    #[test]
346    fn capability_round_trips_through_str() {
347        for c in Capability::all() {
348            let s = c.as_wire_str();
349            let parsed: Capability = s.parse().unwrap();
350            assert_eq!(parsed, *c, "round-trip failed for {s}");
351        }
352    }
353
354    #[test]
355    fn unknown_capability_parses_as_err_not_panic() {
356        let res: Result<Capability, _> = "no-such-thing".parse();
357        assert!(res.is_err());
358    }
359
360    #[test]
361    fn parse_capabilities_tolerates_unknowns_and_whitespace() {
362        let caps = parse_capabilities(" have-set-bloom , no-such-thing,atomic-push ");
363        assert_eq!(caps.len(), 2);
364        assert!(caps.contains(&Capability::HaveSetBloom));
365        assert!(caps.contains(&Capability::AtomicPush));
366    }
367
368    #[test]
369    fn parse_capabilities_skips_empty_entries() {
370        let caps = parse_capabilities(",,have-set-bloom,,");
371        assert_eq!(caps.len(), 1);
372        assert!(caps.contains(&Capability::HaveSetBloom));
373    }
374
375    #[test]
376    fn serialize_capabilities_is_deterministic() {
377        let caps = [
378            Capability::SignedPush,
379            Capability::HaveSetBloom,
380            Capability::AtomicPush,
381        ];
382        let a = serialize_capabilities(caps);
383        let b = serialize_capabilities(caps.iter().copied().rev());
384        assert_eq!(a, b, "output must be order-independent");
385        // Ascending by wire string.
386        assert_eq!(a, "atomic-push,have-set-bloom,signed-push");
387    }
388
389    #[test]
390    fn serialize_then_parse_round_trips() {
391        let original: BTreeSet<Capability> = [
392            Capability::HaveSetBloom,
393            Capability::PushNegotiate,
394            Capability::FilterSpec,
395        ]
396        .into_iter()
397        .collect();
398        let wire = serialize_capabilities(original.iter().copied());
399        let parsed = parse_capabilities(&wire);
400        assert_eq!(parsed, original);
401    }
402
403    #[test]
404    fn capability_serde_round_trips_through_json() {
405        // Exercise the serde impls so downstream JSON bodies compile.
406        let c = Capability::HaveSetBloom;
407        let j = serde_json::to_string(&c).unwrap();
408        assert_eq!(j, "\"have-set-bloom\"");
409        let back: Capability = serde_json::from_str(&j).unwrap();
410        assert_eq!(back, c);
411    }
412
413    #[test]
414    fn capability_set_intersect_empty() {
415        // Empty intersection with anything is empty.
416        let a = CapabilitySet::new();
417        let b = CapabilitySet::all_known();
418        assert!(a.intersect(&b).is_empty());
419        assert!(b.intersect(&a).is_empty());
420    }
421
422    #[test]
423    fn capability_set_intersect_identical() {
424        // Intersection of a set with itself is the set.
425        let a = CapabilitySet::all_known();
426        let r = a.intersect(&a);
427        assert_eq!(r, a);
428        assert_eq!(r.len(), Capability::all().len());
429    }
430
431    #[test]
432    fn capability_set_intersect_disjoint() {
433        // Disjoint capability sets intersect to the empty set.
434        let a = CapabilitySet::with_caps([Capability::HaveSetBloom, Capability::AtomicPush]);
435        let b = CapabilitySet::with_caps([Capability::SignedPush, Capability::FilterSpec]);
436        let r = a.intersect(&b);
437        assert!(r.is_empty());
438    }
439
440    #[test]
441    fn capability_set_intersect_partial() {
442        // Partial overlap keeps only the shared capabilities.
443        let a = CapabilitySet::with_caps([
444            Capability::HaveSetBloom,
445            Capability::AtomicPush,
446            Capability::SignedPush,
447        ]);
448        let b = CapabilitySet::with_caps([Capability::AtomicPush, Capability::FilterSpec]);
449        let r = a.intersect(&b);
450        assert_eq!(r.len(), 1);
451        assert!(r.contains(Capability::AtomicPush));
452    }
453
454    #[test]
455    fn capability_set_wire_round_trip() {
456        // serialize -> parse round-trips and is insensitive to input
457        // order.
458        let a = CapabilitySet::with_caps([Capability::HaveSetBloom, Capability::AtomicPush]);
459        let wire = a.serialize();
460        assert_eq!(wire, "atomic-push,have-set-bloom");
461        let b = CapabilitySet::parse(&wire);
462        assert_eq!(a, b);
463    }
464}