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