Skip to main content

jmap_base_client/ws/
mod.rs

1//! WebSocket transport for JMAP (RFC 8887).
2//!
3//! Provides [`connect_ws`] which establishes a WebSocket connection and
4//! returns a [`WsSession`] for sending and receiving frames.
5//!
6//! URL source: `Session::capabilities["urn:ietf:params:jmap:websocket"].url`
7//! (the session document advertises the WebSocket endpoint).
8
9use std::str::FromStr as _;
10use std::sync::Arc;
11use std::time::Duration;
12
13use futures::SinkExt as _;
14use futures::StreamExt as _;
15use tokio_tungstenite::tungstenite::client::IntoClientRequest as _;
16use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
17use tokio_tungstenite::tungstenite::Message;
18
19use crate::push::StateChange;
20
21/// Wire frame sent from the client to the server over WebSocket (RFC 8887 §4.3.2).
22///
23/// Wraps a [`jmap_types::JmapRequest`] and injects the mandatory `@type: "Request"`
24/// field (and optional `id`) in a single `serde_json::to_string` pass, avoiding
25/// the `to_value` + mutation + `to_string` double-serialization that the naive
26/// approach requires.
27#[derive(serde::Serialize)]
28struct WsRequestFrame<'a> {
29    /// RFC 8887 §4.3.2 — every JMAP request frame MUST carry "@type": "Request".
30    #[serde(rename = "@type")]
31    ws_type: &'static str,
32    /// Optional correlation ID echoed back in the server's Response frame.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    id: Option<&'a str>,
35    /// The JMAP request payload; flattened into the enclosing JSON object.
36    #[serde(flatten)]
37    inner: &'a jmap_types::JmapRequest,
38}
39
40/// Wire frame for RFC 8887 §4.3.5.2 `WebSocketPushEnable`.
41///
42/// `dataTypes` is `String[]|null` — always present, `null` meaning "all
43/// supported data types" — so it is not skipped when `None`. `pushState`
44/// is optional and omitted when `None`.
45#[derive(serde::Serialize)]
46struct WsPushEnableFrame<'a> {
47    #[serde(rename = "@type")]
48    ws_type: &'static str,
49    #[serde(rename = "dataTypes")]
50    data_types: Option<&'a [&'a str]>,
51    #[serde(rename = "pushState", skip_serializing_if = "Option::is_none")]
52    push_state: Option<&'a str>,
53}
54
55/// Wire text of an RFC 8887 §4.3.5.3 `WebSocketPushDisable` message; the
56/// object has no property other than `@type`.
57const WS_PUSH_DISABLE_FRAME: &str = r#"{"@type":"WebSocketPushDisable"}"#;
58
59/// Maximum WebSocket message size (1 MiB), consistent with the SSE frame limit.
60/// Prevents a misbehaving or hostile server from forcing the client to buffer
61/// large messages over the event connection.
62/// Default per-message / per-frame byte cap for WebSocket connections opened
63/// via [`connect_ws`] (which does not take a limit parameter). Callers that
64/// need a different cap should use [`connect_ws_with_limit`] or the
65/// [`crate::JmapClient::connect_ws_session`] convenience method which
66/// reads the `max_ws_message` field from `ClientConfig`. Default: 1 MiB.
67pub const DEFAULT_WS_MAX_MESSAGE_BYTES: usize = 1 << 20;
68
69/// A parsed frame received from the JMAP WebSocket.
70///
71/// Marked `#[non_exhaustive]` because the spec may define additional
72/// `@type` values in future revisions.
73///
74/// # `Debug` redaction
75///
76/// `WsFrame` has a hand-written [`std::fmt::Debug`] impl rather than
77/// `#[derive(Debug)]` so the [`Unknown::raw`](WsFrame::Unknown) field is
78/// printed as `[REDACTED]` instead of being serialised verbatim. See the
79/// field doc on `Unknown.raw` for the credential-leak class this guards
80/// against (bd:JMAP-6r7c.5). Use the structured accessors on the frame
81/// itself, not `{:?}`, when you need the raw value.
82#[non_exhaustive]
83#[derive(Clone, PartialEq)]
84pub enum WsFrame {
85    /// RFC 8620 §7.1 StateChange — one or more object types have changed
86    /// state; client must re-fetch the affected data types.
87    StateChange(StateChange),
88    /// RFC 8887 Response — reply to a JMAP request sent on this connection.
89    ///
90    /// The RFC 8887 §4.3.3 `requestId` lands in
91    /// [`JmapResponse::extra`](jmap_types::JmapResponse::extra); read it
92    /// with [`WsFrame::request_id`].
93    Response(jmap_types::JmapResponse),
94    /// RFC 8887 §4.3.4 RequestError — a request-level Problem Details
95    /// object (RFC 8620 §3.6.1) answering a request sent on this connection.
96    RequestError(WsRequestError),
97    /// Unrecognized `@type` — silently ignored per forward-compatibility rules
98    /// (RFC 8887 §4.3.1: clients SHOULD ignore unknown message types).
99    ///
100    /// Also produced when a known type (`"Response"`, `"RequestError"`, or
101    /// `"StateChange"`) fails to deserialize — `type_name` will be that name
102    /// in that case, which can signal server misbehavior or a schema version
103    /// mismatch. Callers that log unknown frames should check for these names.
104    Unknown {
105        /// Value of the `@type` field. Either an unrecognized message type
106        /// per RFC 8887 §4.3.1, or `"Response"` / `"RequestError"` /
107        /// `"StateChange"` when a known type failed to deserialize into its
108        /// typed variant.
109        type_name: String,
110        /// Raw JSON object as received from the server, preserved for
111        /// forward-compatibility diagnostics.
112        ///
113        /// **DO NOT log this field verbatim.** Future or extension JMAP
114        /// WebSocket message types may carry credential-grade material —
115        /// push verification codes (RFC 8887 §7.2), federation handshake
116        /// tokens, session-rotation challenges, etc. — and a malformed
117        /// `Response` to a method like `PushSubscription/get` can echo a
118        /// `verificationCode` back into this field.
119        ///
120        /// The enclosing `WsFrame` uses a hand-written `Debug` impl that
121        /// renders this field as `[REDACTED]` to neutralise the natural
122        /// `{:?}`-leaks-the-field failure mode (bd:JMAP-6r7c.5). For
123        /// operator logs, prefer logging `type_name` only, or apply a
124        /// project-specific redaction filter before passing `raw` to a
125        /// logging sink. See bd:JMAP-sc1b.98.
126        raw: serde_json::Value,
127    },
128}
129
130impl WsFrame {
131    /// The `requestId` correlating this frame with the `id` passed to
132    /// [`WsSender::send_request`] (RFC 8887 §4.3.3, §4.3.4).
133    ///
134    /// Returns `None` for `StateChange` and `Unknown` frames, and for a
135    /// `Response` or `RequestError` whose `requestId` is absent, `null`, or
136    /// not a string. The server MUST echo the id when the request carried
137    /// one, but sends `null` when it could not parse the request at all
138    /// (RFC 8887 §4.4 `notJSON` example).
139    pub fn request_id(&self) -> Option<&str> {
140        match self {
141            WsFrame::Response(r) => r.extra.get("requestId").and_then(serde_json::Value::as_str),
142            WsFrame::RequestError(e) => e.request_id.as_deref(),
143            WsFrame::StateChange(_) | WsFrame::Unknown { .. } => None,
144        }
145    }
146}
147
148/// A request-level error received over WebSocket (RFC 8887 §4.3.4).
149///
150/// An RFC 8620 §3.6.1 Problem Details object extended with
151/// `"@type": "RequestError"` and `requestId`. The server sends one instead
152/// of a `Response` when the whole request is rejected (not JSON, unknown
153/// capability, limit exceeded, ...).
154#[non_exhaustive]
155#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156pub struct WsRequestError {
157    /// `requestId`: the `id` of the request this error answers. `None` when
158    /// the server sent `null` or omitted it.
159    #[serde(rename = "requestId", default)]
160    pub request_id: Option<String>,
161    /// Problem Details `type` URI, e.g.
162    /// `"urn:ietf:params:jmap:error:notJSON"` (RFC 8620 §3.6.1).
163    #[serde(rename = "type")]
164    pub problem_type: String,
165    /// Problem Details `status`: the HTTP status code the error would carry
166    /// over HTTP.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub status: Option<u16>,
169    /// Problem Details `detail`: human-readable explanation.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub detail: Option<String>,
172    /// `limit`: the name of the exceeded limit, present for
173    /// `urn:ietf:params:jmap:error:limit` (RFC 8620 §3.6.1).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub limit: Option<String>,
176    /// Remaining fields (`@type`, `title`, vendor extensions), preserved per
177    /// the workspace extras-preservation policy.
178    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
179    pub extra: serde_json::Map<String, serde_json::Value>,
180}
181
182impl std::fmt::Debug for WsFrame {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        match self {
185            WsFrame::StateChange(sc) => f.debug_tuple("StateChange").field(sc).finish(),
186            WsFrame::Response(r) => f.debug_tuple("Response").field(r).finish(),
187            WsFrame::RequestError(e) => f.debug_tuple("RequestError").field(e).finish(),
188            // `raw` may carry credential-grade material in failure modes
189            // (see the field doc); render as a literal string so neither
190            // `{:?}` nor `tracing::*` with `?frame` reveals the payload.
191            WsFrame::Unknown { type_name, raw: _ } => f
192                .debug_struct("Unknown")
193                .field("type_name", type_name)
194                .field("raw", &"[REDACTED]")
195                .finish(),
196        }
197    }
198}
199
200type Inner =
201    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
202
203/// Send half shared between [`WsSender`] and the keepalive in
204/// [`WsReceiver`], which must be able to send Ping frames after a split.
205type SharedSink = Arc<futures::lock::Mutex<futures::stream::SplitSink<Inner, Message>>>;
206
207/// An established JMAP WebSocket session (RFC 8887).
208///
209/// Call [`next_frame`](WsSession::next_frame) in a loop to receive events.
210/// Use [`send_request`](WsSession::send_request) to transmit JMAP requests.
211///
212/// The caller is responsible for reconnecting after the stream ends or returns
213/// a transport error. Use exponential backoff.
214///
215/// # Drop and cancellation (bd:JMAP-6r7c.24)
216///
217/// `WsSession` may be dropped at any point — including from inside a
218/// `tokio::select!` losing branch while [`next_frame`](WsSession::next_frame)
219/// is awaiting. Dropping is always safe and always synchronous:
220///
221/// - **Partial frame state is discarded.** The split sink and stream halves
222///   are owned by the session; dropping the session drops both halves and
223///   any in-flight tungstenite buffering.
224/// - **The underlying TCP / TLS connection is closed.** No `Close` frame is
225///   sent — dropping skips the WebSocket close handshake. Callers that want
226///   the server to see a clean shutdown should arrange to receive the
227///   server's `Close` (which `next_frame` returns as `None`) before drop;
228///   this crate does not currently expose an explicit client-initiated
229///   `close()` method.
230/// - **Resumption is the caller's job.** If you need to recover the
231///   conversation state, you must either persist enough application-level
232///   state to replay, or rely on the server's push-state replay protocol.
233///   `WsSession` itself buffers nothing the caller can replay.
234///
235/// `next_frame` is cancel-safe in the sense that cancelling its future
236/// (via `select!` or `drop`) does not corrupt subsequent calls to
237/// `next_frame` on the same `WsSession` — but cancelling means giving up
238/// on any partial frame that was being read; the next `next_frame` call
239/// starts from the next complete message.
240pub struct WsSession {
241    sender: WsSender,
242    receiver: WsReceiver,
243}
244
245/// Owning send-half of a WebSocket connection (bd:JMAP-6r7c.31).
246///
247/// Returned from [`WsSession::split`]. Holds the underlying tungstenite
248/// sink and exposes the per-direction send methods that the unified
249/// [`WsSession`] previously bundled with the receive half. Use with
250/// [`WsReceiver`] in two-task topologies (one task in a `next_frame` loop,
251/// one task occasionally sending requests) — the previous unified shape
252/// required serialising send and receive through a single `&mut WsSession`
253/// borrow, which made concurrent send-while-receiving impossible without a
254/// `Mutex` that holds across `.await`.
255pub struct WsSender {
256    sink: SharedSink,
257}
258
259/// Owning receive-half of a WebSocket connection (bd:JMAP-6r7c.31).
260///
261/// Returned from [`WsSession::split`]. Holds the underlying tungstenite
262/// stream and exposes [`next_frame`](WsReceiver::next_frame). See
263/// [`WsSender`] for the companion type and the concurrency rationale.
264pub struct WsReceiver {
265    stream: futures::stream::SplitStream<Inner>,
266    keepalive: Option<Keepalive>,
267}
268
269/// Client-side keepalive state, set by [`WsSession::enable_keepalive`].
270///
271/// Lives in the receiver (not on the stack of `next_frame`) so cancelling a
272/// `next_frame` future does not reset the idle clock.
273struct Keepalive {
274    sink: SharedSink,
275    interval: Duration,
276    timeout: Duration,
277    last_rx: tokio::time::Instant,
278    ping_sent_at: Option<tokio::time::Instant>,
279}
280
281/// The transport error [`WsReceiver::next_frame`] reports for a dead peer.
282fn keepalive_timed_out(msg: &'static str) -> crate::error::ClientError {
283    crate::error::ClientError::from_ws(tokio_tungstenite::tungstenite::Error::Io(
284        std::io::Error::new(std::io::ErrorKind::TimedOut, msg),
285    ))
286}
287
288/// Maximum number of consecutive non-Text non-Close non-Binary frames
289/// (Ping, Pong, Frame, etc.) `next_frame` will silently skip in a single call.
290///
291/// Tungstenite handles ping/pong at the protocol layer, so seeing them at the
292/// `Message` layer is unusual but legal — we skip them. A misbehaving or
293/// hostile server that floods the stream with no-op frames could otherwise
294/// starve a caller of `next_frame` indefinitely; this cap surfaces an
295/// `UnexpectedResponse` error before that can happen. 64 is high enough that
296/// a normal connection never trips it (typical SSE/WS streams interleave at
297/// most a handful of pings between data frames) and low enough that the
298/// caller doesn't wait long if a bad server is talking nonsense.
299///
300/// `Binary` frames are NOT counted here — they violate RFC 8887 §4.1 and
301/// surface as `UnexpectedResponse` immediately on the first occurrence.
302///
303/// # Do not remove the cap (bd:JMAP-6r7c.17, originally bd:JMAP-6lsm.6)
304///
305/// A future contributor may suggest "tungstenite handles pings at the
306/// protocol layer, just continue the loop silently for any non-text
307/// non-close frame." That re-introduces the resource-exhaustion bug
308/// bd:JMAP-6lsm.6 fixed. The cap is load-bearing for five reasons:
309///
310/// 1. **Starvation defense.** A hostile or misbehaving server can
311///    flood the stream with no-op frames (Ping, Pong, Frame, future
312///    variants) and starve a caller of `next_frame()` indefinitely.
313///    The cap surfaces this as an error before the caller's outer
314///    loop is starved.
315/// 2. **Calibrated threshold.** 64 is high enough that a normal
316///    connection never trips it. Lower numbers (e.g. 8) would
317///    false-positive on networks with weak NAT keepalives where the
318///    server sends frequent pings to keep the connection alive.
319/// 3. **Binary frames are a separate guarantee.** They are NOT
320///    subsumed by the cap — they violate RFC 8887 §4.1 and surface
321///    immediately on first occurrence (see `classify_message`
322///    `MessageDisposition::UnexpectedFrame` path). Removing the cap
323///    does not affect Binary handling; the threats are different.
324/// 4. **Unit-testable policy.** `classify_message` (free function,
325///    not a method) is testable without a real WebSocket. The test
326///    suite in this file exercises the classification explicitly so
327///    a refactor that "just changes the loop" doesn't silently lose
328///    the policy.
329/// 5. **Pinned by tripwire test.**
330///    `consecutive_skip_cap_matches_documented_value` asserts the
331///    constant equals 64. A retune fails the test loudly so the
332///    change is visible in CI, forcing a deliberate choice rather
333///    than a silent regression.
334///
335/// Resist requests to "simplify" by removing the count.
336const MAX_CONSECUTIVE_NON_TEXT_FRAMES: usize = 64;
337
338/// Classify a single tungstenite [`Message`] into a [`MessageDisposition`]
339/// that tells the [`WsSession::next_frame`] loop what to do with it.
340///
341/// Extracted as a free function so the policy is unit-testable without a
342/// real WebSocket: see the inline test module. Pure function over the
343/// message variant.
344fn classify_message(msg: &Message) -> MessageDisposition {
345    match msg {
346        Message::Text(_) => MessageDisposition::Text,
347        Message::Close(_) => MessageDisposition::Close,
348        Message::Binary(_) => MessageDisposition::Binary,
349        // Ping, Pong, Frame, and any future variants: skip, but count.
350        _ => MessageDisposition::Skip,
351    }
352}
353
354/// Decision a `next_frame` loop iteration takes after looking at one
355/// [`Message`]. See [`classify_message`].
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357enum MessageDisposition {
358    /// Text frame: hand to `parse_ws_frame` and return its result.
359    Text,
360    /// Close frame: end the stream by returning `None`.
361    Close,
362    /// Binary frame: violates RFC 8887 §4.1; surface as
363    /// `UnexpectedResponse` immediately on the first occurrence.
364    Binary,
365    /// Ping / Pong / Frame / future variants: silently skip and continue
366    /// the loop, subject to [`MAX_CONSECUTIVE_NON_TEXT_FRAMES`].
367    Skip,
368}
369
370impl WsReceiver {
371    /// Receive the next parsed frame from the server.
372    ///
373    /// Returns `None` when the server has cleanly closed the connection.
374    /// Returns `Some(Err(...))` on parse failure, transport error, RFC 8887
375    /// §4.1 violation (Binary frame), or starvation cap (more than 64
376    /// consecutive Ping/Pong/Frame messages — see the private
377    /// `MAX_CONSECUTIVE_NON_TEXT_FRAMES` constant for the exact value).
378    /// After a transport error the connection is broken and `next_frame`
379    /// must not be called again. After an `UnexpectedResponse` error the
380    /// underlying stream is still healthy — the caller may choose to
381    /// ignore it and retry, or to disconnect.
382    ///
383    /// With keepalive enabled ([`WsSession::enable_keepalive`]) this call
384    /// also sends the Ping frames, so keep a `next_frame` loop running; a
385    /// dead peer surfaces as a transport error whose
386    /// [`WebSocketError::is_io`](crate::error::WebSocketError::is_io) is
387    /// `true`.
388    pub async fn next_frame(&mut self) -> Option<Result<WsFrame, crate::error::ClientError>> {
389        let mut consecutive_skips = 0usize;
390        loop {
391            let next = match self.keepalive.as_mut() {
392                None => self.stream.next().await,
393                Some(ka) => {
394                    let deadline = match ka.ping_sent_at {
395                        Some(sent) => sent + ka.timeout,
396                        None => ka.last_rx + ka.interval,
397                    };
398                    match tokio::time::timeout_at(deadline, self.stream.next()).await {
399                        Ok(next) => {
400                            // Any inbound message, not only a Pong, proves
401                            // the peer is alive.
402                            ka.last_rx = tokio::time::Instant::now();
403                            ka.ping_sent_at = None;
404                            next
405                        }
406                        Err(_elapsed) if ka.ping_sent_at.is_some() => {
407                            return Some(Err(keepalive_timed_out(
408                                "WebSocket keepalive: no traffic from server after Ping",
409                            )));
410                        }
411                        Err(_elapsed) => {
412                            // Bounded: a WsSender blocked on a dead peer's
413                            // full send buffer holds the sink lock, and an
414                            // unbounded wait here would never time out.
415                            let sent = tokio::time::timeout(ka.timeout, async {
416                                ka.sink
417                                    .lock()
418                                    .await
419                                    .send(Message::Ping(Vec::new().into()))
420                                    .await
421                            })
422                            .await;
423                            match sent {
424                                Ok(Ok(())) => {}
425                                Ok(Err(e)) => {
426                                    return Some(Err(crate::error::ClientError::from_ws(e)))
427                                }
428                                Err(_elapsed) => {
429                                    return Some(Err(keepalive_timed_out(
430                                        "WebSocket keepalive: could not send Ping in time",
431                                    )))
432                                }
433                            }
434                            ka.ping_sent_at = Some(tokio::time::Instant::now());
435                            // The Pong we just solicited is not server
436                            // misbehaviour: restart the flood count so an
437                            // idle connection never trips the cap. At most
438                            // one reset per interval keeps the flood
439                            // defence intact.
440                            consecutive_skips = 0;
441                            continue;
442                        }
443                    }
444                }
445            };
446            let msg = match next? {
447                Ok(m) => m,
448                Err(e) => return Some(Err(crate::error::ClientError::from_ws(e))),
449            };
450            match classify_message(&msg) {
451                MessageDisposition::Text => {
452                    let Message::Text(text) = msg else {
453                        // Unreachable: classify_message returned Text only for
454                        // Message::Text. Defensive in case the variant grows.
455                        return Some(Err(crate::error::ClientError::UnexpectedResponse(
456                            "WebSocket: classify_message returned Text for non-Text variant".into(),
457                        )));
458                    };
459                    return Some(parse_ws_frame(&text));
460                }
461                MessageDisposition::Close => return None,
462                MessageDisposition::Binary => {
463                    // RFC 8887 §4.1: JMAP only uses text frames. Surface the
464                    // violation; underlying stream is still healthy so the
465                    // caller can choose to retry next_frame if it wants.
466                    return Some(Err(crate::error::ClientError::UnexpectedResponse(
467                        "WebSocket: server sent Binary frame; RFC 8887 §4.1 mandates text frames"
468                            .into(),
469                    )));
470                }
471                MessageDisposition::Skip => {
472                    consecutive_skips = consecutive_skips.saturating_add(1);
473                    if consecutive_skips > MAX_CONSECUTIVE_NON_TEXT_FRAMES {
474                        return Some(Err(crate::error::ClientError::UnexpectedResponse(
475                            format!(
476                                "WebSocket: exceeded {MAX_CONSECUTIVE_NON_TEXT_FRAMES} consecutive non-text frames; possible server misbehaviour"
477                            ),
478                        )));
479                    }
480                }
481            }
482        }
483    }
484}
485
486impl WsSender {
487    /// Send a raw text frame over the WebSocket connection.
488    ///
489    /// Used by extension crates to send non-JMAP frames (e.g., JMAP Chat
490    /// ephemeral stream control messages).
491    pub async fn send_text(&mut self, text: String) -> Result<(), crate::error::ClientError> {
492        self.sink
493            .lock()
494            .await
495            .send(Message::Text(text.into()))
496            .await
497            .map_err(crate::error::ClientError::from_ws)
498    }
499
500    /// Send a JMAP request over the WebSocket connection.
501    ///
502    /// Serializes `req` and injects `"@type": "Request"` into the outgoing
503    /// JSON object as required by RFC 8887 §4.3.2.  The optional `id` is
504    /// echoed back in the corresponding `Response` frame, enabling out-of-order
505    /// correlation.
506    ///
507    /// # Errors
508    ///
509    /// Returns `ClientError::Serialize` if `req` cannot be serialized, or
510    /// `ClientError::WebSocket` on a transport failure.
511    pub async fn send_request(
512        &mut self,
513        req: &jmap_types::JmapRequest,
514        id: Option<&str>,
515    ) -> Result<(), crate::error::ClientError> {
516        // Wrap req in WsRequestFrame to inject @type and optional id in one
517        // serialization pass (no intermediate serde_json::Value allocation).
518        let frame = WsRequestFrame {
519            ws_type: "Request",
520            id,
521            inner: req,
522        };
523        let text =
524            serde_json::to_string(&frame).map_err(crate::error::ClientError::from_serialize)?;
525        self.send_text(text).await
526    }
527
528    /// Enable push notifications on this connection (RFC 8887 §4.3.5.2).
529    ///
530    /// Sends a `WebSocketPushEnable` object. `data_types` limits pushes to
531    /// the named types (`["Mailbox", "Email"]`); `None` sends
532    /// `"dataTypes": null`, which asks for every supported type.
533    /// `push_state` is the last `pushState` seen on a previous connection,
534    /// from which the server SHOULD replay missed changes; read it from
535    /// the `"pushState"` entry of [`StateChange::extra`].
536    ///
537    /// Only meaningful when the session's WebSocket capability advertises
538    /// `supportsPush: true` (RFC 8887 §4.3.5).
539    ///
540    /// # Errors
541    ///
542    /// Returns `ClientError::WebSocket` on a transport failure.
543    pub async fn send_push_enable(
544        &mut self,
545        data_types: Option<&[&str]>,
546        push_state: Option<&str>,
547    ) -> Result<(), crate::error::ClientError> {
548        let frame = WsPushEnableFrame {
549            ws_type: "WebSocketPushEnable",
550            data_types,
551            push_state,
552        };
553        let text =
554            serde_json::to_string(&frame).map_err(crate::error::ClientError::from_serialize)?;
555        self.send_text(text).await
556    }
557
558    /// Disable push notifications on this connection (RFC 8887 §4.3.5.3)
559    /// by sending a `WebSocketPushDisable` object.
560    ///
561    /// # Errors
562    ///
563    /// Returns `ClientError::WebSocket` on a transport failure.
564    pub async fn send_push_disable(&mut self) -> Result<(), crate::error::ClientError> {
565        self.send_text(WS_PUSH_DISABLE_FRAME.to_owned()).await
566    }
567}
568
569impl WsSession {
570    /// Receive the next parsed frame from the server.
571    ///
572    /// Delegates to [`WsReceiver::next_frame`]. Use this unified handle when
573    /// a single task drives both send and receive; for the
574    /// receive-loop-in-a-separate-task topology, call [`split`](Self::split)
575    /// to get owned [`WsReceiver`] / [`WsSender`] halves.
576    pub async fn next_frame(&mut self) -> Option<Result<WsFrame, crate::error::ClientError>> {
577        self.receiver.next_frame().await
578    }
579
580    /// Send a raw text frame over the WebSocket connection.
581    ///
582    /// Delegates to [`WsSender::send_text`].
583    pub async fn send_text(&mut self, text: String) -> Result<(), crate::error::ClientError> {
584        self.sender.send_text(text).await
585    }
586
587    /// Send a JMAP request over the WebSocket connection.
588    ///
589    /// Delegates to [`WsSender::send_request`].
590    pub async fn send_request(
591        &mut self,
592        req: &jmap_types::JmapRequest,
593        id: Option<&str>,
594    ) -> Result<(), crate::error::ClientError> {
595        self.sender.send_request(req, id).await
596    }
597
598    /// Enable push notifications on this connection.
599    ///
600    /// Delegates to [`WsSender::send_push_enable`].
601    pub async fn send_push_enable(
602        &mut self,
603        data_types: Option<&[&str]>,
604        push_state: Option<&str>,
605    ) -> Result<(), crate::error::ClientError> {
606        self.sender.send_push_enable(data_types, push_state).await
607    }
608
609    /// Disable push notifications on this connection.
610    ///
611    /// Delegates to [`WsSender::send_push_disable`].
612    pub async fn send_push_disable(&mut self) -> Result<(), crate::error::ClientError> {
613        self.sender.send_push_disable().await
614    }
615
616    /// Turn on client-side keepalive (RFC 6455 §5.5.2 Ping).
617    ///
618    /// When no message has arrived from the server for `interval`,
619    /// [`next_frame`](WsReceiver::next_frame) sends a Ping. If nothing at
620    /// all (Pong or any other message) arrives within `timeout` of that
621    /// Ping, `next_frame` returns a transport error (`is_io() == true`,
622    /// timed out) and the connection should be treated as dead.
623    ///
624    /// Pings are sent from inside `next_frame`, so a receive loop must be
625    /// running for keepalive to work. Call this before
626    /// [`split`](Self::split); the receive half keeps the setting. Off by
627    /// default.
628    ///
629    /// # Errors
630    ///
631    /// Returns `ClientError::InvalidArgument` if `interval` or `timeout` is
632    /// zero.
633    pub fn enable_keepalive(
634        &mut self,
635        interval: Duration,
636        timeout: Duration,
637    ) -> Result<(), crate::error::ClientError> {
638        if interval.is_zero() || timeout.is_zero() {
639            return Err(crate::error::ClientError::InvalidArgument(
640                "enable_keepalive: interval and timeout must be > 0".to_owned(),
641            ));
642        }
643        self.receiver.keepalive = Some(Keepalive {
644            sink: Arc::clone(&self.sender.sink),
645            interval,
646            timeout,
647            last_rx: tokio::time::Instant::now(),
648            ping_sent_at: None,
649        });
650        Ok(())
651    }
652
653    /// Consume the session and return its owned send and receive halves
654    /// (bd:JMAP-6r7c.31).
655    ///
656    /// Use this when a caller needs to drive the receive loop and the
657    /// send path concurrently — typically from two `tokio::spawn`-ed
658    /// tasks, one running a `while let Some(...) = receiver.next_frame()
659    /// .await` loop and one occasionally sending requests via
660    /// `sender.send_request(...)`. The unified `WsSession` API requires
661    /// `&mut self` for both directions and therefore cannot service them
662    /// concurrently; `split` is the explicit opt-in.
663    ///
664    /// The two halves are independent owners of their tungstenite
665    /// sub-streams. Dropping one half does not close the connection until
666    /// the other half is also dropped (tungstenite's `WebSocketStream`
667    /// only initiates the close handshake when both halves are gone). To
668    /// initiate a clean shutdown from a split session, drop the sender
669    /// after sending a final request and read from the receiver until it
670    /// returns `None`.
671    pub fn split(self) -> (WsSender, WsReceiver) {
672        let WsSession { sender, receiver } = self;
673        (sender, receiver)
674    }
675}
676
677/// Parse a raw WebSocket text frame into a `WsFrame`.
678///
679/// Two passes over `text`:
680///
681/// 1. Parse to [`serde_json::Value`] to extract `@type` (and to keep a
682///    structured fallback alive for the Unknown branch).
683/// 2. For the typed branches (`StateChange`, `Response`), call
684///    [`serde_json::from_str`] directly against the original `text`.
685///
686/// The previous shape `let raw = val.clone(); from_value::<T>(val)` paid a
687/// deep Value clone on every successful frame even though `raw` was thrown
688/// away. For 1-MiB-cap WS messages on a hot push path, the clone allocates
689/// a HashMap per `Value::Object` and a `String` per `Value::String` and
690/// dropped them moments later. Two text parses are cheaper for typical
691/// payload shapes than one parse + one deep Value clone, and the borrow
692/// checker no longer needs ownership tricks (bd:JMAP-6lsm.11).
693fn parse_ws_frame(text: &str) -> Result<WsFrame, crate::error::ClientError> {
694    let val: serde_json::Value =
695        serde_json::from_str(text).map_err(crate::error::ClientError::from_parse)?;
696
697    let type_name = val
698        .get("@type")
699        .and_then(|v| v.as_str())
700        .unwrap_or("<no @type>")
701        .to_owned();
702
703    match type_name.as_str() {
704        // A malformed StateChange is degraded to Unknown rather than a
705        // transport error. A single bad server frame must not kill the
706        // entire WebSocket connection; only tungstenite transport errors
707        // warrant a reconnect. The `val` we already parsed is the Unknown
708        // payload — no clone needed.
709        "StateChange" => match serde_json::from_str::<StateChange>(text) {
710            Ok(sc) => Ok(WsFrame::StateChange(sc)),
711            Err(_) => Ok(WsFrame::Unknown {
712                type_name,
713                raw: val,
714            }),
715        },
716        // Same degradation policy for malformed Response frames.
717        "Response" => match serde_json::from_str::<jmap_types::JmapResponse>(text) {
718            Ok(r) => Ok(WsFrame::Response(r)),
719            Err(_) => Ok(WsFrame::Unknown {
720                type_name,
721                raw: val,
722            }),
723        },
724        "RequestError" => match serde_json::from_str::<WsRequestError>(text) {
725            Ok(e) => Ok(WsFrame::RequestError(e)),
726            Err(_) => Ok(WsFrame::Unknown {
727                type_name,
728                raw: val,
729            }),
730        },
731        _ => Ok(WsFrame::Unknown {
732            type_name,
733            raw: val,
734        }),
735    }
736}
737
738/// Open a JMAP WebSocket connection (RFC 8887).
739///
740/// `ws_url` must come from the session document's WebSocket capability URL
741/// (a `wss://` endpoint in production; `ws://` is accepted in tests).
742///
743/// `auth_header` is an optional `(header-name, header-value)` pair injected
744/// into the WebSocket upgrade request. Pass `None` when the server does not
745/// require authentication headers on the WebSocket handshake.
746///
747/// Returns `ClientError::InvalidArgument` if the URL scheme is not
748/// `ws://` or `wss://`, preventing accidental use with untrusted URLs.
749///
750/// The returned [`WsSession`] provides [`WsSession::next_frame`] for receiving
751/// events. The caller is responsible for reconnecting after disconnect with
752/// exponential backoff.
753///
754/// Uses [`DEFAULT_WS_MAX_MESSAGE_BYTES`] as the per-message / per-frame cap.
755/// Callers that need a different cap should use [`connect_ws_with_limit`] or
756/// [`crate::JmapClient::connect_ws_session`] (which reads `ClientConfig::max_ws_message`).
757///
758/// # Security
759///
760/// The `auth_header` value is a credential and must not be logged or
761/// echoed back to other systems. Treat it with the same care as a
762/// [`crate::auth::BearerAuth`] token.
763pub async fn connect_ws(
764    ws_url: &str,
765    auth_header: Option<crate::auth::AuthHeader<'_>>,
766) -> Result<WsSession, crate::error::ClientError> {
767    connect_ws_with_limit(ws_url, auth_header, DEFAULT_WS_MAX_MESSAGE_BYTES).await
768}
769
770/// Establish a WebSocket connection with an explicit per-message / per-frame
771/// byte cap.
772///
773/// Same contract as [`connect_ws`] but lets the caller pin the
774/// `max_message_size` / `max_frame_size` config passed to tungstenite.
775/// Useful when the JMAP server is known to send larger pushes than the
776/// 1 MiB default (e.g. some Mailbox/changes push payloads on accounts with
777/// many mailboxes can exceed 1 MiB).
778///
779/// `max_message_bytes` MUST be > 0; tungstenite treats `Some(0)` as
780/// "no message of any size is acceptable" which is a misconfiguration trap.
781/// We surface `ClientError::InvalidArgument` instead.
782///
783/// # Security
784///
785/// The `auth_header` value is a credential and must not be logged or
786/// echoed back to other systems. Treat it with the same care as a
787/// [`crate::auth::BearerAuth`] token. The `ClientError::InvalidArgument`
788/// values produced for malformed auth header names or values are
789/// constructed without the original bytes, but callers should still
790/// avoid printing or storing the `auth_header` they passed in.
791pub async fn connect_ws_with_limit(
792    ws_url: &str,
793    auth_header: Option<crate::auth::AuthHeader<'_>>,
794    max_message_bytes: usize,
795) -> Result<WsSession, crate::error::ClientError> {
796    if max_message_bytes == 0 {
797        return Err(crate::error::ClientError::InvalidArgument(
798            "connect_ws_with_limit: max_message_bytes must be > 0".to_owned(),
799        ));
800    }
801    // Validate scheme to prevent SSRF via a compromised or MITM'd session.
802    // Case-insensitive check per RFC 3986 §3.1: only the SCHEME component is
803    // case-insensitive, not the path/query — so split off the scheme and
804    // compare with eq_ignore_ascii_case rather than lowercasing the whole
805    // URL. Lowercasing the whole URL allocated a fresh String the size of
806    // the URL on every connect (bd:JMAP-6lsm.9). The original (unmodified)
807    // URL is passed to tungstenite and kept in error messages for diagnostics.
808    let scheme_ok = ws_url
809        .split_once("://")
810        .is_some_and(|(s, _)| s.eq_ignore_ascii_case("ws") || s.eq_ignore_ascii_case("wss"));
811    if !scheme_ok {
812        return Err(crate::error::ClientError::InvalidArgument(format!(
813            "WebSocket URL must start with ws:// or wss://, got: {ws_url:?}"
814        )));
815    }
816
817    let mut request = ws_url
818        .into_client_request()
819        .map_err(crate::error::ClientError::from_ws)?;
820
821    if let Some(header) = auth_header {
822        // Both arms construct ClientError::InvalidArgument with a fixed
823        // string and deliberately discard the http-crate's Display output
824        // for the inner error. The original `name` / `value` bytes are
825        // credential-adjacent (the name component is less sensitive than
826        // the value, but a future http-crate version could begin echoing
827        // bytes in its Display impl). Defense-in-depth: keep neither in
828        // the error chain.
829        let hdr_name = http::HeaderName::from_str(header.name()).map_err(|_| {
830            crate::error::ClientError::InvalidArgument("invalid auth header name".to_owned())
831        })?;
832        let hdr_value = http::HeaderValue::from_str(header.expose_value()).map_err(|_| {
833            crate::error::ClientError::InvalidArgument("invalid auth header value".to_owned())
834        })?;
835        request.headers_mut().insert(hdr_name, hdr_value);
836    }
837
838    // WebSocketConfig is #[non_exhaustive] in tungstenite; use Default + field assignment.
839    let mut config = WebSocketConfig::default();
840    config.max_message_size = Some(max_message_bytes);
841    config.max_frame_size = Some(max_message_bytes);
842
843    // Apply a 10-second connect timeout, consistent with the HTTP transport's
844    // connect_timeout in DefaultTransport/CustomCaTransport.  tungstenite does
845    // not expose a connect timeout parameter, so we wrap at the Future level.
846    // A stalled TCP or TLS handshake would otherwise block indefinitely.
847    let connect_result = tokio::time::timeout(
848        std::time::Duration::from_secs(10),
849        tokio_tungstenite::connect_async_with_config(request, Some(config), false),
850    )
851    .await
852    .map_err(|_elapsed| {
853        // Synthesize an Io-kind transport error to surface the timeout
854        // through the public WebSocketError accessors (is_io() will be
855        // true). The third-party error type is constructed locally and
856        // immediately wrapped, so it does not leak to callers.
857        crate::error::ClientError::from_ws(tokio_tungstenite::tungstenite::Error::Io(
858            std::io::Error::new(
859                std::io::ErrorKind::TimedOut,
860                "WebSocket connect timed out after 10 seconds",
861            ),
862        ))
863    })?;
864    let (ws_stream, _response) = connect_result.map_err(crate::error::ClientError::from_ws)?;
865
866    let (sink, stream) = ws_stream.split();
867    Ok(WsSession {
868        sender: WsSender {
869            sink: Arc::new(futures::lock::Mutex::new(sink)),
870        },
871        receiver: WsReceiver {
872            stream,
873            keepalive: None,
874        },
875    })
876}
877
878impl std::fmt::Debug for WsSession {
879    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
880        f.debug_struct("WsSession").finish_non_exhaustive()
881    }
882}
883
884impl std::fmt::Debug for WsSender {
885    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
886        f.debug_struct("WsSender").finish_non_exhaustive()
887    }
888}
889
890impl std::fmt::Debug for WsReceiver {
891    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
892        f.debug_struct("WsReceiver").finish_non_exhaustive()
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    /// Verify WsFrame does not contain ChatTyping or ChatPresence variants.
901    /// This exhaustive match will fail to compile if either variant is reintroduced.
902    #[test]
903    fn ws_frame_has_no_chat_variants() {
904        let frame = WsFrame::Unknown {
905            type_name: "test".to_owned(),
906            raw: serde_json::Value::Null,
907        };
908        match frame {
909            WsFrame::StateChange(_) => {}
910            WsFrame::Response(_) => {}
911            WsFrame::RequestError(_) => {}
912            WsFrame::Unknown { .. } => {}
913        }
914    }
915
916    /// Oracle: parse_ws_frame dispatches on @type field and produces a typed StateChange.
917    /// Wire format from RFC 8620 §7.1.1 example.
918    #[test]
919    fn parse_state_change() {
920        let json = r#"{"@type":"StateChange","changed":{"account1":{"Mail":"s2"}}}"#;
921        let frame = parse_ws_frame(json).expect("must parse");
922        match frame {
923            WsFrame::StateChange(sc) => {
924                let account = sc
925                    .changed
926                    .get("account1")
927                    .expect("account1 must be present");
928                assert_eq!(account.get("Mail").map(|s| s.as_ref()), Some("s2"));
929            }
930            other => panic!("expected StateChange, got {other:?}"),
931        }
932    }
933
934    /// Oracle: a StateChange with missing `changed` field degrades to Unknown.
935    #[test]
936    fn parse_malformed_state_change_degrades_to_unknown() {
937        let json = r#"{"@type":"StateChange","unexpected_field":42}"#;
938        let frame = parse_ws_frame(json).expect("must not error");
939        match frame {
940            WsFrame::Unknown { type_name, .. } => assert_eq!(type_name, "StateChange"),
941            other => panic!("expected Unknown, got {other:?}"),
942        }
943    }
944
945    /// Oracle: parse_ws_frame returns Unknown for unrecognized @type.
946    /// Derived from parse_unknown_type test in source ws/mod.rs.
947    #[test]
948    fn parse_unknown_type() {
949        let json = r#"{"@type":"FutureEvent","foo":"bar"}"#;
950        let frame = parse_ws_frame(json).expect("must parse");
951        match frame {
952            WsFrame::Unknown { type_name, .. } => assert_eq!(type_name, "FutureEvent"),
953            other => panic!("expected Unknown, got {other:?}"),
954        }
955    }
956
957    /// Oracle: parse_ws_frame returns Unknown for missing @type.
958    /// Derived from parse_missing_type_field test in source ws/mod.rs.
959    #[test]
960    fn parse_missing_type_field() {
961        let json = r#"{"foo":"bar"}"#;
962        let frame = parse_ws_frame(json).expect("must parse");
963        assert!(matches!(frame, WsFrame::Unknown { .. }));
964    }
965
966    /// Oracle: parse_ws_frame returns Err(Parse) for invalid JSON.
967    /// Derived from parse_invalid_json_returns_parse_error test in source ws/mod.rs.
968    #[test]
969    fn parse_invalid_json_returns_parse_error() {
970        let err = parse_ws_frame("not json").expect_err("must fail");
971        assert!(matches!(err, crate::error::ClientError::Parse(_)));
972    }
973
974    /// Oracle: RFC 8887 §4.3.2 — every JMAP request sent over WebSocket MUST
975    /// include "@type": "Request".  Tests WsRequestFrame serde directly to
976    /// verify the #[serde(rename = "@type")] attribute and flatten are correct.
977    #[test]
978    fn send_request_includes_at_type_request() {
979        let req = jmap_types::JmapRequest::new(
980            vec!["urn:ietf:params:jmap:core".to_owned()],
981            vec![],
982            None,
983        );
984        let frame = WsRequestFrame {
985            ws_type: "Request",
986            id: None,
987            inner: &req,
988        };
989        let serialized = serde_json::to_string(&frame).expect("WsRequestFrame must serialize");
990        assert!(
991            serialized.contains("\"@type\":\"Request\""),
992            "RFC 8887 §4.3.2 requires @type:Request in outgoing WS frames; got: {serialized}"
993        );
994    }
995
996    /// Oracle: RFC 8887 §4.3.2 — optional `id` field is echoed in the response.
997    /// When an id is supplied, WsRequestFrame must include it in the serialized frame.
998    #[test]
999    fn send_request_includes_id_when_provided() {
1000        let req = jmap_types::JmapRequest::new(
1001            vec!["urn:ietf:params:jmap:core".to_owned()],
1002            vec![],
1003            None,
1004        );
1005        let frame = WsRequestFrame {
1006            ws_type: "Request",
1007            id: Some("req-42"),
1008            inner: &req,
1009        };
1010        let serialized = serde_json::to_string(&frame).expect("WsRequestFrame must serialize");
1011        assert!(
1012            serialized.contains("\"id\":\"req-42\""),
1013            "RFC 8887 §4.3.2 optional id must be present when provided; got: {serialized}"
1014        );
1015    }
1016
1017    /// Oracle: RFC 8887 §4.3.2 — when id is None, no `id` field appears in the frame.
1018    /// WsRequestFrame uses skip_serializing_if to omit the field entirely.
1019    #[test]
1020    fn send_request_omits_id_when_none() {
1021        let req = jmap_types::JmapRequest::new(
1022            vec!["urn:ietf:params:jmap:core".to_owned()],
1023            vec![],
1024            None,
1025        );
1026        let frame = WsRequestFrame {
1027            ws_type: "Request",
1028            id: None,
1029            inner: &req,
1030        };
1031        let serialized = serde_json::to_string(&frame).expect("WsRequestFrame must serialize");
1032        assert!(
1033            !serialized.contains("\"id\":"),
1034            "RFC 8887 §4.3.2: no id field must appear when id is None; got: {serialized}"
1035        );
1036    }
1037
1038    /// Oracle: connect_ws must reject http:// and https:// URLs with InvalidArgument.
1039    ///
1040    /// This is the documented SSRF prevention guard: a compromised or MITM'd session
1041    /// could send an http:// URL; we must not follow it as a WebSocket URL.
1042    /// The scheme check runs before any network I/O.
1043    /// Derived from connect_ws_rejects_non_ws_schemes test in source ws/mod.rs.
1044    #[tokio::test]
1045    async fn connect_ws_rejects_non_ws_schemes() {
1046        for bad_url in &["http://host/", "https://host/", "ftp://host/"] {
1047            let result = connect_ws(bad_url, None).await.map(|_| ());
1048            match result {
1049                Err(crate::error::ClientError::InvalidArgument(_)) => {}
1050                other => panic!("expected InvalidArgument for {bad_url:?}, got {other:?}"),
1051            }
1052        }
1053    }
1054
1055    // -----------------------------------------------------------------------
1056    // classify_message — bd:JMAP-6lsm.6
1057    // -----------------------------------------------------------------------
1058
1059    /// Oracle: Text frames classify as Text. The independent oracle is
1060    /// the next_frame contract in the docstring above.
1061    #[test]
1062    fn classify_text_message() {
1063        let m = Message::Text("hi".into());
1064        assert_eq!(classify_message(&m), MessageDisposition::Text);
1065    }
1066
1067    /// Oracle: Close frames classify as Close, ending the stream.
1068    #[test]
1069    fn classify_close_message() {
1070        let m = Message::Close(None);
1071        assert_eq!(classify_message(&m), MessageDisposition::Close);
1072    }
1073
1074    /// Oracle: Binary frames violate RFC 8887 §4.1 and must classify as
1075    /// Binary so the next_frame loop surfaces UnexpectedResponse rather
1076    /// than silently skipping (the bug JMAP-6lsm.6 fixes). The independent
1077    /// oracle is RFC 8887 §4.1.
1078    #[test]
1079    fn classify_binary_message_is_not_skipped() {
1080        let m = Message::Binary(vec![1, 2, 3].into());
1081        assert_eq!(classify_message(&m), MessageDisposition::Binary);
1082        assert_ne!(
1083            classify_message(&m),
1084            MessageDisposition::Skip,
1085            "Binary must NOT be silently skipped (RFC 8887 §4.1)"
1086        );
1087    }
1088
1089    /// Oracle: Ping/Pong frames classify as Skip. Tungstenite handles
1090    /// them at the protocol layer, so seeing them at the Message layer
1091    /// is unusual but legal — skip and continue.
1092    #[test]
1093    fn classify_ping_pong_messages_are_skipped() {
1094        let ping = Message::Ping(vec![].into());
1095        let pong = Message::Pong(vec![].into());
1096        assert_eq!(classify_message(&ping), MessageDisposition::Skip);
1097        assert_eq!(classify_message(&pong), MessageDisposition::Skip);
1098    }
1099
1100    /// Tripwire: the consecutive-skip cap is the documented value.
1101    /// A future retune will fail this test loudly so the change is
1102    /// visible in CI. Documented value is 64 (see the const docstring).
1103    #[test]
1104    fn consecutive_skip_cap_matches_documented_value() {
1105        assert_eq!(MAX_CONSECUTIVE_NON_TEXT_FRAMES, 64);
1106    }
1107
1108    /// Oracle: bd:JMAP-6r7c.5 / workspace AGENTS.md "Security testing"
1109    /// pattern 1 — per-type Debug redaction canary.
1110    ///
1111    /// A future contributor restoring `#[derive(Debug)]` on `WsFrame` would
1112    /// re-expose the credential-grade material that can land in
1113    /// `Unknown.raw` (push verification codes, federation handshake tokens,
1114    /// session-rotation challenges). This test constructs a canary literal
1115    /// inside the `raw` field and asserts the literal does not appear in
1116    /// the `{:?}`-formatted output of the frame.
1117    #[test]
1118    fn ws_frame_unknown_raw_is_redacted_in_debug_output() {
1119        let canary = "redaction-canary-cred-WFTMr8FoYpfP-do-not-leak";
1120        let frame = WsFrame::Unknown {
1121            type_name: "PushVerification".to_owned(),
1122            raw: serde_json::json!({
1123                "verificationCode": canary,
1124            }),
1125        };
1126        let rendered = format!("{frame:?}");
1127        assert!(
1128            !rendered.contains(canary),
1129            "WsFrame::Unknown Debug must redact `raw`; the canary literal \
1130             appeared in the rendered output, indicating either \
1131             #[derive(Debug)] was restored or the manual Debug impl \
1132             forgot to redact the raw field. Rendered output: {rendered}"
1133        );
1134        assert!(
1135            rendered.contains("[REDACTED]"),
1136            "WsFrame::Unknown Debug must render the redaction placeholder; \
1137             rendered output: {rendered}"
1138        );
1139        assert!(
1140            rendered.contains("PushVerification"),
1141            "WsFrame::Unknown Debug must still surface type_name for \
1142             diagnostics; rendered output: {rendered}"
1143        );
1144    }
1145
1146    /// Oracle: bd:JMAP-6r7c.5 — the Debug impl must still render the
1147    /// other two variants usefully. A naive impl that redacted everything
1148    /// would also be wrong; the security goal is narrowly scoped to the
1149    /// `Unknown.raw` field.
1150    #[test]
1151    fn ws_frame_other_variants_remain_useful_in_debug_output() {
1152        let response_frame = WsFrame::Response(jmap_types::JmapResponse::new(
1153            vec![],
1154            "test-session".into(),
1155            None,
1156        ));
1157        let rendered = format!("{response_frame:?}");
1158        assert!(
1159            rendered.starts_with("Response"),
1160            "Response variant Debug must surface variant tag; got: {rendered}"
1161        );
1162        assert!(
1163            rendered.contains("test-session"),
1164            "Response variant Debug must surface session_state for \
1165             diagnostics; got: {rendered}"
1166        );
1167    }
1168
1169    /// Oracle: RFC 8887 §4.4 — the server's reply to a non-JSON request,
1170    /// copied from the RFC example: `{"@type": "RequestError", "requestId":
1171    /// null, "type": "urn:ietf:params:jmap:error:notJSON", "status": 400,
1172    /// "detail": "The request did not parse as I-JSON."}`. §4.3.4 defines
1173    /// this as a Problem Details object extended with `@type` and
1174    /// `requestId`.
1175    #[test]
1176    fn parse_request_error_rfc8887_example() {
1177        let json = r#"{
1178            "@type": "RequestError",
1179            "requestId": null,
1180            "type": "urn:ietf:params:jmap:error:notJSON",
1181            "status": 400,
1182            "detail": "The request did not parse as I-JSON."
1183        }"#;
1184        let frame = parse_ws_frame(json).expect("must parse");
1185        assert_eq!(frame.request_id(), None);
1186        match frame {
1187            WsFrame::RequestError(e) => {
1188                assert_eq!(e.problem_type, "urn:ietf:params:jmap:error:notJSON");
1189                assert_eq!(e.status, Some(400));
1190                assert_eq!(
1191                    e.detail.as_deref(),
1192                    Some("The request did not parse as I-JSON.")
1193                );
1194                assert_eq!(e.request_id, None);
1195            }
1196            other => panic!("expected RequestError, got {other:?}"),
1197        }
1198    }
1199
1200    /// Oracle: RFC 8887 §4.3.4 — "requestId: ... MUST be returned if given
1201    /// in the request". A RequestError answering request "R7" must expose
1202    /// that id for correlation; the `limit` property is RFC 8620 §3.6.1's
1203    /// extension for `urn:ietf:params:jmap:error:limit`.
1204    #[test]
1205    fn request_error_exposes_request_id_and_limit() {
1206        let json = r#"{"@type":"RequestError","requestId":"R7",
1207            "type":"urn:ietf:params:jmap:error:limit","status":400,
1208            "limit":"maxSizeRequest"}"#;
1209        let frame = parse_ws_frame(json).expect("must parse");
1210        assert_eq!(frame.request_id(), Some("R7"));
1211        match frame {
1212            WsFrame::RequestError(e) => {
1213                assert_eq!(e.limit.as_deref(), Some("maxSizeRequest"));
1214            }
1215            other => panic!("expected RequestError, got {other:?}"),
1216        }
1217    }
1218
1219    /// Oracle: RFC 8887 §4.3.3 — the Response is extended with "requestId:
1220    /// ... The client-specified identifier in the corresponding request."
1221    /// JSON is the RFC 8887 §4.4 Core/echo response example (`"requestId":
1222    /// "R1"`), plus the `sessionState` that RFC 8620 §3.4 makes mandatory
1223    /// and the RFC example elides.
1224    #[test]
1225    fn response_request_id_rfc8887_example() {
1226        let json = r#"{
1227            "@type": "Response",
1228            "requestId": "R1",
1229            "methodResponses": [
1230                ["Core/echo", {"hello": true, "high": 5}, "b3ff"]
1231            ],
1232            "sessionState": "75128aab4b1b"
1233        }"#;
1234        let frame = parse_ws_frame(json).expect("must parse");
1235        assert!(matches!(frame, WsFrame::Response(_)), "got {frame:?}");
1236        assert_eq!(frame.request_id(), Some("R1"));
1237    }
1238
1239    /// bd:JMAP-6r7c.31 — WsSender and WsReceiver must each be `Send` so a
1240    /// caller can move one half into a separate tokio task while keeping
1241    /// the other half in the current task. The whole point of the split
1242    /// is two-task concurrent send-while-receiving; if either half were
1243    /// `!Send`, the split would not enable it.
1244    ///
1245    /// `Sync` is required because `WsSender` behind `Arc` (for concurrent
1246    /// sends from multiple tasks) needs `Arc<WsSender>: Send`, which
1247    /// requires `WsSender: Sync`. Compile-time check only — nothing to
1248    /// assert at runtime.
1249    #[test]
1250    fn ws_sender_and_receiver_are_send_and_sync() {
1251        fn assert_send_sync<T: Send + Sync>() {}
1252        assert_send_sync::<WsSender>();
1253        assert_send_sync::<WsReceiver>();
1254        // The unified session was already Send+Sync before the split
1255        // landed; assert here as a regression guard so a future refactor
1256        // that accidentally introduces a `!Send` or `!Sync` field in
1257        // WsSession is caught.
1258        assert_send_sync::<WsSession>();
1259    }
1260}