Skip to main content

hatchery_core/
multiplex.rs

1//! Binary multiplex framing for the C2 byte restreamer (A2).
2//!
3//! C2 sees only the envelope: `seq`, destination, channel kind and size.
4//! Payload bytes are opaque (inner ciphertext). No g4a / m4a parsing here.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Wire version for multiplex frames.
10pub const MULTIPLEX_VERSION: u8 = 1;
11
12/// Maximum DATA payload length (1 MiB). Larger payloads are split by the sender.
13pub const MAX_DATA_PAYLOAD: usize = 1024 * 1024;
14
15/// Frame header size before the payload: version, type, channel_id, seq, length.
16pub const FRAME_HEADER_LEN: usize = 1 + 1 + 4 + 8 + 4;
17
18
19/// Grow a stream read buffer when a truncated frame cannot fit.
20/// Caps at `MAX_DATA_PAYLOAD + FRAME_HEADER_LEN`. Returns Err if already at cap
21/// (the peer is sending something larger than the protocol allows).
22pub fn grow_read_buf(buf: &mut Vec<u8>, filled: usize) -> Result<(), FrameError> {
23    const CAP: usize = MAX_DATA_PAYLOAD + FRAME_HEADER_LEN;
24    if filled < buf.len() {
25        return Ok(());
26    }
27    if buf.len() >= CAP {
28        return Err(FrameError::PayloadTooLarge(filled));
29    }
30    let next = (buf.len().saturating_mul(2)).clamp(filled + 64 * 1024, CAP);
31    buf.resize(next, 0);
32    Ok(())
33}
34
35
36#[repr(u8)]
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum FrameType {
39    Open = 1,
40    Data = 2,
41    Close = 3,
42    Window = 4,
43    Ping = 5,
44    Pong = 6,
45    GoAway = 7,
46    Ack = 8,
47    Resume = 9,
48    /// C2 → node: request journal entries with seq > N.
49    JournalReq = 10,
50    /// node → C2: journal entries (payload = JournalReplyBody).
51    JournalReply = 11,
52}
53
54impl FrameType {
55    pub fn from_u8(v: u8) -> Option<Self> {
56        Some(match v {
57            1 => Self::Open,
58            2 => Self::Data,
59            3 => Self::Close,
60            4 => Self::Window,
61            5 => Self::Ping,
62            6 => Self::Pong,
63            7 => Self::GoAway,
64            8 => Self::Ack,
65            9 => Self::Resume,
66            10 => Self::JournalReq,
67            11 => Self::JournalReply,
68            _ => return None,
69        })
70    }
71}
72
73/// Logical channel kinds C2 routes without inspecting contents.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum ChannelKind {
77    G4aControl,
78    G4aPty,
79    G4aEvents,
80    M4a,
81    Vault,
82    Observation,
83}
84
85impl ChannelKind {
86    pub fn as_str(self) -> &'static str {
87        match self {
88            Self::G4aControl => "g4a.control",
89            Self::G4aPty => "g4a.pty",
90            Self::G4aEvents => "g4a.events",
91            Self::M4a => "m4a",
92            Self::Vault => "vault",
93            Self::Observation => "observation",
94        }
95    }
96
97    pub fn parse(s: &str) -> Option<Self> {
98        Some(match s {
99            "g4a.control" => Self::G4aControl,
100            "g4a.pty" => Self::G4aPty,
101            "g4a.events" => Self::G4aEvents,
102            "m4a" => Self::M4a,
103            "vault" => Self::Vault,
104            "observation" => Self::Observation,
105            _ => return None,
106        })
107    }
108}
109
110impl fmt::Display for ChannelKind {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116/// Payload of an OPEN frame. No secrets.
117#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
118pub struct OpenPayload {
119    pub channel_kind: ChannelKind,
120    /// Opaque node identifier assigned by C2; core never sees node addresses.
121    pub node_id: String,
122}
123
124/// One multiplex frame.
125#[derive(Clone, Debug, PartialEq, Eq)]
126pub struct Frame {
127    pub version: u8,
128    pub frame_type: FrameType,
129    pub channel_id: u32,
130    /// For DATA: event sequence. For ACK/RESUME: the acknowledged / resume-after number.
131    pub seq: u64,
132    pub payload: Vec<u8>,
133}
134
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub enum FrameError {
137    Truncated,
138    BadVersion(u8),
139    BadType(u8),
140    PayloadTooLarge(usize),
141    Io(String),
142}
143
144impl fmt::Display for FrameError {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        match self {
147            Self::Truncated => write!(f, "truncated frame"),
148            Self::BadVersion(v) => write!(f, "unsupported multiplex version {v}"),
149            Self::BadType(t) => write!(f, "unknown frame type {t}"),
150            Self::PayloadTooLarge(n) => write!(f, "payload length {n} exceeds limit"),
151            Self::Io(msg) => write!(f, "{msg}"),
152        }
153    }
154}
155
156impl std::error::Error for FrameError {}
157
158impl Frame {
159    pub fn open(channel_id: u32, payload: &OpenPayload) -> Result<Self, FrameError> {
160        let body = serde_json::to_vec(payload).map_err(|e| FrameError::Io(e.to_string()))?;
161        Ok(Self {
162            version: MULTIPLEX_VERSION,
163            frame_type: FrameType::Open,
164            channel_id,
165            seq: 0,
166            payload: body,
167        })
168    }
169
170    pub fn data(channel_id: u32, seq: u64, payload: Vec<u8>) -> Result<Self, FrameError> {
171        if payload.len() > MAX_DATA_PAYLOAD {
172            return Err(FrameError::PayloadTooLarge(payload.len()));
173        }
174        Ok(Self {
175            version: MULTIPLEX_VERSION,
176            frame_type: FrameType::Data,
177            channel_id,
178            seq,
179            payload,
180        })
181    }
182
183    pub fn ack(seq: u64) -> Self {
184        Self {
185            version: MULTIPLEX_VERSION,
186            frame_type: FrameType::Ack,
187            channel_id: 0,
188            seq,
189            payload: Vec::new(),
190        }
191    }
192
193    pub fn resume(after: u64) -> Self {
194        Self {
195            version: MULTIPLEX_VERSION,
196            frame_type: FrameType::Resume,
197            channel_id: 0,
198            seq: after,
199            payload: Vec::new(),
200        }
201    }
202
203    pub fn window(channel_id: u32, credit: u32) -> Self {
204        Self {
205            version: MULTIPLEX_VERSION,
206            frame_type: FrameType::Window,
207            channel_id,
208            seq: u64::from(credit),
209            payload: Vec::new(),
210        }
211    }
212
213    pub fn ping(nonce: u64) -> Self {
214        Self {
215            version: MULTIPLEX_VERSION,
216            frame_type: FrameType::Ping,
217            channel_id: 0,
218            seq: nonce,
219            payload: Vec::new(),
220        }
221    }
222
223    pub fn pong(nonce: u64) -> Self {
224        Self {
225            version: MULTIPLEX_VERSION,
226            frame_type: FrameType::Pong,
227            channel_id: 0,
228            seq: nonce,
229            payload: Vec::new(),
230        }
231    }
232
233    pub fn goaway(code: u32) -> Self {
234        Self {
235            version: MULTIPLEX_VERSION,
236            frame_type: FrameType::GoAway,
237            channel_id: 0,
238            seq: u64::from(code),
239            payload: Vec::new(),
240        }
241    }
242
243    pub fn close(channel_id: u32) -> Self {
244        Self {
245            version: MULTIPLEX_VERSION,
246            frame_type: FrameType::Close,
247            channel_id,
248            seq: 0,
249            payload: Vec::new(),
250        }
251    }
252
253    /// Encode big-endian wire bytes.
254    pub fn encode(&self) -> Result<Vec<u8>, FrameError> {
255        if self.payload.len() > MAX_DATA_PAYLOAD {
256            return Err(FrameError::PayloadTooLarge(self.payload.len()));
257        }
258        let mut out = Vec::with_capacity(FRAME_HEADER_LEN + self.payload.len());
259        out.push(self.version);
260        out.push(self.frame_type as u8);
261        out.extend_from_slice(&self.channel_id.to_be_bytes());
262        out.extend_from_slice(&self.seq.to_be_bytes());
263        out.extend_from_slice(&(self.payload.len() as u32).to_be_bytes());
264        out.extend_from_slice(&self.payload);
265        Ok(out)
266    }
267
268    /// Decode one frame from a complete buffer. Returns `(frame, bytes_consumed)`.
269    pub fn decode(buf: &[u8]) -> Result<(Self, usize), FrameError> {
270        if buf.len() < FRAME_HEADER_LEN {
271            return Err(FrameError::Truncated);
272        }
273        let version = buf[0];
274        if version != MULTIPLEX_VERSION {
275            return Err(FrameError::BadVersion(version));
276        }
277        let frame_type = FrameType::from_u8(buf[1]).ok_or(FrameError::BadType(buf[1]))?;
278        let channel_id = u32::from_be_bytes([buf[2], buf[3], buf[4], buf[5]]);
279        let seq = u64::from_be_bytes([
280            buf[6], buf[7], buf[8], buf[9], buf[10], buf[11], buf[12], buf[13],
281        ]);
282        let length = u32::from_be_bytes([buf[14], buf[15], buf[16], buf[17]]) as usize;
283        if length > MAX_DATA_PAYLOAD {
284            return Err(FrameError::PayloadTooLarge(length));
285        }
286        let total = FRAME_HEADER_LEN + length;
287        if buf.len() < total {
288            return Err(FrameError::Truncated);
289        }
290        Ok((
291            Self {
292                version,
293                frame_type,
294                channel_id,
295                seq,
296                payload: buf[FRAME_HEADER_LEN..total].to_vec(),
297            },
298            total,
299        ))
300    }
301
302    pub fn parse_open(&self) -> Result<OpenPayload, FrameError> {
303        if self.frame_type != FrameType::Open {
304            return Err(FrameError::Io("not an OPEN frame".into()));
305        }
306        serde_json::from_slice(&self.payload).map_err(|e| FrameError::Io(e.to_string()))
307    }
308
309    /// Journal request: `seq` = after, payload = u32 limit (big-endian).
310    pub fn journal_req(channel_id: u32, after: u64, limit: u32) -> Self {
311        Self {
312            version: MULTIPLEX_VERSION,
313            frame_type: FrameType::JournalReq,
314            channel_id,
315            seq: after,
316            payload: limit.to_be_bytes().to_vec(),
317        }
318    }
319
320    pub fn journal_req_limit(&self) -> Option<u32> {
321        if self.frame_type != FrameType::JournalReq || self.payload.len() != 4 {
322            return None;
323        }
324        Some(u32::from_be_bytes([
325            self.payload[0],
326            self.payload[1],
327            self.payload[2],
328            self.payload[3],
329        ]))
330    }
331
332    pub fn journal_reply(channel_id: u32, body: &JournalReplyBody) -> Result<Self, FrameError> {
333        let payload = body.encode()?;
334        if payload.len() > MAX_DATA_PAYLOAD {
335            return Err(FrameError::PayloadTooLarge(payload.len()));
336        }
337        Ok(Self {
338            version: MULTIPLEX_VERSION,
339            frame_type: FrameType::JournalReply,
340            channel_id,
341            seq: body.after,
342            payload,
343        })
344    }
345}
346
347/// One journal entry on the wire (opaque payload).
348#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
349pub struct JournalWireEntry {
350    pub seq: u64,
351    pub payload: Vec<u8>,
352}
353
354/// Body of JournalReply: entries with seq > after for one channel.
355#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
356pub struct JournalReplyBody {
357    pub after: u64,
358    pub entries: Vec<JournalWireEntry>,
359}
360
361impl JournalReplyBody {
362    pub fn encode(&self) -> Result<Vec<u8>, FrameError> {
363        serde_json::to_vec(self).map_err(|e| FrameError::Io(e.to_string()))
364    }
365
366    pub fn decode(bytes: &[u8]) -> Result<Self, FrameError> {
367        serde_json::from_slice(bytes).map_err(|e| FrameError::Io(e.to_string()))
368    }
369}
370
371/// Drain-stream envelope: C2 prefixes each uplink DATA with an opaque node id for core.
372#[derive(Clone, Debug, PartialEq, Eq)]
373pub struct DrainEnvelope {
374    /// Opaque destination / source node id. Not an address.
375    pub node_id: String,
376    pub frame: Frame,
377}
378
379impl DrainEnvelope {
380    pub fn encode(&self) -> Result<Vec<u8>, FrameError> {
381        let id = self.node_id.as_bytes();
382        if id.len() > 255 {
383            return Err(FrameError::Io("node_id too long".into()));
384        }
385        let frame_bytes = self.frame.encode()?;
386        let mut out = Vec::with_capacity(1 + id.len() + frame_bytes.len());
387        out.push(id.len() as u8);
388        out.extend_from_slice(id);
389        out.extend_from_slice(&frame_bytes);
390        Ok(out)
391    }
392
393    pub fn decode(buf: &[u8]) -> Result<(Self, usize), FrameError> {
394        if buf.is_empty() {
395            return Err(FrameError::Truncated);
396        }
397        let id_len = buf[0] as usize;
398        if buf.len() < 1 + id_len {
399            return Err(FrameError::Truncated);
400        }
401        let node_id = String::from_utf8(buf[1..1 + id_len].to_vec())
402            .map_err(|_| FrameError::Io("node_id is not utf-8".into()))?;
403        let (frame, n) = Frame::decode(&buf[1 + id_len..])?;
404        Ok((Self { node_id, frame }, 1 + id_len + n))
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn roundtrip_data_ack_resume() {
414        let data = Frame::data(7, 42, b"opaque-ciphertext".to_vec()).unwrap();
415        let bytes = data.encode().unwrap();
416        let (decoded, n) = Frame::decode(&bytes).unwrap();
417        assert_eq!(n, bytes.len());
418        assert_eq!(decoded, data);
419
420        let ack = Frame::ack(42);
421        let (d, _) = Frame::decode(&ack.encode().unwrap()).unwrap();
422        assert_eq!(d.frame_type, FrameType::Ack);
423        assert_eq!(d.seq, 42);
424
425        let resume = Frame::resume(41);
426        let (d, _) = Frame::decode(&resume.encode().unwrap()).unwrap();
427        assert_eq!(d.frame_type, FrameType::Resume);
428        assert_eq!(d.seq, 41);
429    }
430
431    #[test]
432    fn open_payload_roundtrip() {
433        let open = Frame::open(
434            1,
435            &OpenPayload {
436                channel_kind: ChannelKind::M4a,
437                node_id: "node-a".into(),
438            },
439        )
440        .unwrap();
441        let (decoded, _) = Frame::decode(&open.encode().unwrap()).unwrap();
442        let payload = decoded.parse_open().unwrap();
443        assert_eq!(payload.channel_kind, ChannelKind::M4a);
444        assert_eq!(payload.node_id, "node-a");
445    }
446
447
448    #[test]
449    fn grow_read_buf_accepts_frames_between_64kib_and_1mib() {
450        // Simulate the C2 uplink read loop: start at 64 KiB, receive a 100 KiB DATA.
451        let payload = vec![0xABu8; 100 * 1024];
452        let frame = Frame::data(1, 7, payload.clone()).unwrap();
453        let wire = frame.encode().unwrap();
454        assert!(wire.len() > 64 * 1024);
455
456        let mut buf = vec![0u8; 64 * 1024];
457        let mut filled = 0usize;
458        let mut offset = 0usize;
459        loop {
460            if filled == buf.len() {
461                grow_read_buf(&mut buf, filled).expect("must grow for 100 KiB frame");
462            }
463            let space = buf.len() - filled;
464            let take = space.min(wire.len() - offset);
465            buf[filled..filled + take].copy_from_slice(&wire[offset..offset + take]);
466            filled += take;
467            offset += take;
468            match Frame::decode(&buf[..filled]) {
469                Ok((decoded, consumed)) => {
470                    assert_eq!(decoded.payload, payload);
471                    assert_eq!(consumed, wire.len());
472                    break;
473                }
474                Err(FrameError::Truncated) => {
475                    assert!(offset < wire.len(), "still truncated after full wire");
476                    continue;
477                }
478                Err(e) => panic!("unexpected {e}"),
479            }
480        }
481    }
482
483    #[test]
484    fn rejects_oversized_payload() {
485        let big = vec![0u8; MAX_DATA_PAYLOAD + 1];
486        assert!(matches!(
487            Frame::data(1, 1, big),
488            Err(FrameError::PayloadTooLarge(_))
489        ));
490    }
491
492    #[test]
493    fn drain_envelope_prefixes_opaque_node_id() {
494        let env = DrainEnvelope {
495            node_id: "node-b".into(),
496            frame: Frame::data(3, 9, b"inner".to_vec()).unwrap(),
497        };
498        let bytes = env.encode().unwrap();
499        let (decoded, n) = DrainEnvelope::decode(&bytes).unwrap();
500        assert_eq!(n, bytes.len());
501        assert_eq!(decoded.node_id, "node-b");
502        assert_eq!(decoded.frame.seq, 9);
503        assert_eq!(decoded.frame.payload, b"inner");
504    }
505
506    #[test]
507    fn journal_req_roundtrip() {
508        let f = Frame::journal_req(3, 10, 64);
509        let (d, _) = Frame::decode(&f.encode().unwrap()).unwrap();
510        assert_eq!(d.frame_type, FrameType::JournalReq);
511        assert_eq!(d.seq, 10);
512        assert_eq!(d.journal_req_limit(), Some(64));
513        let body = JournalReplyBody {
514            after: 10,
515            entries: vec![JournalWireEntry {
516                seq: 11,
517                payload: b"x".to_vec(),
518            }],
519        };
520        let reply = Frame::journal_reply(3, &body).unwrap();
521        let (d, _) = Frame::decode(&reply.encode().unwrap()).unwrap();
522        let decoded = JournalReplyBody::decode(&d.payload).unwrap();
523        assert_eq!(decoded.entries[0].seq, 11);
524    }
525
526    #[test]
527    fn truncated_frame_errors() {
528        assert!(matches!(Frame::decode(&[1, 2, 3]), Err(FrameError::Truncated)));
529        let mut partial = Frame::data(1, 1, b"hello".to_vec()).unwrap().encode().unwrap();
530        partial.pop();
531        assert!(matches!(Frame::decode(&partial), Err(FrameError::Truncated)));
532    }
533}