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 _;
10
11use futures::SinkExt as _;
12use futures::StreamExt as _;
13use tokio_tungstenite::tungstenite::client::IntoClientRequest as _;
14use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
15use tokio_tungstenite::tungstenite::Message;
16
17use crate::push::StateChange;
18
19/// Wire frame sent from the client to the server over WebSocket (RFC 8887 §4.3.2).
20///
21/// Wraps a [`jmap_types::JmapRequest`] and injects the mandatory `@type: "Request"`
22/// field (and optional `id`) in a single `serde_json::to_string` pass, avoiding
23/// the `to_value` + mutation + `to_string` double-serialization that the naive
24/// approach requires.
25#[derive(serde::Serialize)]
26struct WsRequestFrame<'a> {
27 /// RFC 8887 §4.3.2 — every JMAP request frame MUST carry "@type": "Request".
28 #[serde(rename = "@type")]
29 ws_type: &'static str,
30 /// Optional correlation ID echoed back in the server's Response frame.
31 #[serde(skip_serializing_if = "Option::is_none")]
32 id: Option<&'a str>,
33 /// The JMAP request payload; flattened into the enclosing JSON object.
34 #[serde(flatten)]
35 inner: &'a jmap_types::JmapRequest,
36}
37
38/// Maximum WebSocket message size (1 MiB), consistent with the SSE frame limit.
39/// Prevents a misbehaving or hostile server from forcing the client to buffer
40/// large messages over the event connection.
41/// Default per-message / per-frame byte cap for WebSocket connections opened
42/// via [`connect_ws`] (which does not take a limit parameter). Callers that
43/// need a different cap should use [`connect_ws_with_limit`] or the
44/// [`crate::JmapClient::connect_ws_session`] convenience method which
45/// reads the `max_ws_message` field from `ClientConfig`. Default: 1 MiB.
46pub const DEFAULT_WS_MAX_MESSAGE_BYTES: usize = 1 << 20;
47
48/// A parsed frame received from the JMAP WebSocket.
49///
50/// Marked `#[non_exhaustive]` because the spec may define additional
51/// `@type` values in future revisions.
52#[non_exhaustive]
53#[derive(Debug, Clone, PartialEq)]
54pub enum WsFrame {
55 /// RFC 8620 §7.1 StateChange — one or more object types have changed
56 /// state; client must re-fetch the affected data types.
57 StateChange(StateChange),
58 /// RFC 8887 Response — reply to a JMAP request sent on this connection.
59 Response(jmap_types::JmapResponse),
60 /// Unrecognized `@type` — silently ignored per forward-compatibility rules
61 /// (RFC 8887 §4.3.1: clients SHOULD ignore unknown message types).
62 ///
63 /// Also produced when a known type (`"Response"` or `"StateChange"`) fails
64 /// to deserialize — `type_name` will be `"Response"` or `"StateChange"` in
65 /// that case, which can signal server misbehavior or a schema version
66 /// mismatch. Callers that log unknown frames should check for these names.
67 Unknown {
68 /// Value of the `@type` field. Either an unrecognized message type
69 /// per RFC 8887 §4.3.1, or `"Response"` / `"StateChange"` when a known
70 /// type failed to deserialize into its typed variant.
71 type_name: String,
72 /// Raw JSON object as received from the server, preserved for
73 /// forward-compatibility diagnostics.
74 ///
75 /// **DO NOT log this field verbatim.** Future or extension JMAP
76 /// WebSocket message types may carry credential-grade material —
77 /// push verification codes (RFC 8887 §7.2), federation handshake
78 /// tokens, session-rotation challenges, etc. — and a malformed
79 /// `Response` to a method like `PushSubscription/get` can echo a
80 /// `verificationCode` back into this field. The enum derives
81 /// `Debug`, so a `{:?}`-format of any `WsFrame::Unknown` writes
82 /// this Value to the output stream.
83 ///
84 /// For operator logs, prefer logging `type_name` only, or apply a
85 /// project-specific redaction filter before passing `raw` to a
86 /// logging sink. See bd:JMAP-sc1b.98.
87 raw: serde_json::Value,
88 },
89}
90
91type Inner =
92 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
93
94/// An established JMAP WebSocket session (RFC 8887).
95///
96/// Call [`next_frame`](WsSession::next_frame) in a loop to receive events.
97/// Use [`send_request`](WsSession::send_request) to transmit JMAP requests.
98///
99/// The caller is responsible for reconnecting after the stream ends or returns
100/// a transport error. Use exponential backoff.
101pub struct WsSession {
102 sink: futures::stream::SplitSink<Inner, Message>,
103 stream: futures::stream::SplitStream<Inner>,
104}
105
106/// Maximum number of consecutive non-Text non-Close non-Binary frames
107/// (Ping, Pong, Frame, etc.) `next_frame` will silently skip in a single call.
108///
109/// Tungstenite handles ping/pong at the protocol layer, so seeing them at the
110/// `Message` layer is unusual but legal — we skip them. A misbehaving or
111/// hostile server that floods the stream with no-op frames could otherwise
112/// starve a caller of `next_frame` indefinitely; this cap surfaces an
113/// `UnexpectedResponse` error before that can happen. 64 is high enough that
114/// a normal connection never trips it (typical SSE/WS streams interleave at
115/// most a handful of pings between data frames) and low enough that the
116/// caller doesn't wait long if a bad server is talking nonsense.
117///
118/// `Binary` frames are NOT counted here — they violate RFC 8887 §4.1 and
119/// surface as `UnexpectedResponse` immediately on the first occurrence.
120const MAX_CONSECUTIVE_NON_TEXT_FRAMES: usize = 64;
121
122/// Classify a single tungstenite [`Message`] into a [`MessageDisposition`]
123/// that tells the [`WsSession::next_frame`] loop what to do with it.
124///
125/// Extracted as a free function so the policy is unit-testable without a
126/// real WebSocket: see the inline test module. Pure function over the
127/// message variant.
128fn classify_message(msg: &Message) -> MessageDisposition {
129 match msg {
130 Message::Text(_) => MessageDisposition::Text,
131 Message::Close(_) => MessageDisposition::Close,
132 Message::Binary(_) => MessageDisposition::Binary,
133 // Ping, Pong, Frame, and any future variants: skip, but count.
134 _ => MessageDisposition::Skip,
135 }
136}
137
138/// Decision a `next_frame` loop iteration takes after looking at one
139/// [`Message`]. See [`classify_message`].
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141enum MessageDisposition {
142 /// Text frame: hand to `parse_ws_frame` and return its result.
143 Text,
144 /// Close frame: end the stream by returning `None`.
145 Close,
146 /// Binary frame: violates RFC 8887 §4.1; surface as
147 /// `UnexpectedResponse` immediately on the first occurrence.
148 Binary,
149 /// Ping / Pong / Frame / future variants: silently skip and continue
150 /// the loop, subject to [`MAX_CONSECUTIVE_NON_TEXT_FRAMES`].
151 Skip,
152}
153
154impl WsSession {
155 /// Receive the next parsed frame from the server.
156 ///
157 /// Returns `None` when the server has cleanly closed the connection.
158 /// Returns `Some(Err(...))` on parse failure, transport error, RFC 8887
159 /// §4.1 violation (Binary frame), or starvation cap (more than 64
160 /// consecutive Ping/Pong/Frame messages — see the private
161 /// `MAX_CONSECUTIVE_NON_TEXT_FRAMES` constant for the exact value).
162 /// After a transport error the connection is broken and `next_frame`
163 /// must not be called again. After an `UnexpectedResponse` error the
164 /// underlying stream is still healthy — the caller may choose to
165 /// ignore it and retry, or to disconnect.
166 pub async fn next_frame(&mut self) -> Option<Result<WsFrame, crate::error::ClientError>> {
167 let mut consecutive_skips = 0usize;
168 loop {
169 let msg = match self.stream.next().await? {
170 Ok(m) => m,
171 Err(e) => return Some(Err(crate::error::ClientError::from_ws(e))),
172 };
173 match classify_message(&msg) {
174 MessageDisposition::Text => {
175 let Message::Text(text) = msg else {
176 // Unreachable: classify_message returned Text only for
177 // Message::Text. Defensive in case the variant grows.
178 return Some(Err(crate::error::ClientError::UnexpectedResponse(
179 "WebSocket: classify_message returned Text for non-Text variant".into(),
180 )));
181 };
182 return Some(parse_ws_frame(&text));
183 }
184 MessageDisposition::Close => return None,
185 MessageDisposition::Binary => {
186 // RFC 8887 §4.1: JMAP only uses text frames. Surface the
187 // violation; underlying stream is still healthy so the
188 // caller can choose to retry next_frame if it wants.
189 return Some(Err(crate::error::ClientError::UnexpectedResponse(
190 "WebSocket: server sent Binary frame; RFC 8887 §4.1 mandates text frames"
191 .into(),
192 )));
193 }
194 MessageDisposition::Skip => {
195 consecutive_skips = consecutive_skips.saturating_add(1);
196 if consecutive_skips > MAX_CONSECUTIVE_NON_TEXT_FRAMES {
197 return Some(Err(crate::error::ClientError::UnexpectedResponse(
198 format!(
199 "WebSocket: exceeded {MAX_CONSECUTIVE_NON_TEXT_FRAMES} consecutive non-text frames; possible server misbehaviour"
200 ),
201 )));
202 }
203 }
204 }
205 }
206 }
207
208 /// Send a raw text frame over the WebSocket connection.
209 ///
210 /// Used by extension crates to send non-JMAP frames (e.g., JMAP Chat
211 /// ephemeral stream control messages).
212 pub async fn send_text(&mut self, text: String) -> Result<(), crate::error::ClientError> {
213 self.sink
214 .send(Message::Text(text.into()))
215 .await
216 .map_err(crate::error::ClientError::from_ws)
217 }
218
219 /// Send a JMAP request over the WebSocket connection.
220 ///
221 /// Serializes `req` and injects `"@type": "Request"` into the outgoing
222 /// JSON object as required by RFC 8887 §4.3.2. The optional `id` is
223 /// echoed back in the corresponding `Response` frame, enabling out-of-order
224 /// correlation.
225 ///
226 /// # Errors
227 ///
228 /// Returns `ClientError::Serialize` if `req` cannot be serialized, or
229 /// `ClientError::WebSocket` on a transport failure.
230 pub async fn send_request(
231 &mut self,
232 req: &jmap_types::JmapRequest,
233 id: Option<&str>,
234 ) -> Result<(), crate::error::ClientError> {
235 // Wrap req in WsRequestFrame to inject @type and optional id in one
236 // serialization pass (no intermediate serde_json::Value allocation).
237 let frame = WsRequestFrame {
238 ws_type: "Request",
239 id,
240 inner: req,
241 };
242 let text = serde_json::to_string(&frame).map_err(crate::error::ClientError::Serialize)?;
243 self.sink
244 .send(Message::Text(text.into()))
245 .await
246 .map_err(crate::error::ClientError::from_ws)
247 }
248}
249
250/// Parse a raw WebSocket text frame into a `WsFrame`.
251///
252/// Two passes over `text`:
253///
254/// 1. Parse to [`serde_json::Value`] to extract `@type` (and to keep a
255/// structured fallback alive for the Unknown branch).
256/// 2. For the typed branches (`StateChange`, `Response`), call
257/// [`serde_json::from_str`] directly against the original `text`.
258///
259/// The previous shape `let raw = val.clone(); from_value::<T>(val)` paid a
260/// deep Value clone on every successful frame even though `raw` was thrown
261/// away. For 1-MiB-cap WS messages on a hot push path, the clone allocates
262/// a HashMap per `Value::Object` and a `String` per `Value::String` and
263/// dropped them moments later. Two text parses are cheaper for typical
264/// payload shapes than one parse + one deep Value clone, and the borrow
265/// checker no longer needs ownership tricks (bd:JMAP-6lsm.11).
266fn parse_ws_frame(text: &str) -> Result<WsFrame, crate::error::ClientError> {
267 let val: serde_json::Value =
268 serde_json::from_str(text).map_err(crate::error::ClientError::Parse)?;
269
270 let type_name = val
271 .get("@type")
272 .and_then(|v| v.as_str())
273 .unwrap_or("<no @type>")
274 .to_owned();
275
276 match type_name.as_str() {
277 // A malformed StateChange is degraded to Unknown rather than a
278 // transport error. A single bad server frame must not kill the
279 // entire WebSocket connection; only tungstenite transport errors
280 // warrant a reconnect. The `val` we already parsed is the Unknown
281 // payload — no clone needed.
282 "StateChange" => match serde_json::from_str::<StateChange>(text) {
283 Ok(sc) => Ok(WsFrame::StateChange(sc)),
284 Err(_) => Ok(WsFrame::Unknown {
285 type_name,
286 raw: val,
287 }),
288 },
289 // Same degradation policy for malformed Response frames.
290 "Response" => match serde_json::from_str::<jmap_types::JmapResponse>(text) {
291 Ok(r) => Ok(WsFrame::Response(r)),
292 Err(_) => Ok(WsFrame::Unknown {
293 type_name,
294 raw: val,
295 }),
296 },
297 _ => Ok(WsFrame::Unknown {
298 type_name,
299 raw: val,
300 }),
301 }
302}
303
304/// Open a JMAP WebSocket connection (RFC 8887).
305///
306/// `ws_url` must come from the session document's WebSocket capability URL
307/// (a `wss://` endpoint in production; `ws://` is accepted in tests).
308///
309/// `auth_header` is an optional `(header-name, header-value)` pair injected
310/// into the WebSocket upgrade request. Pass `None` when the server does not
311/// require authentication headers on the WebSocket handshake.
312///
313/// Returns `ClientError::InvalidArgument` if the URL scheme is not
314/// `ws://` or `wss://`, preventing accidental use with untrusted URLs.
315///
316/// The returned [`WsSession`] provides [`WsSession::next_frame`] for receiving
317/// events. The caller is responsible for reconnecting after disconnect with
318/// exponential backoff.
319///
320/// Uses [`DEFAULT_WS_MAX_MESSAGE_BYTES`] as the per-message / per-frame cap.
321/// Callers that need a different cap should use [`connect_ws_with_limit`] or
322/// [`crate::JmapClient::connect_ws_session`] (which reads `ClientConfig::max_ws_message`).
323///
324/// # Security
325///
326/// The `auth_header` value is a credential and must not be logged or
327/// echoed back to other systems. Treat it with the same care as a
328/// [`crate::auth::BearerAuth`] token.
329pub async fn connect_ws(
330 ws_url: &str,
331 auth_header: Option<(&str, &str)>,
332) -> Result<WsSession, crate::error::ClientError> {
333 connect_ws_with_limit(ws_url, auth_header, DEFAULT_WS_MAX_MESSAGE_BYTES).await
334}
335
336/// Establish a WebSocket connection with an explicit per-message / per-frame
337/// byte cap.
338///
339/// Same contract as [`connect_ws`] but lets the caller pin the
340/// `max_message_size` / `max_frame_size` config passed to tungstenite.
341/// Useful when the JMAP server is known to send larger pushes than the
342/// 1 MiB default (e.g. some Mailbox/changes push payloads on accounts with
343/// many mailboxes can exceed 1 MiB).
344///
345/// `max_message_bytes` MUST be > 0; tungstenite treats `Some(0)` as
346/// "no message of any size is acceptable" which is a misconfiguration trap.
347/// We surface `ClientError::InvalidArgument` instead.
348///
349/// # Security
350///
351/// The `auth_header` value is a credential and must not be logged or
352/// echoed back to other systems. Treat it with the same care as a
353/// [`crate::auth::BearerAuth`] token. The `ClientError::InvalidArgument`
354/// values produced for malformed auth header names or values are
355/// constructed without the original bytes, but callers should still
356/// avoid printing or storing the `auth_header` they passed in.
357pub async fn connect_ws_with_limit(
358 ws_url: &str,
359 auth_header: Option<(&str, &str)>,
360 max_message_bytes: usize,
361) -> Result<WsSession, crate::error::ClientError> {
362 if max_message_bytes == 0 {
363 return Err(crate::error::ClientError::InvalidArgument(
364 "connect_ws_with_limit: max_message_bytes must be > 0".to_owned(),
365 ));
366 }
367 // Validate scheme to prevent SSRF via a compromised or MITM'd session.
368 // Case-insensitive check per RFC 3986 §3.1: only the SCHEME component is
369 // case-insensitive, not the path/query — so split off the scheme and
370 // compare with eq_ignore_ascii_case rather than lowercasing the whole
371 // URL. Lowercasing the whole URL allocated a fresh String the size of
372 // the URL on every connect (bd:JMAP-6lsm.9). The original (unmodified)
373 // URL is passed to tungstenite and kept in error messages for diagnostics.
374 let scheme_ok = ws_url
375 .split_once("://")
376 .is_some_and(|(s, _)| s.eq_ignore_ascii_case("ws") || s.eq_ignore_ascii_case("wss"));
377 if !scheme_ok {
378 return Err(crate::error::ClientError::InvalidArgument(format!(
379 "WebSocket URL must start with ws:// or wss://, got: {ws_url:?}"
380 )));
381 }
382
383 let mut request = ws_url
384 .into_client_request()
385 .map_err(crate::error::ClientError::from_ws)?;
386
387 if let Some((name, value)) = auth_header {
388 // Both arms construct ClientError::InvalidArgument with a fixed
389 // string and deliberately discard the http-crate's Display output
390 // for the inner error. The original `name` / `value` bytes are
391 // credential-adjacent (the name component is less sensitive than
392 // the value, but a future http-crate version could begin echoing
393 // bytes in its Display impl). Defense-in-depth: keep neither in
394 // the error chain.
395 let hdr_name = http::HeaderName::from_str(name).map_err(|_| {
396 crate::error::ClientError::InvalidArgument("invalid auth header name".to_owned())
397 })?;
398 let hdr_value = http::HeaderValue::from_str(value).map_err(|_| {
399 crate::error::ClientError::InvalidArgument("invalid auth header value".to_owned())
400 })?;
401 request.headers_mut().insert(hdr_name, hdr_value);
402 }
403
404 // WebSocketConfig is #[non_exhaustive] in tungstenite; use Default + field assignment.
405 let mut config = WebSocketConfig::default();
406 config.max_message_size = Some(max_message_bytes);
407 config.max_frame_size = Some(max_message_bytes);
408
409 // Apply a 10-second connect timeout, consistent with the HTTP transport's
410 // connect_timeout in DefaultTransport/CustomCaTransport. tungstenite does
411 // not expose a connect timeout parameter, so we wrap at the Future level.
412 // A stalled TCP or TLS handshake would otherwise block indefinitely.
413 let connect_result = tokio::time::timeout(
414 std::time::Duration::from_secs(10),
415 tokio_tungstenite::connect_async_with_config(request, Some(config), false),
416 )
417 .await
418 .map_err(|_elapsed| {
419 // Synthesize an Io-kind transport error to surface the timeout
420 // through the public WebSocketError accessors (is_io() will be
421 // true). The third-party error type is constructed locally and
422 // immediately wrapped, so it does not leak to callers.
423 crate::error::ClientError::from_ws(tokio_tungstenite::tungstenite::Error::Io(
424 std::io::Error::new(
425 std::io::ErrorKind::TimedOut,
426 "WebSocket connect timed out after 10 seconds",
427 ),
428 ))
429 })?;
430 let (ws_stream, _response) = connect_result.map_err(crate::error::ClientError::from_ws)?;
431
432 let (sink, stream) = ws_stream.split();
433 Ok(WsSession { sink, stream })
434}
435
436impl std::fmt::Debug for WsSession {
437 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438 f.debug_struct("WsSession").finish_non_exhaustive()
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 /// Verify WsFrame does not contain ChatTyping or ChatPresence variants.
447 /// This exhaustive match will fail to compile if either variant is reintroduced.
448 #[test]
449 fn ws_frame_has_no_chat_variants() {
450 let frame = WsFrame::Unknown {
451 type_name: "test".to_owned(),
452 raw: serde_json::Value::Null,
453 };
454 match frame {
455 WsFrame::StateChange(_) => {}
456 WsFrame::Response(_) => {}
457 WsFrame::Unknown { .. } => {}
458 }
459 }
460
461 /// Oracle: parse_ws_frame dispatches on @type field and produces a typed StateChange.
462 /// Wire format from RFC 8620 §7.1.1 example.
463 #[test]
464 fn parse_state_change() {
465 let json = r#"{"@type":"StateChange","changed":{"account1":{"Mail":"s2"}}}"#;
466 let frame = parse_ws_frame(json).expect("must parse");
467 match frame {
468 WsFrame::StateChange(sc) => {
469 let account = sc
470 .changed
471 .get("account1")
472 .expect("account1 must be present");
473 assert_eq!(account.get("Mail").map(|s| s.as_ref()), Some("s2"));
474 }
475 other => panic!("expected StateChange, got {other:?}"),
476 }
477 }
478
479 /// Oracle: a StateChange with missing `changed` field degrades to Unknown.
480 #[test]
481 fn parse_malformed_state_change_degrades_to_unknown() {
482 let json = r#"{"@type":"StateChange","unexpected_field":42}"#;
483 let frame = parse_ws_frame(json).expect("must not error");
484 match frame {
485 WsFrame::Unknown { type_name, .. } => assert_eq!(type_name, "StateChange"),
486 other => panic!("expected Unknown, got {other:?}"),
487 }
488 }
489
490 /// Oracle: parse_ws_frame returns Unknown for unrecognized @type.
491 /// Derived from parse_unknown_type test in source ws/mod.rs.
492 #[test]
493 fn parse_unknown_type() {
494 let json = r#"{"@type":"FutureEvent","foo":"bar"}"#;
495 let frame = parse_ws_frame(json).expect("must parse");
496 match frame {
497 WsFrame::Unknown { type_name, .. } => assert_eq!(type_name, "FutureEvent"),
498 other => panic!("expected Unknown, got {other:?}"),
499 }
500 }
501
502 /// Oracle: parse_ws_frame returns Unknown for missing @type.
503 /// Derived from parse_missing_type_field test in source ws/mod.rs.
504 #[test]
505 fn parse_missing_type_field() {
506 let json = r#"{"foo":"bar"}"#;
507 let frame = parse_ws_frame(json).expect("must parse");
508 assert!(matches!(frame, WsFrame::Unknown { .. }));
509 }
510
511 /// Oracle: parse_ws_frame returns Err(Parse) for invalid JSON.
512 /// Derived from parse_invalid_json_returns_parse_error test in source ws/mod.rs.
513 #[test]
514 fn parse_invalid_json_returns_parse_error() {
515 let err = parse_ws_frame("not json").expect_err("must fail");
516 assert!(matches!(err, crate::error::ClientError::Parse(_)));
517 }
518
519 /// Oracle: RFC 8887 §4.3.2 — every JMAP request sent over WebSocket MUST
520 /// include "@type": "Request". Tests WsRequestFrame serde directly to
521 /// verify the #[serde(rename = "@type")] attribute and flatten are correct.
522 #[test]
523 fn send_request_includes_at_type_request() {
524 let req = jmap_types::JmapRequest::new(
525 vec!["urn:ietf:params:jmap:core".to_owned()],
526 vec![],
527 None,
528 );
529 let frame = WsRequestFrame {
530 ws_type: "Request",
531 id: None,
532 inner: &req,
533 };
534 let serialized = serde_json::to_string(&frame).expect("WsRequestFrame must serialize");
535 assert!(
536 serialized.contains("\"@type\":\"Request\""),
537 "RFC 8887 §4.3.2 requires @type:Request in outgoing WS frames; got: {serialized}"
538 );
539 }
540
541 /// Oracle: RFC 8887 §4.3.2 — optional `id` field is echoed in the response.
542 /// When an id is supplied, WsRequestFrame must include it in the serialized frame.
543 #[test]
544 fn send_request_includes_id_when_provided() {
545 let req = jmap_types::JmapRequest::new(
546 vec!["urn:ietf:params:jmap:core".to_owned()],
547 vec![],
548 None,
549 );
550 let frame = WsRequestFrame {
551 ws_type: "Request",
552 id: Some("req-42"),
553 inner: &req,
554 };
555 let serialized = serde_json::to_string(&frame).expect("WsRequestFrame must serialize");
556 assert!(
557 serialized.contains("\"id\":\"req-42\""),
558 "RFC 8887 §4.3.2 optional id must be present when provided; got: {serialized}"
559 );
560 }
561
562 /// Oracle: RFC 8887 §4.3.2 — when id is None, no `id` field appears in the frame.
563 /// WsRequestFrame uses skip_serializing_if to omit the field entirely.
564 #[test]
565 fn send_request_omits_id_when_none() {
566 let req = jmap_types::JmapRequest::new(
567 vec!["urn:ietf:params:jmap:core".to_owned()],
568 vec![],
569 None,
570 );
571 let frame = WsRequestFrame {
572 ws_type: "Request",
573 id: None,
574 inner: &req,
575 };
576 let serialized = serde_json::to_string(&frame).expect("WsRequestFrame must serialize");
577 assert!(
578 !serialized.contains("\"id\":"),
579 "RFC 8887 §4.3.2: no id field must appear when id is None; got: {serialized}"
580 );
581 }
582
583 /// Oracle: connect_ws must reject http:// and https:// URLs with InvalidArgument.
584 ///
585 /// This is the documented SSRF prevention guard: a compromised or MITM'd session
586 /// could send an http:// URL; we must not follow it as a WebSocket URL.
587 /// The scheme check runs before any network I/O.
588 /// Derived from connect_ws_rejects_non_ws_schemes test in source ws/mod.rs.
589 #[tokio::test]
590 async fn connect_ws_rejects_non_ws_schemes() {
591 for bad_url in &["http://host/", "https://host/", "ftp://host/"] {
592 let result = connect_ws(bad_url, None).await.map(|_| ());
593 match result {
594 Err(crate::error::ClientError::InvalidArgument(_)) => {}
595 other => panic!("expected InvalidArgument for {bad_url:?}, got {other:?}"),
596 }
597 }
598 }
599
600 // -----------------------------------------------------------------------
601 // classify_message — bd:JMAP-6lsm.6
602 // -----------------------------------------------------------------------
603
604 /// Oracle: Text frames classify as Text. The independent oracle is
605 /// the next_frame contract in the docstring above.
606 #[test]
607 fn classify_text_message() {
608 let m = Message::Text("hi".into());
609 assert_eq!(classify_message(&m), MessageDisposition::Text);
610 }
611
612 /// Oracle: Close frames classify as Close, ending the stream.
613 #[test]
614 fn classify_close_message() {
615 let m = Message::Close(None);
616 assert_eq!(classify_message(&m), MessageDisposition::Close);
617 }
618
619 /// Oracle: Binary frames violate RFC 8887 §4.1 and must classify as
620 /// Binary so the next_frame loop surfaces UnexpectedResponse rather
621 /// than silently skipping (the bug JMAP-6lsm.6 fixes). The independent
622 /// oracle is RFC 8887 §4.1.
623 #[test]
624 fn classify_binary_message_is_not_skipped() {
625 let m = Message::Binary(vec![1, 2, 3].into());
626 assert_eq!(classify_message(&m), MessageDisposition::Binary);
627 assert_ne!(
628 classify_message(&m),
629 MessageDisposition::Skip,
630 "Binary must NOT be silently skipped (RFC 8887 §4.1)"
631 );
632 }
633
634 /// Oracle: Ping/Pong frames classify as Skip. Tungstenite handles
635 /// them at the protocol layer, so seeing them at the Message layer
636 /// is unusual but legal — skip and continue.
637 #[test]
638 fn classify_ping_pong_messages_are_skipped() {
639 let ping = Message::Ping(vec![].into());
640 let pong = Message::Pong(vec![].into());
641 assert_eq!(classify_message(&ping), MessageDisposition::Skip);
642 assert_eq!(classify_message(&pong), MessageDisposition::Skip);
643 }
644
645 /// Tripwire: the consecutive-skip cap is the documented value.
646 /// A future retune will fail this test loudly so the change is
647 /// visible in CI. Documented value is 64 (see the const docstring).
648 #[test]
649 fn consecutive_skip_cap_matches_documented_value() {
650 assert_eq!(MAX_CONSECUTIVE_NON_TEXT_FRAMES, 64);
651 }
652}