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}