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}