Skip to main content

wscall_protocol/
lib.rs

1//! Shared protocol definitions for WSCALL.
2//!
3//! This crate contains the transport envelope, frame codec, encryption modes,
4//! and inline attachment model used by both the server and client crates.
5
6use std::sync::Arc;
7
8use aes_gcm::{Aes256Gcm, KeyInit as AesKeyInit, Nonce as AesNonce, aead::Aead as AesAead};
9use bytes::Bytes;
10use chacha20poly1305::{ChaCha20Poly1305, Nonce};
11use getrandom::getrandom;
12use serde::de::{Deserializer, MapAccess, SeqAccess, Visitor};
13use serde::ser::SerializeMap;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value, json};
16use sha2::{Digest, Sha256};
17use thiserror::Error;
18use x25519_dalek::{PublicKey, StaticSecret};
19
20// ─── ECDH Dynamic Key Agreement ─────────────────────────────────────────────
21//
22// When the ECDH handshake mode is enabled, the client and server exchange
23// raw X25519 public keys (32 bytes each) over the WebSocket binary channel
24// immediately after the TCP/TLS upgrade. Both sides derive the same 32-byte
25// ChaCha20-Poly1305 session key via SHA-256(domain ‖ DH secret). This key is
26// unique per connection and never travels over the wire.
27
28/// Domain-separation tag mixed into the SHA-256 KDF so that wscall session
29/// keys cannot collide with keys derived for any other protocol.
30pub const ECDH_DOMAIN_TAG: &[u8] = b"wscall-ecdh-v1";
31
32/// Byte length of an X25519 public key (also the secret key length).
33pub const ECDH_KEY_LEN: usize = 32;
34
35/// A freshly generated X25519 keypair for the ECDH handshake.
36///
37/// The secret is zeroized on drop. Keep the secret on the side that generated
38/// it and send only the [`EcdhKeypair::public`] bytes to the peer.
39pub struct EcdhKeypair {
40    secret: StaticSecret,
41    public: PublicKey,
42}
43
44impl EcdhKeypair {
45    /// Generates a random X25519 keypair using the platform CSPRNG.
46    pub fn generate() -> Result<Self, ProtocolError> {
47        let mut secret_bytes = [0u8; ECDH_KEY_LEN];
48        getrandom(&mut secret_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
49        let secret = StaticSecret::from(secret_bytes);
50        let public = PublicKey::from(&secret);
51        Ok(Self { secret, public })
52    }
53
54    /// Returns the 32-byte public key that should be sent to the peer.
55    pub fn public_bytes(&self) -> [u8; ECDH_KEY_LEN] {
56        self.public.to_bytes()
57    }
58
59    /// Derives the 32-byte ChaCha20-Poly1305 session key from the peer's
60    /// 32-byte public key.
61    ///
62    /// `peer_public` must be exactly 32 bytes received from the other side.
63    pub fn derive_session_key(&self, peer_public: &[u8; ECDH_KEY_LEN]) -> [u8; 32] {
64        let peer = PublicKey::from(*peer_public);
65        let shared = self.secret.diffie_hellman(&peer);
66        derive_session_key(&shared.to_bytes())
67    }
68}
69
70/// Derives a 32-byte symmetric key from a raw X25519 shared secret.
71///
72/// Uses SHA-256(domain ‖ shared_secret) for domain separation. The result is
73/// suitable as a ChaCha20-Poly1305 or AES-256-GCM key.
74pub fn derive_session_key(shared_secret: &[u8; 32]) -> [u8; 32] {
75    let mut hasher = Sha256::new();
76    hasher.update(ECDH_DOMAIN_TAG);
77    hasher.update(shared_secret);
78    let result = hasher.finalize();
79    let mut key = [0u8; 32];
80    key.copy_from_slice(&result);
81    key
82}
83
84/// Parses 32 raw bytes received from the peer into an X25519 public key.
85///
86/// Returns an error if the slice is not exactly 32 bytes.
87pub fn parse_peer_public(bytes: &[u8]) -> Result<[u8; ECDH_KEY_LEN], ProtocolError> {
88    if bytes.len() != ECDH_KEY_LEN {
89        return Err(ProtocolError::InvalidEcdhPublicKey {
90            expected: ECDH_KEY_LEN,
91            actual: bytes.len(),
92        });
93    }
94    let mut key = [0u8; ECDH_KEY_LEN];
95    key.copy_from_slice(bytes);
96    Ok(key)
97}
98
99const AES256_NONCE_LEN: usize = 12;
100const CHACHA20_NONCE_LEN: usize = 12;
101
102/// Default maximum frame size: 100 MiB.
103///
104/// This is no longer a hard protocol constant; the server configures it via
105/// `WscallServer::with_max_frame_bytes(n)` and the codec carries the value.
106pub const DEFAULT_MAX_FRAME_BYTES: usize = 100 * 1024 * 1024;
107
108/// Distinguishes API messages from event messages inside a WSCALL frame.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[repr(u8)]
111pub enum MessageType {
112    /// API request or API response.
113    Api = 0x00,
114    /// Event emit or event acknowledgement.
115    Event = 0x01,
116}
117
118impl TryFrom<u8> for MessageType {
119    type Error = ProtocolError;
120
121    fn try_from(value: u8) -> Result<Self, Self::Error> {
122        match value {
123            0x00 => Ok(Self::Api),
124            0x01 => Ok(Self::Event),
125            _ => Err(ProtocolError::UnknownMessageType(value)),
126        }
127    }
128}
129
130/// Selects how the payload section of a frame is encoded.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[repr(u8)]
133pub enum EncryptionKind {
134    /// Unencrypted JSON payload.
135    None = 0x00,
136    /// ChaCha20-Poly1305 encrypted payload.
137    ChaCha20 = 0x01,
138    /// AES256-GCM encrypted payload.
139    Aes256 = 0x02,
140}
141
142impl TryFrom<u8> for EncryptionKind {
143    type Error = ProtocolError;
144
145    fn try_from(value: u8) -> Result<Self, Self::Error> {
146        match value {
147            0x00 => Ok(Self::None),
148            0x01 => Ok(Self::ChaCha20),
149            0x02 => Ok(Self::Aes256),
150            _ => Err(ProtocolError::UnknownEncryption(value)),
151        }
152    }
153}
154
155/// Binary attachment carried alongside JSON params or event data.
156///
157/// In protocol v3 the attachment payload is stored as raw bytes in a binary
158/// section appended after the JSON metadata, eliminating the 33% overhead of
159/// Base64 encoding used in protocol v1.
160///
161/// The payload uses [`Bytes`] (reference-counted, immutable) so that cloning
162/// an attachment — e.g. during ECDH broadcasts — is a cheap refcount bump
163/// rather than a deep copy of the entire buffer.
164#[derive(Debug, Clone)]
165pub struct FileAttachment {
166    /// Attachment identifier referenced from JSON using `{ "$file": "..." }`.
167    pub id: String,
168    /// Original file name.
169    pub name: String,
170    /// MIME type supplied by the sender.
171    pub content_type: String,
172    /// Raw attachment payload bytes (zero-copy shared via `Bytes`).
173    pub data: Bytes,
174}
175
176impl FileAttachment {
177    /// Builds a text attachment from a string.
178    pub fn inline_text(
179        id: impl Into<String>,
180        name: impl Into<String>,
181        content_type: impl Into<String>,
182        text: impl AsRef<str>,
183    ) -> Self {
184        Self::inline_bytes(
185            id,
186            name,
187            content_type,
188            Bytes::from(text.as_ref().to_owned()),
189        )
190    }
191
192    /// Builds a binary attachment from raw bytes.
193    ///
194    /// Accepts anything convertible into [`Bytes`] (`Vec<u8>`, `Bytes`,
195    /// `&'static [u8]`, etc.).
196    pub fn inline_bytes(
197        id: impl Into<String>,
198        name: impl Into<String>,
199        content_type: impl Into<String>,
200        bytes: impl Into<Bytes>,
201    ) -> Self {
202        Self {
203            id: id.into(),
204            name: name.into(),
205            content_type: content_type.into(),
206            data: bytes.into(),
207        }
208    }
209
210    /// Returns the byte length of the attachment payload.
211    pub fn size(&self) -> usize {
212        self.data.len()
213    }
214
215    /// Returns a JSON reference object that points to an attachment by id.
216    pub fn param_ref(id: impl Into<String>) -> Value {
217        json!({ "$file": id.into() })
218    }
219
220    /// Computes the total wire size of this attachment's binary section.
221    ///
222    /// Layout: id_len:u8 + id + name_len:u8 + name + ct_len:u8 + ct + data_len:u32 + data
223    pub(crate) fn wire_size(&self) -> usize {
224        1 + self.id.len() + 1 + self.name.len() + 1 + self.content_type.len() + 4 + self.data.len()
225    }
226
227    /// Appends this attachment's binary section to `buf`.
228    pub(crate) fn write_wire(&self, buf: &mut Vec<u8>) {
229        buf.push(self.id.len() as u8);
230        buf.extend_from_slice(self.id.as_bytes());
231        buf.push(self.name.len() as u8);
232        buf.extend_from_slice(self.name.as_bytes());
233        buf.push(self.content_type.len() as u8);
234        buf.extend_from_slice(self.content_type.as_bytes());
235        buf.extend_from_slice(&(self.data.len() as u32).to_be_bytes());
236        buf.extend_from_slice(&self.data);
237    }
238
239    /// Parses one attachment binary section from `data`, returning the
240    /// attachment and the remaining unconsumed bytes.
241    ///
242    /// The payload is extracted via [`Bytes::slice`] which shares the
243    /// underlying buffer (zero-copy) rather than allocating a new `Vec`.
244    pub(crate) fn read_wire(data: &Bytes) -> Result<(Self, Bytes), ProtocolError> {
245        let mut pos = 0;
246
247        // id
248        if pos >= data.len() {
249            return Err(ProtocolError::InvalidAttachmentEncoding(
250                "truncated id_len".into(),
251            ));
252        }
253        let id_len = data[pos] as usize;
254        pos += 1;
255        if pos + id_len > data.len() {
256            return Err(ProtocolError::InvalidAttachmentEncoding(
257                "truncated id".into(),
258            ));
259        }
260        let id = String::from_utf8_lossy(&data[pos..pos + id_len]).into_owned();
261        pos += id_len;
262
263        // name
264        if pos >= data.len() {
265            return Err(ProtocolError::InvalidAttachmentEncoding(
266                "truncated name_len".into(),
267            ));
268        }
269        let name_len = data[pos] as usize;
270        pos += 1;
271        if pos + name_len > data.len() {
272            return Err(ProtocolError::InvalidAttachmentEncoding(
273                "truncated name".into(),
274            ));
275        }
276        let name = String::from_utf8_lossy(&data[pos..pos + name_len]).into_owned();
277        pos += name_len;
278
279        // content_type
280        if pos >= data.len() {
281            return Err(ProtocolError::InvalidAttachmentEncoding(
282                "truncated ct_len".into(),
283            ));
284        }
285        let ct_len = data[pos] as usize;
286        pos += 1;
287        if pos + ct_len > data.len() {
288            return Err(ProtocolError::InvalidAttachmentEncoding(
289                "truncated content_type".into(),
290            ));
291        }
292        let content_type = String::from_utf8_lossy(&data[pos..pos + ct_len]).into_owned();
293        pos += ct_len;
294
295        // data
296        if pos + 4 > data.len() {
297            return Err(ProtocolError::InvalidAttachmentEncoding(
298                "truncated data_len".into(),
299            ));
300        }
301        let data_len =
302            u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
303        pos += 4;
304        if pos + data_len > data.len() {
305            return Err(ProtocolError::InvalidAttachmentEncoding(
306                "truncated data".into(),
307            ));
308        }
309        // Zero-copy: slice shares the underlying Bytes buffer.
310        let payload = data.slice(pos..pos + data_len);
311        pos += data_len;
312
313        Ok((
314            Self {
315                id,
316                name,
317                content_type,
318                data: payload,
319            },
320            data.slice(pos..),
321        ))
322    }
323}
324
325// ─── Serde impls for FileAttachment ─────────────────────────────────────────
326//
327// `Bytes` does not implement Serialize/Deserialize out of the box. We provide
328// manual impls using `serialize_bytes` / `visit_bytes` so that the (rarely
329// used) JSON `a` field round-trips correctly.
330
331impl Serialize for FileAttachment {
332    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
333        let mut map = serializer.serialize_map(Some(4))?;
334        map.serialize_entry("id", &self.id)?;
335        map.serialize_entry("name", &self.name)?;
336        map.serialize_entry("content_type", &self.content_type)?;
337        map.serialize_entry("data", &self.data.to_vec())?;
338        map.end()
339    }
340}
341
342impl<'de> Deserialize<'de> for FileAttachment {
343    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
344        #[derive(Deserialize)]
345        #[serde(field_identifier, rename_all = "snake_case")]
346        enum Field {
347            Id,
348            Name,
349            ContentType,
350            Data,
351        }
352
353        struct FileAttachmentVisitor;
354
355        impl<'de> Visitor<'de> for FileAttachmentVisitor {
356            type Value = FileAttachment;
357
358            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
359                f.write_str("struct FileAttachment")
360            }
361
362            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
363                let mut id = None;
364                let mut name = None;
365                let mut content_type = None;
366                let mut data: Option<Bytes> = None;
367                while let Some(key) = map.next_key()? {
368                    match key {
369                        Field::Id => id = Some(map.next_value()?),
370                        Field::Name => name = Some(map.next_value()?),
371                        Field::ContentType => content_type = Some(map.next_value()?),
372                        Field::Data => {
373                            let bytes: Vec<u8> = map.next_value()?;
374                            data = Some(Bytes::from(bytes));
375                        }
376                    }
377                }
378                Ok(FileAttachment {
379                    id: id.ok_or_else(|| serde::de::Error::missing_field("id"))?,
380                    name: name.ok_or_else(|| serde::de::Error::missing_field("name"))?,
381                    content_type: content_type
382                        .ok_or_else(|| serde::de::Error::missing_field("content_type"))?,
383                    data: data.ok_or_else(|| serde::de::Error::missing_field("data"))?,
384                })
385            }
386
387            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
388                let id = seq
389                    .next_element()?
390                    .ok_or_else(|| serde::de::Error::invalid_length(0, &self))?;
391                let name = seq
392                    .next_element()?
393                    .ok_or_else(|| serde::de::Error::invalid_length(1, &self))?;
394                let content_type = seq
395                    .next_element()?
396                    .ok_or_else(|| serde::de::Error::invalid_length(2, &self))?;
397                let bytes: Vec<u8> = seq
398                    .next_element()?
399                    .ok_or_else(|| serde::de::Error::invalid_length(3, &self))?;
400                Ok(FileAttachment {
401                    id,
402                    name,
403                    content_type,
404                    data: Bytes::from(bytes),
405                })
406            }
407        }
408
409        const FIELDS: &[&str] = &["id", "name", "content_type", "data"];
410        deserializer.deserialize_struct("FileAttachment", FIELDS, FileAttachmentVisitor)
411    }
412}
413
414/// Standard error payload embedded in API responses and event acknowledgements.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct ErrorPayload {
417    pub code: String,
418    pub message: String,
419    pub status: u16,
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub details: Option<Value>,
422}
423
424/// Compact numeric tag identifying each [`PacketBody`] variant in the JSON
425/// wire format. Using a single-byte numeric field (`"k": 0`) instead of a
426/// string tag (`"kind": "api_request"`) saves several bytes per frame.
427pub const K_API_REQUEST: u8 = 0;
428pub const K_EVENT_EMIT: u8 = 1;
429pub const K_API_RESPONSE: u8 = 2;
430pub const K_EVENT_ACK: u8 = 3;
431
432/// JSON-level message body transported inside a WSCALL frame.
433///
434/// The wire format uses short single-letter keys and a numeric `k` tag to
435/// minimize per-frame overhead:
436///
437/// | Variant | `k` | Fields |
438/// | --- | --- | --- |
439/// | `ApiRequest`  | 0 | `i` `r` `p` `a` `m` |
440/// | `EventEmit`   | 1 | `i` `n` `d` `a` `m` `e` |
441/// | `ApiResponse` | 2 | `i` `o` `s` `d` `m` [`er`] |
442/// | `EventAck`    | 3 | `i` `o` `rc` [`er`] |
443#[derive(Debug, Clone)]
444pub enum PacketBody {
445    /// Client-to-server API request.
446    ApiRequest {
447        request_id: u64,
448        route: String,
449        params: Value,
450        attachments: Vec<FileAttachment>,
451        metadata: Value,
452    },
453    /// Server-to-client API response.
454    ApiResponse {
455        request_id: u64,
456        ok: bool,
457        status: u16,
458        data: Value,
459        error: Option<ErrorPayload>,
460        metadata: Value,
461    },
462    /// Event emission in either direction.
463    ///
464    /// The `data` payload is always a JSON object (never a scalar or string).
465    EventEmit {
466        event_id: u64,
467        name: String,
468        data: Map<String, Value>,
469        attachments: Vec<FileAttachment>,
470        metadata: Value,
471        expect_ack: bool,
472    },
473    /// Acknowledgement for an emitted event.
474    EventAck {
475        event_id: u64,
476        ok: bool,
477        receipt: Value,
478        error: Option<ErrorPayload>,
479    },
480}
481
482// --- Manual Serialize: short keys + numeric `k` tag -------------------------
483
484/// Returns `true` when the metadata value carries no information (null or `{}`).
485fn metadata_is_empty(v: &Value) -> bool {
486    matches!(v, Value::Null) || v.as_object().is_some_and(|m| m.is_empty())
487}
488
489impl Serialize for PacketBody {
490    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
491    where
492        S: serde::Serializer,
493    {
494        match self {
495            Self::ApiRequest {
496                request_id,
497                route,
498                params,
499                attachments: _,
500                metadata,
501            } => {
502                let include_meta = !metadata_is_empty(metadata);
503                let field_count = 4 + usize::from(include_meta);
504                let mut s = serializer.serialize_map(Some(field_count))?;
505                s.serialize_entry("k", &K_API_REQUEST)?;
506                s.serialize_entry("i", request_id)?;
507                s.serialize_entry("r", route)?;
508                s.serialize_entry("p", params)?;
509                if include_meta {
510                    s.serialize_entry("m", metadata)?;
511                }
512                s.end()
513            }
514            Self::ApiResponse {
515                request_id,
516                ok,
517                status,
518                data,
519                error,
520                metadata,
521            } => {
522                let include_meta = !metadata_is_empty(metadata);
523                let field_count = 5 + usize::from(error.is_some()) + usize::from(include_meta);
524                let mut s = serializer.serialize_map(Some(field_count))?;
525                s.serialize_entry("k", &K_API_RESPONSE)?;
526                s.serialize_entry("i", request_id)?;
527                s.serialize_entry("o", ok)?;
528                s.serialize_entry("s", status)?;
529                s.serialize_entry("d", data)?;
530                if let Some(err) = error {
531                    s.serialize_entry("er", err)?;
532                }
533                if include_meta {
534                    s.serialize_entry("m", metadata)?;
535                }
536                s.end()
537            }
538            Self::EventEmit {
539                event_id,
540                name,
541                data,
542                attachments: _,
543                metadata,
544                expect_ack,
545            } => {
546                let include_meta = !metadata_is_empty(metadata);
547                let field_count = 5 + usize::from(include_meta);
548                let mut s = serializer.serialize_map(Some(field_count))?;
549                s.serialize_entry("k", &K_EVENT_EMIT)?;
550                s.serialize_entry("i", event_id)?;
551                s.serialize_entry("n", name)?;
552                s.serialize_entry("d", data)?;
553                if include_meta {
554                    s.serialize_entry("m", metadata)?;
555                }
556                s.serialize_entry("e", expect_ack)?;
557                s.end()
558            }
559            Self::EventAck {
560                event_id,
561                ok,
562                receipt,
563                error,
564            } => {
565                let field_count = 3 + usize::from(error.is_some()) + 1;
566                let mut s = serializer.serialize_map(Some(field_count))?;
567                s.serialize_entry("k", &K_EVENT_ACK)?;
568                s.serialize_entry("i", event_id)?;
569                s.serialize_entry("o", ok)?;
570                s.serialize_entry("rc", receipt)?;
571                if let Some(err) = error {
572                    s.serialize_entry("er", err)?;
573                }
574                s.end()
575            }
576        }
577    }
578}
579
580// --- Manual Deserialize: read numeric `k`, move fields out directly ----------
581//
582// The body is parsed into a JSON object map exactly once; each variant then
583// *moves* its fields out of that map. Nested payloads such as `params`, `data`,
584// `metadata` and `receipt` are transferred without a second traversal, unlike
585// the previous `Value::deserialize` + `serde_json::from_value` pair which walked
586// the whole tree twice.
587
588/// Moves a required unsigned integer field out of the map.
589fn take_u64<E: serde::de::Error>(map: &mut Map<String, Value>, key: &str) -> Result<u64, E> {
590    map.remove(key).and_then(|v| v.as_u64()).ok_or_else(|| {
591        E::custom(format!(
592            "missing or non-numeric '{key}' field in packet body"
593        ))
594    })
595}
596
597/// Moves a required string field out of the map.
598fn take_string<E: serde::de::Error>(map: &mut Map<String, Value>, key: &str) -> Result<String, E> {
599    match map.remove(key) {
600        Some(Value::String(s)) => Ok(s),
601        _ => Err(E::custom(format!(
602            "missing or non-string '{key}' field in packet body"
603        ))),
604    }
605}
606
607/// Moves an optional JSON object field, defaulting to an empty map.
608fn take_object<E: serde::de::Error>(
609    map: &mut Map<String, Value>,
610    key: &str,
611) -> Result<Map<String, Value>, E> {
612    match map.remove(key) {
613        None => Ok(Map::new()),
614        Some(Value::Object(o)) => Ok(o),
615        Some(_) => Err(E::custom(format!("'{key}' field must be a JSON object"))),
616    }
617}
618
619/// Moves an optional boolean field, defaulting to `false`.
620fn take_bool(map: &mut Map<String, Value>, key: &str) -> bool {
621    map.remove(key).and_then(|v| v.as_bool()).unwrap_or(false)
622}
623
624/// Moves an optional `u16` field, defaulting to `0`.
625fn take_u16(map: &mut Map<String, Value>, key: &str) -> u16 {
626    map.remove(key).and_then(|v| v.as_u64()).unwrap_or(0) as u16
627}
628
629/// Moves the optional binary-attachment reference list (`a`).
630///
631/// Attachments normally travel in the frame's binary section and are injected
632/// via [`PacketBody::set_attachments`]; the JSON `a` field is only honoured for
633/// completeness and is almost always absent.
634fn take_attachments<E: serde::de::Error>(
635    map: &mut Map<String, Value>,
636) -> Result<Vec<FileAttachment>, E> {
637    match map.remove("a") {
638        None => Ok(Vec::new()),
639        Some(value) => serde_json::from_value(value).map_err(E::custom),
640    }
641}
642
643/// Moves the optional error payload (`er`).
644fn take_error<E: serde::de::Error>(
645    map: &mut Map<String, Value>,
646) -> Result<Option<ErrorPayload>, E> {
647    match map.remove("er") {
648        None => Ok(None),
649        Some(value) => serde_json::from_value(value).map(Some).map_err(E::custom),
650    }
651}
652
653impl<'de> Deserialize<'de> for PacketBody {
654    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
655    where
656        D: Deserializer<'de>,
657    {
658        // Parse into a JSON object map once so we can inspect the numeric `k`
659        // discriminator, then move the remaining fields out by short key.
660        let mut map = match Value::deserialize(deserializer)? {
661            Value::Object(map) => map,
662            _ => {
663                return Err(serde::de::Error::custom(
664                    "packet body must be a JSON object",
665                ));
666            }
667        };
668
669        let k = map.get("k").and_then(|v| v.as_u64()).ok_or_else(|| {
670            serde::de::Error::custom("missing or non-numeric 'k' field in packet body")
671        })?;
672        let k = u8::try_from(k).map_err(|_| {
673            serde::de::Error::custom(format!("'k' value {k} is outside the valid range"))
674        })?;
675
676        match k {
677            K_API_REQUEST => Ok(Self::ApiRequest {
678                request_id: take_u64(&mut map, "i")?,
679                route: take_string(&mut map, "r")?,
680                params: map.remove("p").unwrap_or(Value::Null),
681                attachments: take_attachments(&mut map)?,
682                metadata: map.remove("m").unwrap_or(Value::Null),
683            }),
684            K_EVENT_EMIT => Ok(Self::EventEmit {
685                event_id: take_u64(&mut map, "i")?,
686                name: take_string(&mut map, "n")?,
687                data: take_object(&mut map, "d")?,
688                attachments: take_attachments(&mut map)?,
689                metadata: map.remove("m").unwrap_or(Value::Null),
690                expect_ack: take_bool(&mut map, "e"),
691            }),
692            K_API_RESPONSE => Ok(Self::ApiResponse {
693                request_id: take_u64(&mut map, "i")?,
694                ok: take_bool(&mut map, "o"),
695                status: take_u16(&mut map, "s"),
696                data: map.remove("d").unwrap_or(Value::Null),
697                error: take_error(&mut map)?,
698                metadata: map.remove("m").unwrap_or(Value::Null),
699            }),
700            K_EVENT_ACK => Ok(Self::EventAck {
701                event_id: take_u64(&mut map, "i")?,
702                ok: take_bool(&mut map, "o"),
703                receipt: map.remove("rc").unwrap_or(Value::Null),
704                error: take_error(&mut map)?,
705            }),
706            _ => Err(serde::de::Error::custom(format!("unknown 'k' value: {k}"))),
707        }
708    }
709}
710
711impl PacketBody {
712    pub fn message_type(&self) -> MessageType {
713        match self {
714            Self::ApiRequest { .. } | Self::ApiResponse { .. } => MessageType::Api,
715            Self::EventEmit { .. } | Self::EventAck { .. } => MessageType::Event,
716        }
717    }
718
719    /// Returns the attachments carried by this packet (empty for responses/acks).
720    pub fn attachments(&self) -> &[FileAttachment] {
721        match self {
722            Self::ApiRequest { attachments, .. } => attachments,
723            Self::EventEmit { attachments, .. } => attachments,
724            _ => &[],
725        }
726    }
727
728    /// Injects decoded binary attachments back into the packet body.
729    pub fn set_attachments(&mut self, attachments: Vec<FileAttachment>) {
730        match self {
731            Self::ApiRequest { attachments: a, .. } => *a = attachments,
732            Self::EventEmit { attachments: a, .. } => *a = attachments,
733            _ => {}
734        }
735    }
736}
737
738/// Full transport envelope before frame encoding.
739#[derive(Debug, Clone, Serialize, Deserialize)]
740pub struct PacketEnvelope {
741    /// Message category declared in the frame header.
742    pub message_type: MessageType,
743    /// Encryption mode declared in the frame header.
744    pub encryption: EncryptionKind,
745    /// JSON body payload.
746    pub body: PacketBody,
747}
748
749impl PacketEnvelope {
750    /// Builds a plaintext envelope from a body.
751    pub fn new(body: PacketBody) -> Self {
752        Self {
753            message_type: body.message_type(),
754            encryption: EncryptionKind::None,
755            body,
756        }
757    }
758
759    /// Builds an envelope with an explicit encryption mode.
760    pub fn with_encryption(body: PacketBody, encryption: EncryptionKind) -> Self {
761        Self {
762            message_type: body.message_type(),
763            encryption,
764            body,
765        }
766    }
767}
768
769/// Encodes and decodes WSCALL binary frames (protocol v3).
770///
771/// Protocol v3 uses a composite payload format: JSON metadata followed by raw
772/// binary attachment sections, eliminating the Base64 overhead of protocol v1.
773/// The frame header is 5 bytes (`frame_len:u32 | message_type:u8`); the
774/// per-frame encryption byte present in protocol v2 has been removed in favour
775/// of connection-level encryption (`wire_encryption`).
776///
777/// The encryption mode is a **connection-level** property stored in the codec
778/// (`wire_encryption`). It is determined once at connection setup (PSK config or
779/// ECDH handshake) and applies to every frame on that connection; the frame
780/// header no longer carries a per-frame encryption byte.
781///
782/// The symmetric ciphers are constructed once when a key is configured and shared
783/// via [`Arc`] across every clone of the codec. This avoids redoing the key
784/// schedule (which for AES-256-GCM is especially expensive) on every frame.
785#[derive(Clone)]
786pub struct FrameCodec {
787    aes256_cipher: Option<Arc<Aes256Gcm>>,
788    chacha20_cipher: Option<Arc<ChaCha20Poly1305>>,
789    /// Connection-level encryption mode applied to every frame.
790    wire_encryption: EncryptionKind,
791    /// Maximum total frame size (including the 4-byte length prefix).
792    max_frame_bytes: usize,
793}
794
795impl Default for FrameCodec {
796    fn default() -> Self {
797        Self {
798            aes256_cipher: None,
799            chacha20_cipher: None,
800            wire_encryption: EncryptionKind::None,
801            max_frame_bytes: DEFAULT_MAX_FRAME_BYTES,
802        }
803    }
804}
805
806impl std::fmt::Debug for FrameCodec {
807    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
808        f.debug_struct("FrameCodec")
809            .field("aes256", &self.aes256_cipher.is_some())
810            .field("chacha20", &self.chacha20_cipher.is_some())
811            .field("wire_encryption", &self.wire_encryption)
812            .field("max_frame_bytes", &self.max_frame_bytes)
813            .finish()
814    }
815}
816
817impl FrameCodec {
818    /// Builds a codec configured for plaintext transport.
819    pub fn plaintext() -> Self {
820        Self::default()
821    }
822
823    /// Configures a ChaCha20-Poly1305 key.
824    ///
825    /// The cipher is constructed eagerly so that subsequent frame encoding never
826    /// pays for the key schedule again. Also sets `wire_encryption` to
827    /// [`EncryptionKind::ChaCha20`].
828    pub fn with_chacha20_key(self, key: [u8; 32]) -> Self {
829        let cipher = ChaCha20Poly1305::new_from_slice(&key)
830            .expect("a 32-byte key is always valid for ChaCha20-Poly1305");
831        Self {
832            chacha20_cipher: Some(Arc::new(cipher)),
833            wire_encryption: EncryptionKind::ChaCha20,
834            ..self
835        }
836    }
837
838    /// Configures an AES256-GCM key.
839    ///
840    /// The cipher is constructed eagerly so that subsequent frame encoding never
841    /// pays for the AES key expansion again. Also sets `wire_encryption` to
842    /// [`EncryptionKind::Aes256`].
843    pub fn with_aes256_key(self, key: [u8; 32]) -> Self {
844        let cipher =
845            Aes256Gcm::new_from_slice(&key).expect("a 32-byte key is always valid for AES-256-GCM");
846        Self {
847            aes256_cipher: Some(Arc::new(cipher)),
848            wire_encryption: EncryptionKind::Aes256,
849            ..self
850        }
851    }
852
853    /// Returns the connection-level encryption mode used by this codec.
854    pub fn wire_encryption(&self) -> EncryptionKind {
855        self.wire_encryption
856    }
857
858    /// Sets the maximum total frame size (including the 4-byte length prefix).
859    ///
860    /// Frames exceeding this limit are rejected during encode/decode.
861    pub fn with_max_frame_bytes(mut self, max: usize) -> Self {
862        self.max_frame_bytes = max;
863        self
864    }
865
866    /// Returns the configured maximum frame size.
867    pub fn max_frame_bytes(&self) -> usize {
868        self.max_frame_bytes
869    }
870
871    /// Encodes an envelope into a binary WSCALL frame (protocol v3 composite format).
872    ///
873    /// Frame layout: `frame_len:u32 | message_type:u8 | payload`
874    ///
875    /// The composite payload layout (before encryption):
876    /// ```text
877    /// | meta_len:u32(be) | JSON_bytes | att_count:u8 | [att sections...] |
878    /// ```
879    ///
880    /// The encryption mode is taken from `self.wire_encryption` (connection-level),
881    /// not from `packet.encryption`.
882    pub fn encode(&self, packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
883        let max_payload = self.max_frame_bytes.saturating_sub(5);
884        let attachments = packet.body.attachments();
885        let att_size: usize = attachments.iter().map(|a| a.wire_size()).sum();
886        let message_type = packet.message_type as u8;
887
888        // Plaintext fast path: build the final frame in a single buffer with no
889        // intermediate copies. The JSON metadata is written directly into the
890        // frame via `to_writer` (no temporary `json_bytes` Vec), and the
891        // `frame_len` / `meta_len` prefixes are patched in place once known.
892        //
893        // Layout: frame_len:u32 | msg_type:u8 | meta_len:u32 | json | att_count:u8 | [atts]
894        if self.wire_encryption == EncryptionKind::None {
895            let mut frame = Vec::with_capacity(4 + 1 + 4 + 1 + att_size + 64);
896            frame.extend_from_slice(&[0u8; 4]); // frame_len placeholder
897            frame.push(message_type);
898            frame.extend_from_slice(&[0u8; 4]); // meta_len placeholder
899            serde_json::to_writer(&mut frame, &packet.body)?;
900            let json_len = frame.len() - 9;
901            frame.push(attachments.len() as u8);
902            for att in attachments {
903                att.write_wire(&mut frame);
904            }
905            let payload_len = frame.len() - 5;
906            if payload_len > max_payload {
907                return Err(ProtocolError::PayloadTooLarge {
908                    actual: payload_len,
909                    max: max_payload,
910                });
911            }
912            let frame_len = (frame.len() - 4) as u32;
913            frame[0..4].copy_from_slice(&frame_len.to_be_bytes());
914            frame[5..9].copy_from_slice(&(json_len as u32).to_be_bytes());
915            return Ok(frame);
916        }
917
918        // Encrypted path: build the composite payload (meta_len | json |
919        // att_count | atts) writing JSON directly, encrypt it, then wrap with
920        // the frame header.
921        let mut composite = Vec::with_capacity(4 + 1 + att_size + 64);
922        composite.extend_from_slice(&[0u8; 4]); // meta_len placeholder
923        serde_json::to_writer(&mut composite, &packet.body)?;
924        let json_len = composite.len() - 4;
925        composite[0..4].copy_from_slice(&(json_len as u32).to_be_bytes());
926        composite.push(attachments.len() as u8);
927        for att in attachments {
928            att.write_wire(&mut composite);
929        }
930
931        // Pre-check size before encryption.
932        if composite.len() > max_payload {
933            return Err(ProtocolError::PayloadTooLarge {
934                actual: composite.len(),
935                max: max_payload,
936            });
937        }
938
939        let payload = match self.wire_encryption {
940            EncryptionKind::ChaCha20 => self.encrypt_chacha20(&composite)?,
941            EncryptionKind::Aes256 => self.encrypt_aes256(&composite)?,
942            EncryptionKind::None => unreachable!("plaintext is handled by the fast path above"),
943        };
944
945        // Post-encryption size check (nonce + tag add overhead).
946        if payload.len() > max_payload {
947            return Err(ProtocolError::PayloadTooLarge {
948                actual: payload.len(),
949                max: max_payload,
950            });
951        }
952
953        let frame_len = 1 + payload.len();
954        let mut frame = Vec::with_capacity(4 + frame_len);
955        frame.extend_from_slice(&(frame_len as u32).to_be_bytes());
956        frame.push(message_type);
957        frame.extend_from_slice(&payload);
958        Ok(frame)
959    }
960
961    /// Decodes a binary WSCALL frame back into an envelope (protocol v3 composite format).
962    ///
963    /// Frame layout: `frame_len:u32 | message_type:u8 | payload`
964    ///
965    /// The encryption mode is taken from `self.wire_encryption` (connection-level).
966    ///
967    /// Accepts [`Bytes`] so that plaintext attachment payloads can be extracted
968    /// via zero-copy slicing of the original buffer (no intermediate `Vec`).
969    pub fn decode(&self, frame: Bytes) -> Result<PacketEnvelope, ProtocolError> {
970        if frame.len() < 5 {
971            return Err(ProtocolError::FrameTooShort);
972        }
973
974        let declared = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
975        let actual = frame.len() - 4;
976        if declared != actual {
977            return Err(ProtocolError::FrameLengthMismatch { declared, actual });
978        }
979
980        if frame.len() > self.max_frame_bytes {
981            return Err(ProtocolError::FrameTooLarge {
982                actual: frame.len(),
983                max: self.max_frame_bytes,
984            });
985        }
986
987        let message_type = MessageType::try_from(frame[4])?;
988
989        // Obtain the composite payload as `Bytes`. In the plaintext path this
990        // is a zero-copy slice of the original frame buffer; in the encrypted
991        // path the decrypted `Vec` is wrapped into a fresh `Bytes`.
992        let composite: Bytes = match self.wire_encryption {
993            EncryptionKind::None => frame.slice(5..),
994            EncryptionKind::ChaCha20 => Bytes::from(self.decrypt_chacha20(&frame[5..])?),
995            EncryptionKind::Aes256 => Bytes::from(self.decrypt_aes256(&frame[5..])?),
996        };
997
998        // Parse composite: meta_len:u32 | JSON | att_count:u8 | [att sections]
999        if composite.len() < 5 {
1000            return Err(ProtocolError::FrameTooShort);
1001        }
1002        let meta_len =
1003            u32::from_be_bytes([composite[0], composite[1], composite[2], composite[3]]) as usize;
1004        if composite.len() < 4 + meta_len + 1 {
1005            return Err(ProtocolError::FrameTooShort);
1006        }
1007        let json_slice = &composite[4..4 + meta_len];
1008        let att_count = composite[4 + meta_len] as usize;
1009        let mut att_data = composite.slice(4 + meta_len + 1..);
1010
1011        // Parse binary attachment sections (zero-copy via Bytes::slice).
1012        let mut attachments = Vec::with_capacity(att_count);
1013        for _ in 0..att_count {
1014            let (att, rest) = FileAttachment::read_wire(&att_data)?;
1015            attachments.push(att);
1016            att_data = rest;
1017        }
1018
1019        // Parse JSON body and inject attachments.
1020        let mut body: PacketBody = serde_json::from_slice(json_slice)?;
1021        body.set_attachments(attachments);
1022
1023        if body.message_type() != message_type {
1024            return Err(ProtocolError::MessageTypeMismatch);
1025        }
1026
1027        Ok(PacketEnvelope {
1028            message_type,
1029            encryption: self.wire_encryption,
1030            body,
1031        })
1032    }
1033
1034    fn encrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
1035        let cipher = self
1036            .chacha20_cipher
1037            .as_ref()
1038            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
1039        let mut nonce_bytes = [0_u8; CHACHA20_NONCE_LEN];
1040        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
1041        let ciphertext = cipher
1042            .encrypt(Nonce::from_slice(&nonce_bytes), payload)
1043            .map_err(|_| ProtocolError::EncryptionFailed("chacha20"))?;
1044
1045        let mut encoded = Vec::with_capacity(CHACHA20_NONCE_LEN + ciphertext.len());
1046        encoded.extend_from_slice(&nonce_bytes);
1047        encoded.extend_from_slice(&ciphertext);
1048        Ok(encoded)
1049    }
1050
1051    fn decrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
1052        if payload.len() < CHACHA20_NONCE_LEN {
1053            return Err(ProtocolError::EncryptedPayloadTooShort {
1054                algorithm: "chacha20",
1055                expected_min: CHACHA20_NONCE_LEN,
1056                actual: payload.len(),
1057            });
1058        }
1059
1060        let cipher = self
1061            .chacha20_cipher
1062            .as_ref()
1063            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
1064        let (nonce_bytes, ciphertext) = payload.split_at(CHACHA20_NONCE_LEN);
1065        cipher
1066            .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
1067            .map_err(|_| ProtocolError::DecryptionFailed("chacha20"))
1068    }
1069
1070    fn encrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
1071        let cipher = self
1072            .aes256_cipher
1073            .as_ref()
1074            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
1075        let mut nonce_bytes = [0_u8; AES256_NONCE_LEN];
1076        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
1077        let ciphertext = cipher
1078            .encrypt(AesNonce::from_slice(&nonce_bytes), payload)
1079            .map_err(|_| ProtocolError::EncryptionFailed("aes256"))?;
1080
1081        let mut encoded = Vec::with_capacity(AES256_NONCE_LEN + ciphertext.len());
1082        encoded.extend_from_slice(&nonce_bytes);
1083        encoded.extend_from_slice(&ciphertext);
1084        Ok(encoded)
1085    }
1086
1087    fn decrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
1088        if payload.len() < AES256_NONCE_LEN {
1089            return Err(ProtocolError::EncryptedPayloadTooShort {
1090                algorithm: "aes256",
1091                expected_min: AES256_NONCE_LEN,
1092                actual: payload.len(),
1093            });
1094        }
1095
1096        let cipher = self
1097            .aes256_cipher
1098            .as_ref()
1099            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
1100        let (nonce_bytes, ciphertext) = payload.split_at(AES256_NONCE_LEN);
1101        cipher
1102            .decrypt(AesNonce::from_slice(nonce_bytes), ciphertext)
1103            .map_err(|_| ProtocolError::DecryptionFailed("aes256"))
1104    }
1105}
1106
1107/// Helper for encoding a plaintext frame without constructing a custom codec.
1108pub fn encode_frame(packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
1109    FrameCodec::plaintext().encode(packet)
1110}
1111
1112/// Helper for decoding a plaintext frame without constructing a custom codec.
1113pub fn decode_frame(frame: Bytes) -> Result<PacketEnvelope, ProtocolError> {
1114    FrameCodec::plaintext().decode(frame)
1115}
1116
1117/// Errors returned while encoding or decoding WSCALL frames.
1118#[derive(Debug, Error)]
1119pub enum ProtocolError {
1120    #[error("frame too short")]
1121    FrameTooShort,
1122    #[error("frame length mismatch: declared={declared}, actual={actual}")]
1123    FrameLengthMismatch { declared: usize, actual: usize },
1124    #[error("payload too large: actual={actual}, max={max}")]
1125    PayloadTooLarge { actual: usize, max: usize },
1126    #[error("frame too large: actual={actual}, max={max}")]
1127    FrameTooLarge { actual: usize, max: usize },
1128    #[error("unknown message type: {0:#x}")]
1129    UnknownMessageType(u8),
1130    #[error("unknown encryption kind: {0:#x}")]
1131    UnknownEncryption(u8),
1132    #[error("unsupported encryption kind: {0:#x}")]
1133    UnsupportedEncryption(u8),
1134    #[error("missing encryption key for {0}")]
1135    MissingEncryptionKey(&'static str),
1136    #[error("invalid encryption key for {0}")]
1137    InvalidEncryptionKey(&'static str),
1138    #[error("secure random generation failed: {0}")]
1139    Random(String),
1140    #[error(
1141        "encrypted payload too short for {algorithm}: expected at least {expected_min}, actual={actual}"
1142    )]
1143    EncryptedPayloadTooShort {
1144        algorithm: &'static str,
1145        expected_min: usize,
1146        actual: usize,
1147    },
1148    #[error("encryption failed for {0}")]
1149    EncryptionFailed(&'static str),
1150    #[error("decryption failed for {0}")]
1151    DecryptionFailed(&'static str),
1152    #[error("invalid ECDH public key: expected {expected} bytes, got {actual}")]
1153    InvalidEcdhPublicKey { expected: usize, actual: usize },
1154    #[error("ECDH handshake failed: {0}")]
1155    EcdhHandshake(String),
1156    #[error("message type does not match packet body")]
1157    MessageTypeMismatch,
1158    #[error("invalid attachment encoding: {0}")]
1159    InvalidAttachmentEncoding(String),
1160    #[error("json error: {0}")]
1161    Json(#[from] serde_json::Error),
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::{
1167        Bytes, EncryptionKind, FileAttachment, FrameCodec, PacketBody, PacketEnvelope,
1168        ProtocolError, decode_frame, encode_frame,
1169    };
1170    use serde_json::json;
1171
1172    const TEST_KEY: [u8; 32] = [0x11; 32];
1173
1174    #[test]
1175    fn plaintext_helpers_still_work() {
1176        let packet = PacketEnvelope::new(PacketBody::EventAck {
1177            event_id: 1,
1178            ok: true,
1179            receipt: json!({ "ok": true }),
1180            error: None,
1181        });
1182
1183        let encoded = encode_frame(&packet).expect("encode plaintext");
1184        let decoded = decode_frame(Bytes::from(encoded)).expect("decode plaintext");
1185        assert!(matches!(decoded.encryption, EncryptionKind::None));
1186    }
1187
1188    #[test]
1189    fn aes256_roundtrip_works() {
1190        let codec = FrameCodec::plaintext().with_aes256_key(TEST_KEY);
1191        let packet = PacketEnvelope::with_encryption(
1192            PacketBody::ApiResponse {
1193                request_id: 1,
1194                ok: true,
1195                status: 200,
1196                data: json!({ "message": "encrypted" }),
1197                error: None,
1198                metadata: json!({}),
1199            },
1200            EncryptionKind::Aes256,
1201        );
1202
1203        let encoded = codec.encode(&packet).expect("encode aes256");
1204        let decoded = codec.decode(Bytes::from(encoded)).expect("decode aes256");
1205        assert!(matches!(decoded.encryption, EncryptionKind::Aes256));
1206    }
1207
1208    #[test]
1209    fn attachment_roundtrip_plaintext() {
1210        let att = FileAttachment::inline_bytes(
1211            "f1",
1212            "test.bin",
1213            "application/octet-stream",
1214            vec![1, 2, 3, 4, 5],
1215        );
1216        let packet = PacketEnvelope::new(PacketBody::ApiRequest {
1217            request_id: 42,
1218            route: "files.upload".to_string(),
1219            params: json!({ "file": { "$file": "f1" } }),
1220            attachments: vec![att],
1221            metadata: json!({}),
1222        });
1223
1224        let encoded = encode_frame(&packet).expect("encode with attachment");
1225        let decoded = decode_frame(Bytes::from(encoded)).expect("decode with attachment");
1226
1227        let atts = decoded.body.attachments();
1228        assert_eq!(atts.len(), 1);
1229        assert_eq!(atts[0].id, "f1");
1230        assert_eq!(atts[0].name, "test.bin");
1231        assert_eq!(atts[0].content_type, "application/octet-stream");
1232        assert_eq!(&atts[0].data[..], &[1, 2, 3, 4, 5]);
1233    }
1234
1235    #[test]
1236    fn attachment_roundtrip_encrypted() {
1237        let codec = FrameCodec::plaintext().with_chacha20_key(TEST_KEY);
1238        let att = FileAttachment::inline_text("f2", "hello.txt", "text/plain", "hello world");
1239        let packet = PacketEnvelope::with_encryption(
1240            PacketBody::EventEmit {
1241                event_id: 7,
1242                name: "chat.message".to_string(),
1243                data: json!({ "text": "see attached" })
1244                    .as_object()
1245                    .unwrap()
1246                    .clone(),
1247                attachments: vec![att],
1248                metadata: json!({}),
1249                expect_ack: true,
1250            },
1251            EncryptionKind::ChaCha20,
1252        );
1253
1254        let encoded = codec
1255            .encode(&packet)
1256            .expect("encode encrypted with attachment");
1257        let decoded = codec
1258            .decode(Bytes::from(encoded))
1259            .expect("decode encrypted with attachment");
1260
1261        let atts = decoded.body.attachments();
1262        assert_eq!(atts.len(), 1);
1263        assert_eq!(atts[0].id, "f2");
1264        assert_eq!(&atts[0].data[..], b"hello world");
1265    }
1266
1267    #[test]
1268    fn encode_rejects_payloads_over_limit() {
1269        // Use a small max to trigger the limit without allocating 100 MiB.
1270        let codec = FrameCodec::plaintext().with_max_frame_bytes(1024);
1271        let packet = PacketEnvelope::new(PacketBody::ApiResponse {
1272            request_id: 999,
1273            ok: true,
1274            status: 200,
1275            data: json!({ "blob": "a".repeat(2048) }),
1276            error: None,
1277            metadata: json!({}),
1278        });
1279
1280        let error = codec
1281            .encode(&packet)
1282            .expect_err("oversized payload should fail");
1283        assert!(matches!(error, ProtocolError::PayloadTooLarge { .. }));
1284    }
1285
1286    #[test]
1287    fn decode_rejects_frames_over_limit() {
1288        let codec = FrameCodec::plaintext().with_max_frame_bytes(64);
1289        // Build a valid frame that exceeds 64 bytes total.
1290        let packet = PacketEnvelope::new(PacketBody::ApiResponse {
1291            request_id: 1,
1292            ok: true,
1293            status: 200,
1294            data: json!({ "msg": "a]".repeat(50) }),
1295            error: None,
1296            metadata: json!({}),
1297        });
1298        let encoded = FrameCodec::plaintext()
1299            .encode(&packet)
1300            .expect("encode with default limit");
1301        assert!(encoded.len() > 64);
1302
1303        let error = codec
1304            .decode(Bytes::from(encoded))
1305            .expect_err("oversized frame should fail");
1306        assert!(matches!(error, ProtocolError::FrameTooLarge { .. }));
1307    }
1308}