Skip to main content

subc_protocol/
lib.rs

1//! subc wire contract.
2//!
3//! This crate is the single source of truth for the subc <-> module wire,
4//! shared by subc-core and AFT. It defines the **envelope** (the fixed
5//! 21-byte routing header subc splices on), the canonical subc-generated body
6//! schemas such as [`ErrorBody`], and the capability manifest. JSON-RPC request
7//! and response bodies remain module-owned opaque payloads to subc.
8//!
9//! ## The envelope (locked — see docs/subc-core-architecture.md §4.8)
10//!
11//! ```text
12//!  offset  size  field     type    purpose
13//!    0      4    len       u32     # of BODY bytes after this 21-byte header
14//!    4      1    ver       u8      envelope version
15//!    5      1    type      u8      frame kind (see FrameType)
16//!    6      1    flags     u8      bit0 BINARY · bits1-2 PRIORITY · bit3 LAST · bits4-5 ADMISSION · bits6-7 reserved
17//!    7      2    channel   u16     route = (component, session); 0 = subc itself
18//!    9      4    epoch     u32     per-slot binding epoch; 0 on channel 0
19//!   13      8    corr      u64     correlation id; CANCEL carries the target call's corr
20//!   21 -> body
21//! ```
22//!
23//! Little-endian (same-machine, native, no byte-swap on the hot path).
24//!
25//! **Frozen prefix (the versioning invariant):** `len` (u32 @ 0) and `ver`
26//! (u8 @ 4) keep fixed meaning + position in *every* future version. A reader
27//! of any version can therefore always read the first 5 bytes, learn `ver`,
28//! look up that version's header length, read the rest, and splice `len` body
29//! bytes. `decode_header` enforces this discipline.
30
31#![forbid(unsafe_code)]
32
33use std::{error::Error, fmt, path::PathBuf};
34
35use serde::{Deserialize, Serialize};
36
37pub mod frame;
38pub mod manifest;
39pub mod session;
40
41pub use frame::{Frame, FrameBuildError};
42
43/// Per-route bind identity shared by client-facing and module-facing control.
44///
45/// EVERY FIELD HERE IS CLIENT-SUPPLIED AND UNATTESTED. The daemon canonicalizes
46/// `project_root` as a path but does not verify that the caller has any relation
47/// to it, and `harness` and `session` are strings the caller chose. A client
48/// holding the connection key can present any values it likes.
49///
50/// This sits directly above `Principal`, which is the opposite: stamped BY the
51/// daemon from a launch nonce it minted. The two travel together on every
52/// `route.bind`, so a module reading them side by side is reading one fact it can
53/// trust and three it cannot. THE DISTINCTION IS INVISIBLE FROM THE TYPES, which
54/// is why it is written here.
55///
56/// So these fields are for SCOPING AND ATTRIBUTION -- which project's state to
57/// open, which session to thread, what to log -- and never for authorization. A
58/// module that grants capability on `harness` or trusts `project_root` to bound
59/// what a caller may reach has built an authorization check on a value the caller
60/// controls. Gate on `Principal` instead, and where a module needs a caller fact
61/// subc does not stamp, it must establish that fact itself rather than believe
62/// this struct.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct BindIdentity {
65    pub project_root: PathBuf,
66    pub harness: String,
67    pub session: String,
68}
69
70/// Caller fact stamped by subc on each route.bind relayed to a module.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(tag = "kind", rename_all = "snake_case")]
73pub enum Principal {
74    /// A daemon-spawned module proved possession of its launch nonce.
75    Reserved { module_id: String },
76    /// No consumer identity was presented; the caller is a direct key-holder.
77    Direct,
78    /// Reserved vocabulary for a future degraded/no-key-auth mode.
79    Unverified,
80}
81
82/// Explicit target for a route open/bind operation.
83///
84/// RouteTarget.kind ↔ ProviderRole mapping:
85///
86/// | RouteTarget.kind | required ProviderRole | disambiguator |
87/// |---|---|---|
88/// | `tool_provider` | `ToolProvider` | v1: ≤1 per module |
89/// | `management_surface` | `ManagementSurface` | v1: ≤1 per module |
90/// | `internal_service` | `InternalService` | `service_id` (multiple allowed) |
91///
92/// `ProviderRole::PipelineStage` is intentionally unroutable; pipeline modules
93/// are wired by an orchestrator rather than opened directly by clients.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(tag = "kind", rename_all = "snake_case")]
96pub enum RouteTarget {
97    ToolProvider {
98        module_id: String,
99    },
100    ManagementSurface {
101        module_id: String,
102    },
103    InternalService {
104        module_id: String,
105        service_id: String,
106    },
107}
108
109/// Envelope protocol version this build speaks.
110pub const PROTOCOL_VERSION: u8 = 2;
111
112/// Oldest envelope protocol version this build accepts.
113pub const MIN_SUPPORTED_VERSION: u8 = 2;
114
115/// Env var subc sets on each supervised child telling it the module_id it is
116/// supervised under, so it can register under that id.
117pub const SUBC_MODULE_ID_ENV: &str = "SUBC_MODULE_ID";
118
119/// Env var subc sets, on each spawn of a `reserved` module only, to a fresh
120/// one-time launch nonce. The child echoes it in `ModuleHelloBody::launch_nonce`;
121/// subc accepts a reserved module_id's HELLO only when the nonce matches the one it
122/// last injected for that id. Non-reserved modules never receive it.
123pub const SUBC_LAUNCH_NONCE_ENV: &str = "SUBC_LAUNCH_NONCE";
124
125/// Fixed header length for `PROTOCOL_VERSION` 2.
126pub const HEADER_LEN: usize = 21;
127
128/// Bytes of the frozen prefix (`len` u32 + `ver` u8) that are stable across
129/// every envelope version. A reader needs only these to learn the version and
130/// thus the full header length.
131pub const FROZEN_PREFIX_LEN: usize = 5;
132
133/// Maximum frame body accepted before allocation.
134///
135/// This 64 MiB starting cap prevents a malformed header from forcing an
136/// unbounded allocation. Future protocol versions can negotiate or encode a
137/// different cap while preserving the frozen prefix.
138pub const MAX_FRAME_BODY_LEN: u32 = 64 * 1024 * 1024;
139
140/// Canonical JSON body for all subc-generated `ERROR` frames.
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct ErrorBody {
143    pub code: String,
144    pub message: String,
145}
146
147/// Module-to-subc `HELLO` body used during module registration.
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
149pub struct ModuleHelloBody {
150    pub manifest: manifest::ModuleManifest,
151    pub protocol_ver: u8,
152    #[serde(default)]
153    pub control_ops: Option<Vec<String>>,
154    /// One-time launch nonce, echoed back from the `SUBC_LAUNCH_NONCE` environment
155    /// variable the daemon injected when it spawned this process. Only a daemon-spawned
156    /// process for a `reserved` module receives a nonce; subc accepts a reserved
157    /// `module_id`'s HELLO only when this matches the nonce it last injected for that
158    /// id, so a different process cannot register as a reserved module while the real
159    /// one is down/restarting. Absent (`serde(default)`) for non-reserved modules and
160    /// self-connecting providers, which are never nonce-checked.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub launch_nonce: Option<String>,
163}
164
165/// subc-to-module `HELLO_ACK` body used during module registration.
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
167pub struct ModuleHelloAckBody {
168    pub negotiated_ver: u8,
169    pub subc_ops: Vec<String>,
170    pub subc_capabilities: Vec<String>,
171    /// The module's resolved storage descriptor, when the daemon's central config
172    /// configures managed storage. Carried opaquely here (subc-protocol stays a
173    /// thin wire crate with no storage/database dependency); a module that uses
174    /// managed storage deserializes it into `cortexkit_store_types::StorageDescriptor`
175    /// and hands it to `cortexkit-store`. Absent when no storage is configured, and
176    /// `serde(default)` so an older module simply ignores it.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub storage: Option<serde_json::Value>,
179}
180
181/// Frame kind (`type` byte at offset 5).
182///
183/// `CANCEL`, `PING`, `PONG`, and `GOODBYE` are pure-header frames (`len == 0`);
184/// only `HELLO`/`HELLO_ACK` and the RPC payloads carry bodies.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186#[repr(u8)]
187pub enum FrameType {
188    Request = 0,
189    Response = 1,
190    Push = 2,
191    StreamData = 3,
192    StreamEnd = 4,
193    Error = 5,
194    Cancel = 6,
195    Ping = 7,
196    Pong = 8,
197    Hello = 9,
198    HelloAck = 10,
199    Goodbye = 11,
200}
201
202impl FrameType {
203    /// Map the raw `type` byte to a `FrameType`, or `None` if unknown.
204    pub fn from_u8(b: u8) -> Option<Self> {
205        Some(match b {
206            0 => Self::Request,
207            1 => Self::Response,
208            2 => Self::Push,
209            3 => Self::StreamData,
210            4 => Self::StreamEnd,
211            5 => Self::Error,
212            6 => Self::Cancel,
213            7 => Self::Ping,
214            8 => Self::Pong,
215            9 => Self::Hello,
216            10 => Self::HelloAck,
217            11 => Self::Goodbye,
218            _ => return None,
219        })
220    }
221
222    pub fn is_pure_header(self) -> bool {
223        matches!(self, Self::Cancel | Self::Ping | Self::Pong | Self::Goodbye)
224    }
225}
226
227/// Scheduling priority carried in `flags` bits 1-2. subc schedules on this
228/// without parsing the body.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230#[repr(u8)]
231pub enum Priority {
232    Passive = 0,
233    Interactive = 1,
234    Background = 2,
235}
236
237impl Priority {
238    fn from_bits(bits: u8) -> Option<Self> {
239        Some(match bits {
240            0 => Self::Passive,
241            1 => Self::Interactive,
242            2 => Self::Background,
243            _ => return None,
244        })
245    }
246}
247
248/// Admission behavior carried in `flags` bits 4-5.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250#[repr(u8)]
251pub enum AdmissionClass {
252    Normal = 0,
253    Expedite = 1,
254    Sheddable = 2,
255}
256
257impl AdmissionClass {
258    fn from_bits(bits: u8) -> Option<Self> {
259        Some(match bits {
260            0 => Self::Normal,
261            1 => Self::Expedite,
262            2 => Self::Sheddable,
263            _ => return None,
264        })
265    }
266}
267
268const FLAG_BINARY: u8 = 0b0000_0001; // bit 0
269const FLAG_PRIORITY_MASK: u8 = 0b0000_0110; // bits 1-2
270const FLAG_PRIORITY_SHIFT: u8 = 1;
271const FLAG_LAST: u8 = 0b0000_1000; // bit 3
272const FLAG_ADMISSION_MASK: u8 = 0b0011_0000; // bits 4-5
273const FLAG_ADMISSION_SHIFT: u8 = 4;
274const FLAG_RESERVED_MASK: u8 = 0b1100_0000; // bits 6-7 must be zero
275
276/// The `flags` byte (offset 6): binary, priority, last, admission, then reserved bits.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub struct Flags(pub u8);
279
280impl Flags {
281    /// Build flags with the default [`AdmissionClass::Normal`] class.
282    pub fn new(binary: bool, priority: Priority, last: bool) -> Self {
283        let mut b = 0u8;
284        if binary {
285            b |= FLAG_BINARY;
286        }
287        b |= (priority as u8) << FLAG_PRIORITY_SHIFT;
288        if last {
289            b |= FLAG_LAST;
290        }
291        Flags(b)
292    }
293
294    /// Return these flags with a typed admission class.
295    pub fn with_admission_class(mut self, admission_class: AdmissionClass) -> Self {
296        self.0 =
297            (self.0 & !FLAG_ADMISSION_MASK) | ((admission_class as u8) << FLAG_ADMISSION_SHIFT);
298        self
299    }
300
301    /// Body is raw bytes (bulk lane) rather than JSON-RPC.
302    pub fn is_binary(self) -> bool {
303        self.0 & FLAG_BINARY != 0
304    }
305
306    /// Final frame of a streamed message.
307    pub fn is_last(self) -> bool {
308        self.0 & FLAG_LAST != 0
309    }
310
311    /// Decode the priority bits, or `None` if they hold a reserved value.
312    pub fn priority(self) -> Option<Priority> {
313        Priority::from_bits((self.0 & FLAG_PRIORITY_MASK) >> FLAG_PRIORITY_SHIFT)
314    }
315
316    /// Decode the admission-class bits, or `None` if they hold `0b11`.
317    pub fn admission_class(self) -> Option<AdmissionClass> {
318        AdmissionClass::from_bits((self.0 & FLAG_ADMISSION_MASK) >> FLAG_ADMISSION_SHIFT)
319    }
320
321    /// True if either reserved bit (6-7) is set.
322    pub fn has_reserved_bits(self) -> bool {
323        self.0 & FLAG_RESERVED_MASK != 0
324    }
325}
326
327/// A decoded envelope header. The body is the `len` bytes that follow it.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub struct EnvelopeHeader {
330    /// Number of body bytes after the header.
331    pub len: u32,
332    /// Envelope version.
333    pub ver: u8,
334    /// Frame kind.
335    pub ty: FrameType,
336    /// Flag bits.
337    pub flags: Flags,
338    /// Sender-local route slot; 0 is the control channel.
339    pub channel: u16,
340    /// Sender-local binding epoch; 0 is reserved for the control channel.
341    pub epoch: u32,
342    /// Correlation id.
343    pub corr: u64,
344}
345
346impl EnvelopeHeader {
347    /// Serialize the header to its fixed 21-byte little-endian form.
348    pub fn encode(&self) -> [u8; HEADER_LEN] {
349        let mut buf = [0u8; HEADER_LEN];
350        buf[0..4].copy_from_slice(&self.len.to_le_bytes());
351        buf[4] = self.ver;
352        buf[5] = self.ty as u8;
353        buf[6] = self.flags.0;
354        buf[7..9].copy_from_slice(&self.channel.to_le_bytes());
355        buf[9..13].copy_from_slice(&self.epoch.to_le_bytes());
356        buf[13..21].copy_from_slice(&self.corr.to_le_bytes());
357        buf
358    }
359}
360
361/// Why a header could not be decoded.
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum DecodeError {
364    /// Fewer than `FROZEN_PREFIX_LEN` bytes — cannot even read `len`/`ver`.
365    TooShortForPrefix { have: usize },
366    /// `ver` is not a version this build understands.
367    UnsupportedVersion { ver: u8 },
368    /// Version known but fewer than its header length is present.
369    TooShortForHeader { have: usize, need: usize },
370    /// `type` byte is not a known `FrameType`.
371    UnknownFrameType { byte: u8 },
372    /// A reserved flag bit (6-7) is set.
373    ReservedFlagBits { flags: u8 },
374    /// Priority bits 1-2 hold the reserved value `0b11`.
375    ReservedPriorityBits { flags: u8 },
376    /// Admission bits 4-5 hold the reserved value `0b11`.
377    ReservedAdmissionClass { flags: u8 },
378    /// SHEDDABLE is set on a frame type that must be delivered.
379    SheddableIllegalFrameType { ty: FrameType, flags: u8 },
380    /// Channel 0 carried an epoch other than its reserved epoch 0.
381    NonzeroEpochOnControlChannel { epoch: u32 },
382    /// A pure-header frame declared body bytes.
383    PureHeaderFrameWithBody { ty: FrameType, len: u32 },
384}
385
386impl fmt::Display for DecodeError {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        match self {
389            Self::TooShortForPrefix { have } => {
390                write!(f, "header shorter than frozen prefix: have {have} bytes")
391            }
392            Self::UnsupportedVersion { ver } => write!(f, "unsupported envelope version {ver}"),
393            Self::TooShortForHeader { have, need } => {
394                write!(
395                    f,
396                    "header too short for version: have {have} bytes, need {need}"
397                )
398            }
399            Self::UnknownFrameType { byte } => write!(f, "unknown frame type byte {byte}"),
400            Self::ReservedFlagBits { flags } => {
401                write!(f, "reserved flag bits set in flags 0b{flags:08b}")
402            }
403            Self::ReservedPriorityBits { flags } => {
404                write!(f, "reserved priority bits set in flags 0b{flags:08b}")
405            }
406            Self::ReservedAdmissionClass { flags } => {
407                write!(f, "reserved admission class set in flags 0b{flags:08b}")
408            }
409            Self::SheddableIllegalFrameType { ty, flags } => write!(
410                f,
411                "SHEDDABLE admission class is illegal on {ty:?} in flags 0b{flags:08b}"
412            ),
413            Self::NonzeroEpochOnControlChannel { epoch } => {
414                write!(f, "control channel carried nonzero epoch {epoch}")
415            }
416            Self::PureHeaderFrameWithBody { ty, len } => {
417                write!(
418                    f,
419                    "pure-header frame {ty:?} declared non-zero body length {len}"
420                )
421            }
422        }
423    }
424}
425
426impl Error for DecodeError {}
427
428/// How many header bytes a given envelope version occupies. Driven by the
429/// frozen prefix: read `ver`, then learn the full header length here.
430fn header_len_for_version(ver: u8) -> Option<usize> {
431    match ver {
432        PROTOCOL_VERSION => Some(HEADER_LEN),
433        _ => None,
434    }
435}
436
437/// Decode an envelope header from the front of `bytes`, following the
438/// frozen-prefix discipline:
439/// 1. need at least the 5-byte prefix to read `len` + `ver`;
440/// 2. dispatch the full header length on `ver`;
441/// 3. need the full header present; then parse the rest.
442///
443/// Never panics on malformed input — returns a typed [`DecodeError`].
444pub fn decode_header(bytes: &[u8]) -> Result<EnvelopeHeader, DecodeError> {
445    if bytes.len() < FROZEN_PREFIX_LEN {
446        return Err(DecodeError::TooShortForPrefix { have: bytes.len() });
447    }
448    let ver = bytes[4];
449    let need = header_len_for_version(ver).ok_or(DecodeError::UnsupportedVersion { ver })?;
450    if bytes.len() < need {
451        return Err(DecodeError::TooShortForHeader {
452            have: bytes.len(),
453            need,
454        });
455    }
456
457    let len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
458    let ty =
459        FrameType::from_u8(bytes[5]).ok_or(DecodeError::UnknownFrameType { byte: bytes[5] })?;
460    let flags = Flags(bytes[6]);
461    if flags.has_reserved_bits() {
462        return Err(DecodeError::ReservedFlagBits { flags: bytes[6] });
463    }
464    if flags.priority().is_none() {
465        return Err(DecodeError::ReservedPriorityBits { flags: bytes[6] });
466    }
467    let admission_class = flags
468        .admission_class()
469        .ok_or(DecodeError::ReservedAdmissionClass { flags: bytes[6] })?;
470    if admission_class == AdmissionClass::Sheddable
471        && !matches!(ty, FrameType::Push | FrameType::StreamData)
472    {
473        return Err(DecodeError::SheddableIllegalFrameType {
474            ty,
475            flags: bytes[6],
476        });
477    }
478    if ty.is_pure_header() && len != 0 {
479        return Err(DecodeError::PureHeaderFrameWithBody { ty, len });
480    }
481    let channel = u16::from_le_bytes([bytes[7], bytes[8]]);
482    let epoch = u32::from_le_bytes([bytes[9], bytes[10], bytes[11], bytes[12]]);
483    if channel == 0 && epoch != 0 {
484        return Err(DecodeError::NonzeroEpochOnControlChannel { epoch });
485    }
486    let corr = u64::from_le_bytes([
487        bytes[13], bytes[14], bytes[15], bytes[16], bytes[17], bytes[18], bytes[19], bytes[20],
488    ]);
489
490    Ok(EnvelopeHeader {
491        len,
492        ver,
493        ty,
494        flags,
495        channel,
496        epoch,
497        corr,
498    })
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    fn hdr(len: u32, ty: FrameType, flags: Flags, channel: u16, corr: u64) -> EnvelopeHeader {
506        hdr_with_epoch(len, ty, flags, channel, u32::from(channel != 0), corr)
507    }
508
509    fn hdr_with_epoch(
510        len: u32,
511        ty: FrameType,
512        flags: Flags,
513        channel: u16,
514        epoch: u32,
515        corr: u64,
516    ) -> EnvelopeHeader {
517        EnvelopeHeader {
518            len,
519            ver: PROTOCOL_VERSION,
520            ty,
521            flags,
522            channel,
523            epoch,
524            corr,
525        }
526    }
527
528    #[test]
529    fn bind_identity_round_trips_json() {
530        let identity = BindIdentity {
531            project_root: PathBuf::from("/tmp/project"),
532            harness: "opencode".to_string(),
533            session: "session-1".to_string(),
534        };
535
536        let encoded = serde_json::to_vec(&identity).unwrap();
537        let decoded: BindIdentity = serde_json::from_slice(&encoded).unwrap();
538
539        assert_eq!(decoded, identity);
540    }
541
542    #[test]
543    fn route_target_variants_round_trip_json() {
544        let targets = [
545            RouteTarget::ToolProvider {
546                module_id: "aft".to_string(),
547            },
548            RouteTarget::ManagementSurface {
549                module_id: "memory".to_string(),
550            },
551            RouteTarget::InternalService {
552                module_id: "bus".to_string(),
553                service_id: "dm".to_string(),
554            },
555        ];
556
557        for target in targets {
558            let encoded = serde_json::to_vec(&target).unwrap();
559            let decoded: RouteTarget = serde_json::from_slice(&encoded).unwrap();
560            assert_eq!(decoded, target);
561        }
562    }
563
564    #[test]
565    fn error_body_round_trips_json() {
566        let body = ErrorBody {
567            code: "config_divergence".to_string(),
568            message: "active config differs".to_string(),
569        };
570
571        let encoded = serde_json::to_vec(&body).unwrap();
572        let decoded: ErrorBody = serde_json::from_slice(&encoded).unwrap();
573
574        assert_eq!(decoded, body);
575    }
576
577    #[test]
578    fn round_trip_request() {
579        let h = hdr(
580            1234,
581            FrameType::Request,
582            Flags::new(false, Priority::Interactive, false),
583            42,
584            0xDEAD_BEEF_0000_0001,
585        );
586        let decoded = decode_header(&h.encode()).unwrap();
587        assert_eq!(h, decoded);
588    }
589
590    #[test]
591    fn round_trip_all_frame_types() {
592        for b in 0u8..=11 {
593            let ty = FrameType::from_u8(b).unwrap();
594            let h = hdr(0, ty, Flags::new(false, Priority::Passive, false), 0, 0);
595            assert_eq!(decode_header(&h.encode()).unwrap().ty, ty);
596        }
597    }
598
599    #[test]
600    fn pure_header_frame_has_zero_len() {
601        // CANCEL carries only header (len = 0) + the target corr.
602        let h = hdr(
603            0,
604            FrameType::Cancel,
605            Flags::new(false, Priority::Passive, false),
606            7,
607            99,
608        );
609        let d = decode_header(&h.encode()).unwrap();
610        assert_eq!(d.len, 0);
611        assert_eq!(d.corr, 99);
612    }
613
614    #[test]
615    fn flags_round_trip() {
616        let f = Flags::new(true, Priority::Background, true)
617            .with_admission_class(AdmissionClass::Expedite);
618        assert!(f.is_binary());
619        assert!(f.is_last());
620        assert_eq!(f.priority(), Some(Priority::Background));
621        assert_eq!(f.admission_class(), Some(AdmissionClass::Expedite));
622        let h = hdr(8, FrameType::StreamData, f, 1, 1);
623        assert_eq!(decode_header(&h.encode()).unwrap().flags, f);
624    }
625
626    #[test]
627    fn little_endian_and_frozen_prefix_layout() {
628        let h = hdr_with_epoch(
629            0x0403_0201,
630            FrameType::Request,
631            Flags(0),
632            0x0605,
633            0x0a09_0807,
634            0x1211_100f_0e0d_0c0b,
635        );
636        let buf = h.encode();
637        assert_eq!(&buf[0..4], &[1, 2, 3, 4]);
638        assert_eq!(buf[4], PROTOCOL_VERSION);
639        assert_eq!(&buf[7..9], &[5, 6]);
640        assert_eq!(&buf[9..13], &[7, 8, 9, 10]);
641        assert_eq!(&buf[13..21], &[11, 12, 13, 14, 15, 16, 17, 18]);
642        assert_eq!(buf.len(), HEADER_LEN);
643    }
644
645    #[test]
646    fn reject_too_short_for_prefix() {
647        assert_eq!(
648            decode_header(&[0, 0, 0, 0]),
649            Err(DecodeError::TooShortForPrefix { have: 4 })
650        );
651    }
652
653    #[test]
654    fn reject_too_short_for_header() {
655        // Valid 5-byte prefix but the v2 header is truncated.
656        let mut b = [0u8; 10];
657        b[4] = PROTOCOL_VERSION;
658        assert_eq!(
659            decode_header(&b),
660            Err(DecodeError::TooShortForHeader {
661                have: 10,
662                need: HEADER_LEN
663            })
664        );
665    }
666
667    #[test]
668    fn reject_unsupported_version() {
669        let mut b = [0u8; HEADER_LEN];
670        b[4] = 1;
671        assert_eq!(
672            decode_header(&b),
673            Err(DecodeError::UnsupportedVersion { ver: 1 })
674        );
675    }
676
677    #[test]
678    fn reject_unknown_frame_type() {
679        let mut b = [0u8; HEADER_LEN];
680        b[4] = PROTOCOL_VERSION;
681        b[5] = 99;
682        assert_eq!(
683            decode_header(&b),
684            Err(DecodeError::UnknownFrameType { byte: 99 })
685        );
686    }
687
688    #[test]
689    fn reject_reserved_flag_bits() {
690        let mut b = [0u8; HEADER_LEN];
691        b[4] = PROTOCOL_VERSION;
692        b[5] = FrameType::Request as u8;
693        b[6] = 0b1000_0000; // reserved bit 7 set
694        assert_eq!(
695            decode_header(&b),
696            Err(DecodeError::ReservedFlagBits { flags: 0b1000_0000 })
697        );
698    }
699
700    #[test]
701    fn reject_reserved_priority_bits() {
702        let mut b = [0u8; HEADER_LEN];
703        b[4] = PROTOCOL_VERSION;
704        b[5] = FrameType::Request as u8;
705        b[6] = 0b0000_0110; // priority bits 1-2 are reserved value 0b11
706        assert_eq!(
707            decode_header(&b),
708            Err(DecodeError::ReservedPriorityBits { flags: 0b0000_0110 })
709        );
710    }
711
712    #[test]
713    fn reject_pure_header_frame_with_body_len() {
714        let h = hdr(
715            1,
716            FrameType::Ping,
717            Flags::new(false, Priority::Passive, false),
718            0,
719            1,
720        );
721        assert_eq!(
722            decode_header(&h.encode()),
723            Err(DecodeError::PureHeaderFrameWithBody {
724                ty: FrameType::Ping,
725                len: 1
726            })
727        );
728    }
729
730    #[test]
731    fn epoch_boundaries_round_trip() {
732        for (channel, epoch) in [(0, 0), (1, 1), (u16::MAX, u32::MAX)] {
733            let h = hdr_with_epoch(
734                0,
735                FrameType::Request,
736                Flags::new(false, Priority::Passive, false),
737                channel,
738                epoch,
739                9,
740            );
741            assert_eq!(decode_header(&h.encode()).unwrap(), h);
742        }
743    }
744
745    #[test]
746    fn admission_classes_accept_three_values_and_reject_reserved_value() {
747        for (ty, admission_class) in [
748            (FrameType::Request, AdmissionClass::Normal),
749            (FrameType::Request, AdmissionClass::Expedite),
750            (FrameType::Push, AdmissionClass::Sheddable),
751            (FrameType::StreamData, AdmissionClass::Sheddable),
752        ] {
753            let flags = Flags::new(false, Priority::Interactive, false)
754                .with_admission_class(admission_class);
755            let h = hdr(0, ty, flags, 1, 2);
756            assert_eq!(decode_header(&h.encode()).unwrap().flags, flags);
757        }
758
759        let mut h = hdr(
760            0,
761            FrameType::Push,
762            Flags::new(false, Priority::Passive, false),
763            1,
764            2,
765        )
766        .encode();
767        h[6] |= 0b0011_0000;
768        assert_eq!(
769            decode_header(&h),
770            Err(DecodeError::ReservedAdmissionClass { flags: h[6] })
771        );
772    }
773
774    #[test]
775    fn sheddable_rejected_on_every_illegal_frame_type() {
776        let flags = Flags::new(false, Priority::Passive, false)
777            .with_admission_class(AdmissionClass::Sheddable);
778        for ty in [
779            FrameType::Request,
780            FrameType::Response,
781            FrameType::StreamEnd,
782            FrameType::Error,
783            FrameType::Cancel,
784            FrameType::Ping,
785            FrameType::Pong,
786            FrameType::Hello,
787            FrameType::HelloAck,
788            FrameType::Goodbye,
789        ] {
790            let h = hdr(0, ty, flags, 1, 2);
791            assert_eq!(
792                decode_header(&h.encode()),
793                Err(DecodeError::SheddableIllegalFrameType { ty, flags: flags.0 })
794            );
795        }
796    }
797
798    #[test]
799    fn nonzero_epoch_on_control_channel_is_rejected() {
800        let h = hdr_with_epoch(
801            0,
802            FrameType::Request,
803            Flags::new(false, Priority::Passive, false),
804            0,
805            u32::MAX,
806            2,
807        );
808        assert_eq!(
809            decode_header(&h.encode()),
810            Err(DecodeError::NonzeroEpochOnControlChannel { epoch: u32::MAX })
811        );
812    }
813}