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}