Skip to main content

melin_wire_protocol/
control_codec.rs

1//! Codec for transport-level control frames.
2//!
3//! Encodes/decodes [`TransportResponse`] and [`ChallengeResponse`].
4//! Tag constants are public so every program that reads or writes frames
5//! — the node, the client library, a gateway on its own I/O loop — shares
6//! one source of truth for the wire values.
7//!
8//! Every frame, in either direction, is `[length: u32 LE][tag: u8][body]`.
9//! The tag is the protocol's alone: [`TAG_APP`] marks a frame whose body
10//! belongs to the application, and every other tag is one of the
11//! protocol's own frames. The protocol never reads an application body,
12//! and an application never sees the tag, so an application body may
13//! start with any byte.
14//!
15//! A request ([`TAG_REQUEST`]) and every frame that ends a request's
16//! reply (the batch terminators, `ServerBusy`, `Refused`) carry the
17//! request's [`RequestId`] straight after the tag, as eight
18//! little-endian bytes: the client chose it, and the node echoes it on
19//! the one final frame it sends for that request.
20
21use crate::control::{
22    ChallengeResponse, MAX_REFUSAL_REASON, RefusalKind, RequestId, TransportResponse,
23};
24use crate::error::ProtocolError;
25use crate::framing::PREFIX_LEN;
26
27/// Length of the tag that follows every frame's length prefix, in either
28/// direction.
29pub const TAG_LEN: usize = 1;
30
31/// Length of a request ID on the wire: a `u64`, little-endian, straight
32/// after the tag of a request and of every frame that ends its reply.
33pub const REQUEST_ID_LEN: usize = 8;
34
35/// The client protocol this build speaks, carried in the node's auth
36/// challenge. Protocol 0 is every release before request IDs (a challenge
37/// with no version in it). It changes once per release that changes the
38/// client wire, not once per commit. A client refuses any version other
39/// than its own, before it signs anything.
40pub const CLIENT_PROTOCOL_VERSION: u16 = 1;
41
42/// An application frame: the body is the application's response, opaque
43/// to the protocol. A request travels under [`TAG_REQUEST`] instead.
44///
45/// Not `0x00`, so a zeroed buffer on the wire is an unknown tag rather
46/// than an empty application frame. Not in `0x10..`, where application
47/// tags sat while applications shared this byte with the protocol (a
48/// development build between releases, never a released one). And no
49/// control frame's tag, so neither side can read one as the other.
50pub const TAG_APP: u8 = 0x09;
51
52pub const TAG_RESPONSE_HEARTBEAT: u8 = 0x01;
53/// The end of a request's reply, backed as the ack policy in force
54/// requires: `[tag][id: u64 LE]`.
55pub const TAG_BATCH_END: u8 = 0x02;
56/// Request `id` was shed at ingress, not applied: `[tag][id: u64 LE]`.
57pub const TAG_SERVER_BUSY: u8 = 0x04;
58pub const TAG_CHALLENGE: u8 = 0x05;
59pub const TAG_CHALLENGE_RESPONSE: u8 = 0x06;
60pub const TAG_AUTH_FAILED: u8 = 0x07;
61pub const TAG_SERVER_READY: u8 = 0x08;
62/// The end of a request's reply that only the primary's own disk backs:
63/// sent by a node halted for want of a replica, for a request it had
64/// already sequenced, once its own journal holds it. Weaker than the ack
65/// policy in force, and marked so; see
66/// [`TransportResponse::BatchEndDegraded`]. `[tag][id: u64 LE]`.
67pub const TAG_BATCH_END_DEGRADED: u8 = 0x0A;
68/// A client's request: `[tag][id: u64 LE][body]`, the body the
69/// application's.
70pub const TAG_REQUEST: u8 = 0x0B;
71/// The application's decoder refused request `id`:
72/// `[tag][id: u64 LE][kind: u8][len: u8][reason: len bytes, UTF-8]`.
73pub const TAG_REFUSED: u8 = 0x0C;
74
75/// Every control frame's tag, in either direction. An array, as the one
76/// collection a `const` block can walk.
77const CONTROL_TAGS: [u8; 10] = [
78    TAG_RESPONSE_HEARTBEAT,
79    TAG_BATCH_END,
80    TAG_SERVER_BUSY,
81    TAG_CHALLENGE,
82    TAG_CHALLENGE_RESPONSE,
83    TAG_AUTH_FAILED,
84    TAG_SERVER_READY,
85    TAG_BATCH_END_DEGRADED,
86    TAG_REQUEST,
87    TAG_REFUSED,
88];
89
90/// Tags that frames used to carry and no frame carries any more. Never
91/// reused, so a peer still speaking an older protocol cannot have one of
92/// its frames read as a different one. `0x03` was `EngineError`, a frame
93/// no node ever sent.
94pub const RETIRED_TAGS: [u8; 1] = [0x03];
95
96/// A rule of the protocol's tag space a set of tags breaks; see
97/// [`tag_rule_violation`].
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub(crate) enum TagRule {
100    /// The application tag is a zeroed byte or in `0x10..`.
101    AppTagOutOfRange,
102    /// A control or retired tag is a zeroed byte or in `0x10..`.
103    OutOfRange(u8),
104    /// Two entries share a tag.
105    Duplicate(u8),
106    /// A control or retired tag is the application tag.
107    IsAppTag(u8),
108}
109
110/// The first rule of the tag space that `control` (the live control tags),
111/// `retired` and `app` together break, or `None`: `app` is neither `0x00`
112/// nor in `0x10..`; every control and retired tag is neither either, is
113/// not `app`, and is distinct from every other. A `const fn`, so the
114/// `const` block below fails the build on a tag that breaks a rule, and
115/// the unit tests can call it on sets that do.
116pub(crate) const fn tag_rule_violation(control: &[u8], retired: &[u8], app: u8) -> Option<TagRule> {
117    if app == 0x00 || app >= 0x10 {
118        return Some(TagRule::AppTagOutOfRange);
119    }
120    let total = control.len() + retired.len();
121    let mut i = 0;
122    while i < total {
123        let tag = tag_at(control, retired, i);
124        if tag == 0x00 || tag >= 0x10 {
125            return Some(TagRule::OutOfRange(tag));
126        }
127        if tag == app {
128            return Some(TagRule::IsAppTag(tag));
129        }
130        let mut j = i + 1;
131        while j < total {
132            if tag_at(control, retired, j) == tag {
133                return Some(TagRule::Duplicate(tag));
134            }
135            j += 1;
136        }
137        i += 1;
138    }
139    None
140}
141
142/// Entry `i` of `control` and `retired` taken as one list.
143const fn tag_at(control: &[u8], retired: &[u8], i: usize) -> u8 {
144    if i < control.len() {
145        control[i]
146    } else {
147        retired[i - control.len()]
148    }
149}
150
151const _: () = assert!(
152    tag_rule_violation(&CONTROL_TAGS, &RETIRED_TAGS, TAG_APP).is_none(),
153    "the protocol's tags break a rule of the tag space: see `tag_rule_violation`"
154);
155
156/// The frame of a batch terminator ([`TAG_BATCH_END`] or
157/// [`TAG_BATCH_END_DEGRADED`]), length prefix included: what a response
158/// stage pre-encodes and writes a request's ID into
159/// ([`write_terminal_id`]) before it appends the terminator.
160pub const BATCH_END_FRAME_LEN: usize = PREFIX_LEN + TAG_LEN + REQUEST_ID_LEN;
161
162/// The widest frame [`encode_transport_response`] writes, length prefix
163/// included: a `Refused` with the longest reason.
164pub const MAX_CONTROL_FRAME_LEN: usize =
165    PREFIX_LEN + TAG_LEN + REQUEST_ID_LEN + 2 + MAX_REFUSAL_REASON;
166
167/// Where a terminal frame's ID starts, counted from the start of its
168/// payload (after the length prefix): straight after the tag.
169const ID_OFFSET: usize = TAG_LEN;
170
171/// Write `id` into a terminator frame pre-encoded by
172/// [`encode_transport_response`] (`BatchEnd` or `BatchEndDegraded`, any
173/// ID): the one place the ID's offset in such a frame is written, so a
174/// response stage's template and the encoder cannot drift apart.
175#[inline]
176pub fn write_terminal_id(frame: &mut [u8; BATCH_END_FRAME_LEN], id: RequestId) {
177    frame[PREFIX_LEN + ID_OFFSET..].copy_from_slice(&id.0.to_le_bytes());
178}
179
180/// The ID a reply frame's payload (after the length prefix) names, if it
181/// is a frame that ends a request's reply: `BatchEnd`, `BatchEndDegraded`,
182/// `ServerBusy` or `Refused`. `None` for any other tag, and for a payload
183/// too short to hold the ID. Reads only the tag and the ID, so the head
184/// of a frame is enough.
185#[inline]
186pub fn terminal_request_id(payload: &[u8]) -> Option<RequestId> {
187    match payload.first()? {
188        &TAG_BATCH_END | &TAG_BATCH_END_DEGRADED | &TAG_SERVER_BUSY | &TAG_REFUSED => {
189            read_id(payload)
190        }
191        _ => None,
192    }
193}
194
195/// The ID straight after the tag of `payload`, or `None` if it is too
196/// short to hold one.
197#[inline]
198pub(crate) fn read_id(payload: &[u8]) -> Option<RequestId> {
199    let id = payload.get(ID_OFFSET..ID_OFFSET + REQUEST_ID_LEN)?;
200    Some(RequestId(u64::from_le_bytes(
201        id.try_into().expect("a slice of the ID's length"),
202    )))
203}
204
205/// `reason` cut to at most [`MAX_REFUSAL_REASON`] bytes, on a character
206/// boundary, so it stays UTF-8: the one place a refusal's reason is
207/// truncated.
208fn truncate_reason(reason: &str) -> &str {
209    if reason.len() <= MAX_REFUSAL_REASON {
210        return reason;
211    }
212    let mut end = MAX_REFUSAL_REASON;
213    // A UTF-8 character is at most four bytes, so this walks back at most
214    // three; index 0 is always a boundary.
215    while !reason.is_char_boundary(end) {
216        end -= 1;
217    }
218    &reason[..end]
219}
220
221/// Encode a transport-level response into `buf`.
222///
223/// Returns the total bytes written including the 4-byte LE length
224/// prefix. Returns `Err(Truncated)` if `buf` is too small for the
225/// variant (5 bytes for tag-only variants, [`BATCH_END_FRAME_LEN`] for
226/// `BatchEnd`, `BatchEndDegraded` and `ServerBusy`, 39 for `Challenge`,
227/// and up to [`MAX_CONTROL_FRAME_LEN`] for `Refused`).
228pub fn encode_transport_response(
229    response: &TransportResponse,
230    buf: &mut [u8],
231) -> Result<usize, ProtocolError> {
232    // What follows the tag.
233    let reason = match response {
234        TransportResponse::Refused { reason, .. } => truncate_reason(reason),
235        _ => "",
236    };
237    let body_len = match response {
238        TransportResponse::Heartbeat
239        | TransportResponse::AuthFailed
240        | TransportResponse::ServerReady => 0,
241        TransportResponse::BatchEnd { .. }
242        | TransportResponse::BatchEndDegraded { .. }
243        | TransportResponse::ServerBusy { .. } => REQUEST_ID_LEN,
244        TransportResponse::Refused { .. } => REQUEST_ID_LEN + 2 + reason.len(),
245        TransportResponse::Challenge { .. } => CHALLENGE_VERSION_LEN + CHALLENGE_NONCE_LEN,
246    };
247    let needed = PREFIX_LEN + TAG_LEN + body_len;
248    let Some(frame) = buf.get_mut(..needed) else {
249        return Err(ProtocolError::Truncated);
250    };
251    let (tag, id) = match *response {
252        TransportResponse::Heartbeat => (TAG_RESPONSE_HEARTBEAT, None),
253        TransportResponse::BatchEnd { id } => (TAG_BATCH_END, Some(id)),
254        TransportResponse::BatchEndDegraded { id } => (TAG_BATCH_END_DEGRADED, Some(id)),
255        TransportResponse::ServerBusy { id } => (TAG_SERVER_BUSY, Some(id)),
256        TransportResponse::Refused { id, .. } => (TAG_REFUSED, Some(id)),
257        TransportResponse::Challenge { .. } => (TAG_CHALLENGE, None),
258        TransportResponse::AuthFailed => (TAG_AUTH_FAILED, None),
259        TransportResponse::ServerReady => (TAG_SERVER_READY, None),
260    };
261    // Lossless: at most `MAX_CONTROL_FRAME_LEN`.
262    let payload_len = (TAG_LEN + body_len) as u32;
263    frame[..PREFIX_LEN].copy_from_slice(&payload_len.to_le_bytes());
264    frame[PREFIX_LEN] = tag;
265    let mut pos = PREFIX_LEN + TAG_LEN;
266    if let Some(id) = id {
267        frame[pos..pos + REQUEST_ID_LEN].copy_from_slice(&id.0.to_le_bytes());
268        pos += REQUEST_ID_LEN;
269    }
270    match *response {
271        TransportResponse::Refused { kind, .. } => {
272            frame[pos] = kind.to_byte();
273            // Lossless: truncated to `MAX_REFUSAL_REASON`, `u8::MAX`.
274            frame[pos + 1] = reason.len() as u8;
275            frame[pos + 2..].copy_from_slice(reason.as_bytes());
276        }
277        TransportResponse::Challenge { nonce } => {
278            frame[pos..pos + CHALLENGE_VERSION_LEN]
279                .copy_from_slice(&CLIENT_PROTOCOL_VERSION.to_le_bytes());
280            frame[pos + CHALLENGE_VERSION_LEN..].copy_from_slice(&nonce);
281        }
282        TransportResponse::Heartbeat
283        | TransportResponse::BatchEnd { .. }
284        | TransportResponse::BatchEndDegraded { .. }
285        | TransportResponse::ServerBusy { .. }
286        | TransportResponse::AuthFailed
287        | TransportResponse::ServerReady => {}
288    }
289    Ok(needed)
290}
291
292/// A `Refused` frame's fields: the request's ID, the kind, and the
293/// reason, borrowed from the payload. See [`decode_refused`].
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub struct Refused<'a> {
296    pub id: RequestId,
297    pub kind: RefusalKind,
298    pub reason: &'a str,
299}
300
301/// Why a payload under [`TAG_REFUSED`] is not a `Refused` frame.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum BadRefusal {
304    /// Too short for the ID, the kind and the reason's length.
305    Truncated,
306    /// The kind byte names no [`RefusalKind`].
307    UnknownKind(u8),
308    /// The reason's length does not match the bytes the frame has after
309    /// it: `declared` against `available`.
310    ReasonLength { declared: usize, available: usize },
311    /// The reason is not UTF-8.
312    ReasonNotUtf8,
313}
314
315impl std::fmt::Display for BadRefusal {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        match self {
318            Self::Truncated => f.write_str("a refusal too short for its fields"),
319            Self::UnknownKind(kind) => write!(f, "a refusal of unknown kind {kind:#04x}"),
320            Self::ReasonLength {
321                declared,
322                available,
323            } => write!(
324                f,
325                "a refusal declaring a {declared}-byte reason with {available} bytes after it"
326            ),
327            Self::ReasonNotUtf8 => f.write_str("a refusal whose reason is not UTF-8"),
328        }
329    }
330}
331
332/// Decode a `Refused` frame's payload (after the length prefix, tag
333/// included), checking its length exactly: the reason's declared length
334/// must be what follows it, and it must be UTF-8. The inverse of
335/// [`encode_transport_response`] for that frame, kept beside it so its
336/// layout has one home. A payload whose tag is not [`TAG_REFUSED`] is the
337/// caller's to rule out; this reads past the tag without looking at it.
338pub fn decode_refused(payload: &[u8]) -> Result<Refused<'_>, BadRefusal> {
339    let id = read_id(payload).ok_or(BadRefusal::Truncated)?;
340    let fields = ID_OFFSET + REQUEST_ID_LEN;
341    let [kind, len, reason @ ..] = payload.get(fields..).ok_or(BadRefusal::Truncated)? else {
342        return Err(BadRefusal::Truncated);
343    };
344    let kind = RefusalKind::from_byte(*kind).ok_or(BadRefusal::UnknownKind(*kind))?;
345    let declared = usize::from(*len);
346    if reason.len() != declared {
347        return Err(BadRefusal::ReasonLength {
348            declared,
349            available: reason.len(),
350        });
351    }
352    let reason = std::str::from_utf8(reason).map_err(|_| BadRefusal::ReasonNotUtf8)?;
353    Ok(Refused { id, kind, reason })
354}
355
356// ---------------------------------------------------------------------------
357// The auth challenge
358// ---------------------------------------------------------------------------
359
360/// Bytes of the version a challenge carries ahead of its nonce.
361const CHALLENGE_VERSION_LEN: usize = 2;
362
363/// Bytes of the challenge's nonce.
364pub const CHALLENGE_NONCE_LEN: usize = 32;
365
366/// A challenge's payload (after the length prefix): `[tag][version: u16
367/// LE][nonce: 32]`.
368pub const CHALLENGE_LEN: usize = TAG_LEN + CHALLENGE_VERSION_LEN + CHALLENGE_NONCE_LEN;
369
370/// A protocol-0 challenge's payload, from a node of a release before
371/// client protocol versions: `[tag][nonce: 32]`, no version.
372pub const CHALLENGE_V0_LEN: usize = TAG_LEN + CHALLENGE_NONCE_LEN;
373
374/// Why a node's first frame is not a challenge this build can answer.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum BadChallenge {
377    /// Not a challenge at all: another tag, or a length no protocol's
378    /// challenge has. `len` and `tag` are the payload's, for the message.
379    NotAChallenge { len: usize, tag: Option<u8> },
380    /// A challenge from a node speaking client protocol `node`, not
381    /// [`CLIENT_PROTOCOL_VERSION`]: a protocol-0 challenge (no version),
382    /// or a versioned one naming another.
383    Version { node: u16 },
384}
385
386/// The nonce of a node's challenge payload (after the length prefix), if
387/// the node speaks [`CLIENT_PROTOCOL_VERSION`]. The one parser of the
388/// challenge's layout, for a client's handshake and for tests that read
389/// a node's challenge by hand.
390pub fn decode_challenge(payload: &[u8]) -> Result<[u8; CHALLENGE_NONCE_LEN], BadChallenge> {
391    let not_a_challenge = BadChallenge::NotAChallenge {
392        len: payload.len(),
393        tag: payload.first().copied(),
394    };
395    let Some((&TAG_CHALLENGE, rest)) = payload.split_first() else {
396        return Err(not_a_challenge);
397    };
398    match payload.len() {
399        CHALLENGE_V0_LEN => Err(BadChallenge::Version { node: 0 }),
400        CHALLENGE_LEN => {
401            let (version, nonce) = rest.split_at(CHALLENGE_VERSION_LEN);
402            let version = u16::from_le_bytes(version.try_into().expect("two bytes"));
403            if version != CLIENT_PROTOCOL_VERSION {
404                return Err(BadChallenge::Version { node: version });
405            }
406            Ok(nonce.try_into().expect("the nonce's length"))
407        }
408        _ => Err(not_a_challenge),
409    }
410}
411
412/// Wire size of a challenge-response frame payload:
413/// tag(1) + signature(64) + public_key(32).
414pub const CHALLENGE_RESPONSE_LEN: usize = TAG_LEN + 64 + 32;
415
416/// Encode a client's auth challenge-response into `buf`, as the frame
417/// payload *without* the 4-byte length prefix:
418/// `[tag:u8][signature:64][pubkey:32]`.
419///
420/// The inverse of [`decode_challenge_response`], kept beside it so the
421/// layout has one home. Returns `Err(Truncated)` if `buf` is shorter
422/// than [`CHALLENGE_RESPONSE_LEN`].
423pub fn encode_challenge_response(
424    response: &ChallengeResponse,
425    buf: &mut [u8],
426) -> Result<usize, ProtocolError> {
427    if buf.len() < CHALLENGE_RESPONSE_LEN {
428        return Err(ProtocolError::Truncated);
429    }
430    buf[0] = TAG_CHALLENGE_RESPONSE;
431    buf[1..65].copy_from_slice(&response.signature);
432    buf[65..97].copy_from_slice(&response.public_key);
433    Ok(CHALLENGE_RESPONSE_LEN)
434}
435
436/// Decode a client's auth challenge-response from a wire frame.
437///
438/// `buf` must contain the frame payload *after* the 4-byte length
439/// prefix has been stripped: `[tag:u8][signature:64][pubkey:32]`.
440///
441/// Returns `Err(UnknownTag)` if the tag is not `TAG_CHALLENGE_RESPONSE`.
442pub fn decode_challenge_response(buf: &[u8]) -> Result<ChallengeResponse, ProtocolError> {
443    if buf.len() < CHALLENGE_RESPONSE_LEN {
444        return Err(ProtocolError::Truncated);
445    }
446
447    let tag = buf[0];
448    if tag != TAG_CHALLENGE_RESPONSE {
449        return Err(ProtocolError::UnknownTag(tag));
450    }
451
452    let mut signature = [0u8; 64];
453    signature.copy_from_slice(&buf[1..65]);
454    let mut public_key = [0u8; 32];
455    public_key.copy_from_slice(&buf[65..97]);
456
457    Ok(ChallengeResponse {
458        signature,
459        public_key,
460    })
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use proptest::prelude::*;
467
468    /// One of each response, terminals under `id`.
469    fn every_response(id: RequestId) -> [TransportResponse; 8] {
470        [
471            TransportResponse::Heartbeat,
472            TransportResponse::BatchEnd { id },
473            TransportResponse::BatchEndDegraded { id },
474            TransportResponse::ServerBusy { id },
475            TransportResponse::Refused {
476                id,
477                kind: RefusalKind::Malformed,
478                reason: "short",
479            },
480            TransportResponse::Challenge { nonce: [0x5A; 32] },
481            TransportResponse::AuthFailed,
482            TransportResponse::ServerReady,
483        ]
484    }
485
486    /// `response` encoded, length prefix included.
487    fn encoded(response: &TransportResponse) -> Vec<u8> {
488        let mut buf = [0u8; MAX_CONTROL_FRAME_LEN];
489        let n = encode_transport_response(response, &mut buf).unwrap();
490        buf[..n].to_vec()
491    }
492
493    #[test]
494    fn tag_only_variants_are_five_bytes() {
495        for variant in [
496            TransportResponse::Heartbeat,
497            TransportResponse::AuthFailed,
498            TransportResponse::ServerReady,
499        ] {
500            let frame = encoded(&variant);
501            assert_eq!(frame.len(), PREFIX_LEN + TAG_LEN, "{variant:?}");
502            assert_eq!(u32::from_le_bytes(frame[..4].try_into().unwrap()), 1);
503            assert_eq!(terminal_request_id(&frame[PREFIX_LEN..]), None);
504        }
505    }
506
507    /// Each terminal carries its ID straight after the tag, and
508    /// `terminal_request_id` reads it back.
509    #[test]
510    fn each_terminal_encodes_with_its_id_and_decodes_back() {
511        let id = RequestId(0x0102_0304_0506_0708);
512        for (response, tag) in [
513            (TransportResponse::BatchEnd { id }, TAG_BATCH_END),
514            (
515                TransportResponse::BatchEndDegraded { id },
516                TAG_BATCH_END_DEGRADED,
517            ),
518            (TransportResponse::ServerBusy { id }, TAG_SERVER_BUSY),
519        ] {
520            let frame = encoded(&response);
521            assert_eq!(frame.len(), BATCH_END_FRAME_LEN, "{response:?}");
522            assert_eq!(
523                u32::from_le_bytes(frame[..4].try_into().unwrap()) as usize,
524                TAG_LEN + REQUEST_ID_LEN
525            );
526            assert_eq!(frame[PREFIX_LEN], tag);
527            assert_eq!(&frame[PREFIX_LEN + 1..], &id.0.to_le_bytes());
528            assert_eq!(terminal_request_id(&frame[PREFIX_LEN..]), Some(id));
529        }
530        let refused = encoded(&TransportResponse::Refused {
531            id,
532            kind: RefusalKind::PermissionDenied,
533            reason: "no",
534        });
535        assert_eq!(terminal_request_id(&refused[PREFIX_LEN..]), Some(id));
536        // `0` is as good an ID as any.
537        let zero = encoded(&TransportResponse::BatchEnd { id: RequestId(0) });
538        assert_eq!(terminal_request_id(&zero[PREFIX_LEN..]), Some(RequestId(0)));
539    }
540
541    #[test]
542    fn terminal_request_id_is_none_for_other_tags_and_short_heads() {
543        assert_eq!(terminal_request_id(&[]), None);
544        for tag in 0..=u8::MAX {
545            let head = [&[tag][..], &[7; REQUEST_ID_LEN]].concat();
546            let terminal = matches!(
547                tag,
548                TAG_BATCH_END | TAG_BATCH_END_DEGRADED | TAG_SERVER_BUSY | TAG_REFUSED
549            );
550            assert_eq!(
551                terminal_request_id(&head).is_some(),
552                terminal,
553                "tag {tag:#04x}"
554            );
555            // A head cut anywhere inside the ID holds no ID.
556            for cut in 0..head.len() {
557                assert_eq!(terminal_request_id(&head[..cut]), None, "tag {tag:#04x}");
558            }
559        }
560    }
561
562    /// `CONTROL_TAGS` is checked at compile time; this checks the frames
563    /// as the encoder writes them, so a variant written under a tag missing
564    /// from that list cannot slip past.
565    #[test]
566    fn every_encoded_tag_is_a_live_control_tag() {
567        for variant in &every_response(RequestId(1)) {
568            let frame = encoded(variant);
569            assert_ne!(frame[4], TAG_APP, "{variant:?}");
570            assert!(CONTROL_TAGS.contains(&frame[4]), "{variant:?}");
571            assert!(!RETIRED_TAGS.contains(&frame[4]), "{variant:?}");
572        }
573    }
574
575    #[test]
576    fn the_tag_rules_hold_and_are_checked() {
577        assert_eq!(
578            tag_rule_violation(&CONTROL_TAGS, &RETIRED_TAGS, TAG_APP),
579            None
580        );
581        assert_eq!(tag_rule_violation(&[1, 2, 3], &[4], 9), None);
582        assert_eq!(
583            tag_rule_violation(&[1, 2, 1], &[], 9),
584            Some(TagRule::Duplicate(1))
585        );
586        // A live tag reusing a retired one.
587        assert_eq!(
588            tag_rule_violation(&[1, 3], &[3], 9),
589            Some(TagRule::Duplicate(3))
590        );
591        assert_eq!(
592            tag_rule_violation(&[1, 0x00], &[], 9),
593            Some(TagRule::OutOfRange(0x00))
594        );
595        assert_eq!(
596            tag_rule_violation(&[1], &[0x10], 9),
597            Some(TagRule::OutOfRange(0x10))
598        );
599        assert_eq!(
600            tag_rule_violation(&[1, 9], &[], 9),
601            Some(TagRule::IsAppTag(9))
602        );
603        assert_eq!(
604            tag_rule_violation(&[1], &[], 0x00),
605            Some(TagRule::AppTagOutOfRange)
606        );
607        assert_eq!(
608            tag_rule_violation(&[1], &[], 0x10),
609            Some(TagRule::AppTagOutOfRange)
610        );
611    }
612
613    #[test]
614    fn every_refusal_kind_round_trips_and_no_other_byte_is_one() {
615        for kind in [RefusalKind::PermissionDenied, RefusalKind::Malformed] {
616            assert_eq!(RefusalKind::from_byte(kind.to_byte()), Some(kind));
617        }
618        for byte in 0..=u8::MAX {
619            match RefusalKind::from_byte(byte) {
620                Some(kind) => assert_eq!(kind.to_byte(), byte),
621                None => assert!(!(1..=2).contains(&byte), "{byte}"),
622            }
623        }
624        assert_eq!(RefusalKind::from_byte(0), None, "a zeroed byte is no kind");
625    }
626
627    /// A refusal's frame, decoded back.
628    fn refused_round_trip(reason: &'static str) -> (Vec<u8>, String) {
629        let id = RequestId(42);
630        let frame = encoded(&TransportResponse::Refused {
631            id,
632            kind: RefusalKind::Malformed,
633            reason,
634        });
635        let refused = decode_refused(&frame[PREFIX_LEN..]).unwrap();
636        assert_eq!(refused.id, id);
637        assert_eq!(refused.kind, RefusalKind::Malformed);
638        let reason = refused.reason.to_string();
639        (frame, reason)
640    }
641
642    #[test]
643    fn a_refusal_carries_its_kind_and_reason() {
644        let (frame, reason) = refused_round_trip("auditor role may not notarize");
645        assert_eq!(reason, "auditor role may not notarize");
646        assert_eq!(frame[PREFIX_LEN], TAG_REFUSED);
647        assert_eq!(
648            frame.len(),
649            PREFIX_LEN + TAG_LEN + REQUEST_ID_LEN + 2 + reason.len()
650        );
651        let (_, empty) = refused_round_trip("");
652        assert_eq!(empty, "");
653    }
654
655    /// The reason is cut at 255 bytes, back to a character boundary: at,
656    /// just past and well past the limit, with a multi-byte character
657    /// straddling it.
658    #[test]
659    fn a_refusal_reason_is_truncated_on_a_character_boundary() {
660        // Exactly the limit: kept whole, the widest frame.
661        let at: &'static str = "a".repeat(MAX_REFUSAL_REASON).leak();
662        let (frame, reason) = refused_round_trip(at);
663        assert_eq!(reason, at);
664        assert_eq!(frame.len(), MAX_CONTROL_FRAME_LEN);
665        assert_eq!(MAX_CONTROL_FRAME_LEN, 270);
666        assert_eq!(frame.len() - PREFIX_LEN, 266, "with the tag");
667
668        // One past: the last byte goes.
669        let past: &'static str = "b".repeat(MAX_REFUSAL_REASON + 1).leak();
670        assert_eq!(refused_round_trip(past).1, &past[..MAX_REFUSAL_REASON]);
671
672        // Well past.
673        let far: &'static str = "c".repeat(4 * MAX_REFUSAL_REASON).leak();
674        assert_eq!(refused_round_trip(far).1.len(), MAX_REFUSAL_REASON);
675
676        // A three-byte character over the limit: cut before it, not inside.
677        for lead in MAX_REFUSAL_REASON - 3..=MAX_REFUSAL_REASON {
678            let s: &'static str = format!("{}€tail", "d".repeat(lead)).leak();
679            let reason = refused_round_trip(s).1;
680            assert!(reason.len() <= MAX_REFUSAL_REASON, "lead {lead}");
681            assert!(s.starts_with(&reason), "lead {lead}");
682            let euro_fits = lead + '€'.len_utf8() <= MAX_REFUSAL_REASON;
683            assert_eq!(reason.ends_with('€'), euro_fits, "lead {lead}");
684            if !euro_fits {
685                assert_eq!(reason.len(), lead, "lead {lead}");
686            }
687        }
688    }
689
690    #[test]
691    fn a_malformed_refusal_is_refused() {
692        let good = encoded(&TransportResponse::Refused {
693            id: RequestId(1),
694            kind: RefusalKind::PermissionDenied,
695            reason: "abc",
696        });
697        let payload = &good[PREFIX_LEN..];
698        assert!(decode_refused(payload).is_ok());
699        // Cut before the length byte, or inside the ID.
700        for cut in 0..TAG_LEN + REQUEST_ID_LEN + 2 {
701            assert_eq!(decode_refused(&payload[..cut]), Err(BadRefusal::Truncated));
702        }
703        // One byte short of its reason, one byte long.
704        assert_eq!(
705            decode_refused(&payload[..payload.len() - 1]),
706            Err(BadRefusal::ReasonLength {
707                declared: 3,
708                available: 2
709            })
710        );
711        let long = [payload, b"x"].concat();
712        assert!(matches!(
713            decode_refused(&long),
714            Err(BadRefusal::ReasonLength {
715                declared: 3,
716                available: 4
717            })
718        ));
719        // A zero kind and an unknown one.
720        let kind_at = TAG_LEN + REQUEST_ID_LEN;
721        for kind in [0x00, 0x03, 0xFF] {
722            let mut bad = payload.to_vec();
723            bad[kind_at] = kind;
724            assert_eq!(decode_refused(&bad), Err(BadRefusal::UnknownKind(kind)));
725        }
726        // A reason that is not UTF-8.
727        let mut bad = payload.to_vec();
728        bad[kind_at + 2] = 0xFF;
729        assert_eq!(decode_refused(&bad), Err(BadRefusal::ReasonNotUtf8));
730        // Every error says what is wrong.
731        for err in [
732            BadRefusal::Truncated,
733            BadRefusal::UnknownKind(3),
734            BadRefusal::ReasonLength {
735                declared: 3,
736                available: 2,
737            },
738            BadRefusal::ReasonNotUtf8,
739        ] {
740            assert!(err.to_string().contains("refusal"), "{err}");
741        }
742    }
743
744    #[test]
745    fn encode_refuses_a_buffer_too_small_for_the_variant() {
746        for variant in every_response(RequestId(9)) {
747            let len = encoded(&variant).len();
748            let mut buf = vec![0u8; len - 1];
749            assert!(
750                matches!(
751                    encode_transport_response(&variant, &mut buf),
752                    Err(ProtocolError::Truncated)
753                ),
754                "{variant:?}"
755            );
756            let mut buf = vec![0u8; len];
757            assert_eq!(encode_transport_response(&variant, &mut buf).unwrap(), len);
758        }
759    }
760
761    #[test]
762    fn the_challenge_carries_the_version_ahead_of_the_nonce() {
763        let nonce = [0xAB; 32];
764        let frame = encoded(&TransportResponse::Challenge { nonce });
765        assert_eq!(frame.len(), PREFIX_LEN + CHALLENGE_LEN);
766        assert_eq!(CHALLENGE_LEN, 35);
767        assert_eq!(
768            u32::from_le_bytes(frame[..4].try_into().unwrap()) as usize,
769            CHALLENGE_LEN
770        );
771        assert_eq!(frame[4], TAG_CHALLENGE);
772        assert_eq!(&frame[5..7], &CLIENT_PROTOCOL_VERSION.to_le_bytes());
773        assert_eq!(&frame[7..], &nonce);
774        assert_eq!(decode_challenge(&frame[PREFIX_LEN..]), Ok(nonce));
775    }
776
777    #[test]
778    fn a_challenge_from_another_protocol_names_its_version() {
779        // Protocol 0: a 33-byte challenge, no version.
780        let v0 = [&[TAG_CHALLENGE][..], &[0x11; 32]].concat();
781        assert_eq!(v0.len(), CHALLENGE_V0_LEN);
782        assert_eq!(
783            decode_challenge(&v0),
784            Err(BadChallenge::Version { node: 0 })
785        );
786        // A version this build does not speak.
787        for version in [0, 2, u16::MAX] {
788            let other = [&[TAG_CHALLENGE][..], &version.to_le_bytes(), &[0x11; 32]].concat();
789            assert_eq!(
790                decode_challenge(&other),
791                Err(BadChallenge::Version { node: version })
792            );
793        }
794        // Not a challenge: another tag, another length, nothing.
795        for wrong in [
796            vec![TAG_SERVER_READY],
797            vec![TAG_CHALLENGE; 34],
798            vec![TAG_CHALLENGE; 36],
799            [&[TAG_BATCH_END][..], &[0; 34]].concat(),
800        ] {
801            assert_eq!(
802                decode_challenge(&wrong),
803                Err(BadChallenge::NotAChallenge {
804                    len: wrong.len(),
805                    tag: wrong.first().copied()
806                })
807            );
808        }
809        assert_eq!(
810            decode_challenge(&[]),
811            Err(BadChallenge::NotAChallenge { len: 0, tag: None })
812        );
813    }
814
815    #[test]
816    fn decode_valid_challenge_response() {
817        let sig = [0x11; 64];
818        let pubkey = [0x22; 32];
819
820        // Build wire frame: [tag:u8][sig:64][pubkey:32]
821        let mut buf = [0u8; 97];
822        buf[0] = TAG_CHALLENGE_RESPONSE;
823        buf[1..65].copy_from_slice(&sig);
824        buf[65..97].copy_from_slice(&pubkey);
825
826        let cr = decode_challenge_response(&buf).unwrap();
827        assert_eq!(cr.signature, sig);
828        assert_eq!(cr.public_key, pubkey);
829    }
830
831    #[test]
832    fn challenge_response_round_trips() {
833        let response = ChallengeResponse {
834            signature: [0x33; 64],
835            public_key: [0x44; 32],
836        };
837        let mut buf = [0u8; CHALLENGE_RESPONSE_LEN];
838        let written = encode_challenge_response(&response, &mut buf).unwrap();
839        assert_eq!(written, CHALLENGE_RESPONSE_LEN);
840        assert_eq!(decode_challenge_response(&buf).unwrap(), response);
841
842        let mut short = [0u8; CHALLENGE_RESPONSE_LEN - 1];
843        assert!(matches!(
844            encode_challenge_response(&response, &mut short),
845            Err(ProtocolError::Truncated)
846        ));
847    }
848
849    #[test]
850    fn decode_truncated() {
851        let buf = [0u8; 50]; // too short
852        assert!(matches!(
853            decode_challenge_response(&buf),
854            Err(ProtocolError::Truncated)
855        ));
856    }
857
858    #[test]
859    fn decode_wrong_tag() {
860        let mut buf = [0u8; CHALLENGE_RESPONSE_LEN];
861        buf[0] = 99; // not TAG_CHALLENGE_RESPONSE
862        assert!(matches!(
863            decode_challenge_response(&buf),
864            Err(ProtocolError::UnknownTag(99))
865        ));
866    }
867
868    proptest! {
869        /// The pre-encoded terminator with an ID written into it is the
870        /// frame the encoder writes for that ID, for either terminator,
871        /// so a response stage's template and the encoder cannot drift.
872        #[test]
873        fn the_template_writer_matches_the_encoder(id in any::<u64>(), stale in any::<u64>()) {
874            let id = RequestId(id);
875            for (make, name) in [
876                (
877                    (|id| TransportResponse::BatchEnd { id }) as fn(RequestId) -> TransportResponse,
878                    "BatchEnd",
879                ),
880                (|id| TransportResponse::BatchEndDegraded { id }, "BatchEndDegraded"),
881            ] {
882                let mut template = [0u8; BATCH_END_FRAME_LEN];
883                let n = encode_transport_response(&make(RequestId(stale)), &mut template).unwrap();
884                prop_assert_eq!(n, BATCH_END_FRAME_LEN, "{}", name);
885                write_terminal_id(&mut template, id);
886                prop_assert_eq!(template.to_vec(), encoded(&make(id)), "{}", name);
887            }
888        }
889    }
890}