jmap_server/parse.rs
1//! Request parsing and ResultReference resolution (RFC 8620 §3.3, §3.7).
2
3use crate::{Invocation, JmapError, JmapRequest, ResultReference};
4use serde_json::Value;
5
6/// Parse and validate a JMAP request from a raw JSON value.
7///
8/// Validates:
9/// - The body deserializes as a [`JmapRequest`].
10/// - The number of method calls does not exceed `max_calls` (RFC 8620 §3.3).
11///
12/// An empty `using` array is **not** rejected here. Per the jmap-test-suite
13/// conformance ruling (Q4 / `error-empty-using`), the server must process the
14/// request and return `unknownMethod` for every call — not a 400-level
15/// `notRequest`. Capability URI validation is the caller's responsibility;
16/// call [`check_known_capabilities`] immediately after this function and map
17/// any `Err` to an HTTP 400 response.
18///
19/// # Caller responsibility: `notJSON`
20///
21/// This function takes a pre-parsed [`serde_json::Value`], not raw bytes. The
22/// caller is responsible for the initial JSON parse of the HTTP request body.
23/// If that parse fails (the body is not valid JSON), the caller must produce the
24/// `notJSON` error response itself — [`crate::error_invocation`] and
25/// [`crate::request_error`] with [`JmapError::not_json()`] handle that case.
26/// `parse_request` only validates the JMAP structure of an already-parsed value.
27///
28/// # Caller responsibility: resource limits
29///
30/// Because this function works on an already-parsed [`serde_json::Value`], it
31/// cannot enforce the byte-size or JSON-nesting-depth limits that determine
32/// whether the input is safe to walk on a worker thread. Those limits MUST
33/// be applied by the HTTP integration before `parse_request` is called:
34///
35/// - **Body size cap.** Apply a maximum request-body size before reading the
36/// body into memory. RFC 8620 §3 defines `maxSizeRequest` as a session
37/// capability the server advertises; the byte cap MUST be `<=` that value.
38/// A sensible default is 10 MiB. In `axum`, wrap your router with
39/// `tower_http::limit::RequestBodyLimitLayer`; in `hyper`, use
40/// `http_body_util::Limited`; in `warp`, pair `warp::body::content_length_limit`
41/// with `warp::body::bytes`.
42/// - **JSON nesting depth cap.** Use `serde_json::from_slice` (which honours
43/// `serde_json`'s recursion limit) rather than constructing a [`Value`] by
44/// hand. `serde_json`'s default 128-level recursion limit is intentionally
45/// loose; deployments that face untrusted clients should consider rejecting
46/// request bodies that exceed ~32 levels of JSON nesting before passing
47/// them here. 32 levels is well above any legitimate JMAP request shape.
48/// - **Per-pointer recursion.** ResultReference paths are walked by an
49/// internal helper that carries its own depth cap, so integrators do not
50/// need additional guards on the path string itself once the body-size
51/// and JSON-depth limits are in place.
52///
53/// Failing to enforce these limits exposes the dispatcher to memory and
54/// stack-exhaustion DoS on adversarial input. The library cannot apply them
55/// itself because they belong to the HTTP layer, not the JMAP layer.
56///
57/// # Errors
58///
59/// Returns [`JmapError::not_request()`] if the value does not match the
60/// `JmapRequest` schema. Returns
61/// [`JmapError::limit("maxCallsInRequest")`][JmapError::limit] if the method
62/// call count exceeds `max_calls`.
63pub fn parse_request(body: Value, max_calls: usize) -> Result<JmapRequest, JmapError> {
64 let req: JmapRequest = serde_json::from_value(body).map_err(|_| JmapError::not_request())?;
65
66 if req.method_calls.len() > max_calls {
67 return Err(JmapError::limit("maxCallsInRequest"));
68 }
69
70 Ok(req)
71}
72
73/// Validate that every capability URI in `req.using` is in the `known` set.
74///
75/// RFC 8620 §3.3 requires the server to return an `unknownCapability` error
76/// (HTTP 400) if the request declares a capability the server does not support.
77/// This library cannot enforce that check because it has no knowledge of which
78/// capabilities a given deployment supports — that is the caller's
79/// responsibility.
80///
81/// Call this immediately after [`parse_request`] and map any `Err` to an HTTP
82/// 400 response using [`crate::request_error`].
83///
84/// # Errors
85///
86/// Returns [`JmapError::unknown_capability_with_detail`] for the first URI in
87/// `req.using` that is not present in `known`. If all URIs are known,
88/// returns `Ok(())`.
89///
90/// # Example
91///
92/// ```rust
93/// # use jmap_server::check_known_capabilities;
94/// # use jmap_types::JmapRequest;
95/// let req = JmapRequest::new(
96/// vec!["urn:ietf:params:jmap:core".into()],
97/// vec![],
98/// None,
99/// );
100/// let known = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
101/// check_known_capabilities(&req, known).expect("all URIs in known — Ok(()) expected (doctest)");
102/// ```
103pub fn check_known_capabilities<S: AsRef<str>>(
104 req: &JmapRequest,
105 known: &[S],
106) -> Result<(), JmapError> {
107 for uri in &req.using {
108 if !known.iter().any(|k| k.as_ref() == uri.as_str()) {
109 return Err(JmapError::unknown_capability_with_detail(uri));
110 }
111 }
112 Ok(())
113}
114
115/// Resolve all `#key` ResultReference fields in `args` against `prior_responses`.
116///
117/// For every key in `args` that starts with `#`:
118/// 1. Parse the value as a [`ResultReference`].
119/// 2. Find the prior response whose call-id matches `rr.result_of` (index 2 of tuple).
120/// 3. Verify `rr.name` matches the method name of that response (index 0 of tuple).
121/// 4. Apply `rr.path` as an RFC 6901 JSON Pointer (with RFC 8620 §3.7 `*` extension)
122/// to the response args (index 1 of tuple).
123/// 5. Collect `(plain_key, resolved_value)` pairs.
124///
125/// This is two-phase atomic: `args` is not modified at all unless every
126/// `#key` resolves successfully. If any resolution fails, `args` is returned
127/// unchanged and an error is returned.
128///
129/// `prior_responses` entries are `(method_name, response_args, call_id)` — same
130/// layout as [`Invocation`].
131///
132/// # Why two-phase? (bd:JMAP-jfia.12 decision record)
133///
134/// A future contributor will reasonably suggest "this is one extra
135/// pass over the keys — just resolve-and-mutate inline, that's
136/// simpler". That suggestion is **WRONG**. The two-phase structure
137/// is load-bearing:
138///
139/// 1. RFC 8620 §3.7 semantics are atomic: if ANY `ResultReference`
140/// fails to resolve, the entire method call gets
141/// `invalidResultReference`. A partial-mutation would leave a
142/// malformed `args` object that handlers could observe.
143/// 2. `args` is taken by `&mut`, so it remains observable to the
144/// caller after the function returns `Err`. A test that checks
145/// `args` contents on the error path would see a partial state
146/// under inline-mutation.
147/// 3. The test `parse::tests::resolve_args_atomic_on_partial_failure`
148/// directly encodes this contract ("args must be completely
149/// unchanged on error"). Without two-phase, that test would
150/// fail.
151///
152/// The cost is one `Vec<(ref_key, plain_key, resolved_value)>`
153/// allocation per call; that cost is the price of the atomicity
154/// contract.
155pub fn resolve_args(args: &mut Value, prior_responses: &[Invocation]) -> Result<(), JmapError> {
156 let Some(obj) = args.as_object_mut() else {
157 return Ok(()); // non-object args cannot contain #-key references
158 };
159
160 // Collect (#key, value) pairs up front; cannot borrow obj mutably while iterating.
161 // obj.len() is an upper bound (not all keys need the # prefix).
162 let mut ref_pairs: Vec<(String, Value)> = Vec::with_capacity(obj.len());
163 ref_pairs.extend(
164 obj.iter()
165 .filter(|(k, _)| k.starts_with('#'))
166 .map(|(k, v)| (k.clone(), v.clone())),
167 );
168
169 if ref_pairs.is_empty() {
170 return Ok(());
171 }
172
173 // Phase 1: resolve every reference read-only; args are not touched yet.
174 // If any step fails, return the error immediately without modifying args.
175 let mut resolutions: Vec<(String, String, Value)> = Vec::with_capacity(ref_pairs.len());
176
177 for (ref_key, ref_value) in ref_pairs {
178 let plain_key = ref_key[1..].to_owned();
179
180 // Parse the value as a ResultReference.
181 let rr: ResultReference = serde_json::from_value(ref_value).map_err(|e| {
182 JmapError::invalid_arguments(format!("invalid ResultReference for #{plain_key}: {e}"))
183 })?;
184
185 // Find the prior response by call-id (index 2 of the Invocation tuple).
186 let (prior_method, prior_value) = prior_responses
187 .iter()
188 .find(|(_, _, call_id)| call_id == &rr.result_of)
189 .map(|(method, value, _)| (method.as_str(), value))
190 .ok_or_else(JmapError::invalid_result_reference)?;
191
192 // Verify the name field matches the method name (RFC 8620 §3.7).
193 if rr.name != prior_method {
194 return Err(JmapError::invalid_result_reference());
195 }
196
197 // Apply the RFC 6901 JSON Pointer path with RFC 8620 §3.7 `*` wildcard.
198 let resolved = json_pointer_ext(prior_value, &rr.path)
199 .ok_or_else(JmapError::invalid_result_reference)?;
200
201 // Check for key conflict: plain_key must not already exist in args.
202 if obj.contains_key(&plain_key) {
203 return Err(JmapError::invalid_arguments(format!(
204 "argument key conflict: '{}' and '#{}' both present",
205 plain_key, plain_key
206 )));
207 }
208
209 resolutions.push((ref_key, plain_key, resolved));
210 }
211
212 // Phase 2: all resolutions succeeded — apply mutations atomically.
213 for (ref_key, plain_key, resolved) in resolutions {
214 obj.remove(&ref_key);
215 obj.insert(plain_key, resolved);
216 }
217
218 Ok(())
219}
220
221/// Maximum recursion depth for JSON Pointer resolution.
222///
223/// `json_pointer_ext` walks one token of the path per recursive call. A
224/// client-supplied ResultReference path can specify arbitrary depth; without
225/// a cap, an attacker can force unbounded recursion and crash the dispatcher
226/// worker via stack overflow (bd:JMAP-sc1b.95).
227///
228/// 32 levels comfortably exceeds any legitimate JMAP ResultReference shape
229/// (the deepest standard JMAP response — `Email/get` with nested
230/// `bodyStructure` — tops out around 6 levels), while keeping per-request
231/// stack use bounded.
232const MAX_JSON_POINTER_DEPTH: usize = 32;
233
234/// Apply a path to a JSON value, supporting the RFC 8620 §3.7 `*` wildcard extension.
235///
236/// This is RFC 6901 JSON Pointer extended with `*` as an array-map operator.
237/// When the current value is an array and the token is `*`, the remaining tokens
238/// are applied to each element; array results are flattened into the output.
239///
240/// Returns `None` if the path is malformed, the structure doesn't match, or
241/// the path exceeds [`MAX_JSON_POINTER_DEPTH`] tokens. The depth cap exists
242/// to bound stack use on adversarial input (bd:JMAP-sc1b.95).
243fn json_pointer_ext(value: &Value, path: &str) -> Option<Value> {
244 json_pointer_ext_inner(value, path, 0)
245}
246
247fn json_pointer_ext_inner(value: &Value, path: &str, depth: usize) -> Option<Value> {
248 if depth > MAX_JSON_POINTER_DEPTH {
249 // Reject deep pointers rather than walking them — the call site
250 // treats `None` as "resolution failed", which surfaces as a
251 // ResultReference error per RFC 8620 §3.7 and is the same
252 // behaviour the dispatcher already produces for any malformed path.
253 return None;
254 }
255 if path.is_empty() {
256 return Some(value.clone());
257 }
258 // bd:JMAP-jfia.34 — strip_prefix communicates the prefix check at
259 // the type level (Option<&str>) and avoids the byte-index slicing
260 // that would silently break if the prefix character ever changed
261 // to a multi-byte char.
262 let after_slash = path.strip_prefix('/')?;
263
264 // Split off the first token.
265 let (token, remaining) = match after_slash.find('/') {
266 Some(pos) => (&after_slash[..pos], &after_slash[pos..]),
267 None => (after_slash, ""),
268 };
269
270 if token == "*" {
271 // RFC 8620 §3.7 wildcard: map over array, flatten array results.
272 let arr = value.as_array()?;
273 let mut result: Vec<Value> = Vec::new();
274 for item in arr {
275 match json_pointer_ext_inner(item, remaining, depth + 1) {
276 Some(Value::Array(inner)) => result.extend(inner),
277 Some(other) => result.push(other),
278 None => return None, // any failure = whole resolution fails
279 }
280 }
281 Some(Value::Array(result))
282 } else {
283 // RFC 6901: unescape ~1 → /, ~0 → ~ (in that order).
284 // Skip allocation when the token contains no ~ characters (common case).
285 let key: std::borrow::Cow<str> = if token.contains('~') {
286 token.replace("~1", "/").replace("~0", "~").into()
287 } else {
288 token.into()
289 };
290 let next = match value {
291 Value::Object(obj) => obj.get(key.as_ref())?,
292 Value::Array(arr) => {
293 // RFC 6901 §4: leading zeros are not allowed in array index tokens.
294 if key.len() > 1 && key.starts_with('0') {
295 return None;
296 }
297 let idx: usize = key.parse().ok()?;
298 arr.get(idx)?
299 }
300 _ => return None,
301 };
302 json_pointer_ext_inner(next, remaining, depth + 1)
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use serde_json::json;
310
311 // Oracle: RFC 8620 §3 (request format), §7.1 (error type strings).
312
313 #[test]
314 fn parse_request_valid() {
315 let body = json!({
316 "using": ["urn:ietf:params:jmap:core"],
317 "methodCalls": [
318 ["Foo/get", {"accountId": "a1"}, "0"]
319 ]
320 });
321 let req = parse_request(body, 16).expect("valid request must parse");
322 assert_eq!(req.using, vec!["urn:ietf:params:jmap:core"]);
323 assert_eq!(req.method_calls.len(), 1);
324 }
325
326 // Oracle: jmap-test-suite Q4 / error-empty-using — empty using[] must be
327 // accepted by parse_request; the dispatcher returns unknownMethod per call.
328 #[test]
329 fn parse_request_empty_using_is_ok() {
330 let body = json!({
331 "using": [],
332 "methodCalls": []
333 });
334 parse_request(body, 16)
335 .expect("empty using must be accepted — unknownMethod is dispatcher's job");
336 }
337
338 #[test]
339 fn parse_request_too_many_calls() {
340 let call = json!(["Foo/get", {}, "0"]);
341 let calls: Vec<_> = (0..5).map(|_| call.clone()).collect();
342 let body = json!({
343 "using": ["urn:ietf:params:jmap:core"],
344 "methodCalls": calls
345 });
346 let err = parse_request(body, 4).unwrap_err();
347 assert_eq!(
348 err.error_type, "limit",
349 "exceeding maxCallsInRequest must return limit per RFC 8620 §3.6.1"
350 );
351 }
352
353 #[test]
354 fn parse_request_at_max_calls_is_ok() {
355 let call = json!(["Foo/get", {}, "0"]);
356 let calls: Vec<_> = (0..4).map(|_| call.clone()).collect();
357 let body = json!({
358 "using": ["urn:ietf:params:jmap:core"],
359 "methodCalls": calls
360 });
361 parse_request(body, 4).expect("exactly max_calls must be accepted");
362 }
363
364 #[test]
365 fn parse_request_malformed_body() {
366 let body = json!("not an object");
367 let err = parse_request(body, 16).unwrap_err();
368 assert_eq!(
369 err.error_type, "notRequest",
370 "malformed body does not match Request type — must be notRequest per RFC 8620 §3.6.1"
371 );
372 }
373
374 // Oracle: RFC 8620 §3.7 — #ids resolves to prior response's value at path.
375 #[test]
376 fn resolve_args_basic() {
377 let prior = vec![(
378 "Foo/get".to_owned(),
379 json!({"list": [{"id": "x1"}], "state": "s0"}),
380 "c0".to_owned(),
381 )];
382 let mut args = json!({
383 "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/list/0/id"}
384 });
385 resolve_args(&mut args, &prior).expect("must resolve");
386 assert_eq!(args, json!({"ids": "x1"}));
387 }
388
389 // Oracle: RFC 8620 §3.7 — unknown resultOf → invalidResultReference.
390 #[test]
391 fn resolve_args_unknown_result_of() {
392 let prior: Vec<Invocation> = vec![];
393 let mut args = json!({
394 "#ids": {"resultOf": "missing", "name": "Foo/get", "path": "/ids"}
395 });
396 let original = args.clone();
397 let err = resolve_args(&mut args, &prior).unwrap_err();
398 assert_eq!(err.error_type, "invalidResultReference");
399 // args must be unchanged on error (atomicity).
400 assert_eq!(args, original);
401 }
402
403 // Oracle: RFC 8620 §3.7 — name mismatch → invalidResultReference.
404 #[test]
405 fn resolve_args_name_mismatch() {
406 let prior = vec![("Foo/get".to_owned(), json!({"ids": ["a"]}), "c0".to_owned())];
407 let mut args = json!({
408 "#ids": {"resultOf": "c0", "name": "Bar/get", "path": "/ids"}
409 });
410 let original = args.clone();
411 let err = resolve_args(&mut args, &prior).unwrap_err();
412 assert_eq!(err.error_type, "invalidResultReference");
413 assert_eq!(args, original);
414 }
415
416 // Oracle: RFC 8620 §3.7 — path not found → invalidResultReference.
417 #[test]
418 fn resolve_args_path_not_found() {
419 let prior = vec![("Foo/get".to_owned(), json!({"ids": ["a"]}), "c0".to_owned())];
420 let mut args = json!({
421 "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/nonexistent"}
422 });
423 let original = args.clone();
424 let err = resolve_args(&mut args, &prior).unwrap_err();
425 assert_eq!(err.error_type, "invalidResultReference");
426 assert_eq!(args, original);
427 }
428
429 // Oracle: atomicity — if one of two refs fails, args must be completely unchanged.
430 #[test]
431 fn resolve_args_atomic_on_partial_failure() {
432 let prior = vec![(
433 "Foo/get".to_owned(),
434 json!({"ids": ["a", "b"]}),
435 "c0".to_owned(),
436 )];
437 // #ids is valid; #properties references a non-existent call.
438 let mut args = json!({
439 "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/ids"},
440 "#properties": {"resultOf": "missing", "name": "Foo/get", "path": "/props"}
441 });
442 let original = args.clone();
443 let err = resolve_args(&mut args, &prior).unwrap_err();
444 assert_eq!(err.error_type, "invalidResultReference");
445 assert_eq!(args, original);
446 }
447
448 // Oracle: non-object args pass through unchanged.
449 #[test]
450 fn resolve_args_non_object_passthrough() {
451 let prior: Vec<Invocation> = vec![];
452 let mut args = json!("not-an-object");
453 resolve_args(&mut args, &prior).expect("non-object must not error");
454 assert_eq!(args, json!("not-an-object"));
455 }
456
457 // Oracle: no #-prefixed keys → args unchanged, Ok returned.
458 #[test]
459 fn resolve_args_no_ref_keys() {
460 let prior: Vec<Invocation> = vec![];
461 let mut args = json!({"ids": ["a", "b"]});
462 resolve_args(&mut args, &prior).expect("no ref keys must not error");
463 assert_eq!(args, json!({"ids": ["a", "b"]}));
464 }
465
466 // Oracle: kith-jmap deviation — unknown capability URIs are silently accepted
467 // at this layer; capability checking is the caller's responsibility.
468 #[test]
469 fn parse_request_unknown_capability_accepted() {
470 let body = json!({
471 "using": ["urn:ietf:params:jmap:core", "urn:example:unknown"],
472 "methodCalls": [
473 ["Foo/get", {}, "0"]
474 ]
475 });
476 let req = parse_request(body, 16).expect("unknown capability must be accepted");
477 assert_eq!(req.using.len(), 2);
478 }
479
480 // Oracle: RFC 8620 §3.3 — `using` is valid with any non-empty array.
481 #[test]
482 fn parse_request_core_only_accepted() {
483 let body = json!({
484 "using": ["urn:ietf:params:jmap:core"],
485 "methodCalls": [
486 ["Foo/get", {}, "0"]
487 ]
488 });
489 parse_request(body, 16).expect("core-only using must be accepted");
490 }
491
492 // Oracle: boundary condition — max_calls=0, one call → limit (RFC 8620 §3.6.1).
493 #[test]
494 fn parse_request_zero_max_calls_rejects_any_call() {
495 let body = json!({
496 "using": ["urn:ietf:params:jmap:core"],
497 "methodCalls": [
498 ["Foo/get", {}, "0"]
499 ]
500 });
501 let err = parse_request(body, 0).unwrap_err();
502 assert_eq!(
503 err.error_type, "limit",
504 "zero max_calls means any call exceeds limit — must be limit per RFC 8620 §3.6.1"
505 );
506 }
507
508 // Oracle: RFC 8620 §3.7 — multiple #-keys in the same args object all resolve
509 // independently against the same prior response.
510 #[test]
511 fn resolve_args_multiple_refs_all_resolve() {
512 let prior = vec![(
513 "Foo/get".to_owned(),
514 json!({"list": [{"id": "x1"}], "state": "s0"}),
515 "c0".to_owned(),
516 )];
517 let mut args = json!({
518 "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/list"},
519 "#state": {"resultOf": "c0", "name": "Foo/get", "path": "/state"}
520 });
521 resolve_args(&mut args, &prior).expect("both refs must resolve");
522 // No #-keys must remain.
523 let obj = args.as_object().expect("must still be an object");
524 assert!(!obj.contains_key("#ids"), "#ids must be removed");
525 assert!(!obj.contains_key("#state"), "#state must be removed");
526 assert_eq!(args["ids"], json!([{"id": "x1"}]));
527 assert_eq!(args["state"], json!("s0"));
528 }
529
530 // Oracle: RFC 8620 §3.7 — having both `key` and `#key` in the same args
531 // object is an error (key conflict).
532 #[test]
533 fn resolve_args_key_conflict_is_error() {
534 let prior = vec![("Foo/get".to_owned(), json!({"ids": ["a"]}), "c0".to_owned())];
535 let mut args = json!({
536 "ids": "existing",
537 "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/ids"}
538 });
539 let original = args.clone();
540 let err = resolve_args(&mut args, &prior).unwrap_err();
541 assert_eq!(err.error_type, "invalidArguments");
542 // args must be completely unchanged on error (atomicity).
543 assert_eq!(args, original);
544 }
545
546 // Oracle: RFC 8620 §3.7 — `#key` value must be a valid ResultReference object;
547 // a non-object value is rejected with invalidArguments.
548 #[test]
549 fn resolve_args_invalid_ref_value_is_error() {
550 let prior: Vec<Invocation> = vec![];
551 let mut args = json!({"#ids": "not-an-object"});
552 let original = args.clone();
553 let err = resolve_args(&mut args, &prior).unwrap_err();
554 assert_eq!(err.error_type, "invalidArguments");
555 assert_eq!(args, original);
556 }
557
558 // Oracle: RFC 8620 §3.7, JSON Pointer RFC 6901 §4 — path pointing to an
559 // array resolves to that array value.
560 #[test]
561 fn resolve_args_array_path_resolves_to_array() {
562 let prior = vec![(
563 "List/query".to_owned(),
564 json!({"ids": ["a", "b", "c"]}),
565 "c0".to_owned(),
566 )];
567 let mut args = json!({
568 "#ids": {"resultOf": "c0", "name": "List/query", "path": "/ids"}
569 });
570 resolve_args(&mut args, &prior).expect("array path must resolve");
571 assert_eq!(args, json!({"ids": ["a", "b", "c"]}));
572 }
573
574 // Oracle: RFC 8620 §3.7, JSON Pointer RFC 6901 §4 — multi-segment path
575 // drills into nested structures.
576 #[test]
577 fn resolve_args_nested_path_resolves() {
578 let prior = vec![(
579 "Foo/get".to_owned(),
580 json!({"list": [{"id": "deep1"}]}),
581 "c0".to_owned(),
582 )];
583 let mut args = json!({
584 "#id": {"resultOf": "c0", "name": "Foo/get", "path": "/list/0/id"}
585 });
586 resolve_args(&mut args, &prior).expect("nested path must resolve");
587 assert_eq!(args, json!({"id": "deep1"}));
588 }
589
590 // Oracle: RFC 6901 §7 — an array index that is out of bounds causes the
591 // pointer to fail, which maps to invalidResultReference.
592 #[test]
593 fn resolve_args_path_array_oob_is_error() {
594 let prior = vec![("Foo/get".to_owned(), json!({"ids": ["a"]}), "c0".to_owned())];
595 let mut args = json!({
596 "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/ids/5"}
597 });
598 let original = args.clone();
599 let err = resolve_args(&mut args, &prior).unwrap_err();
600 assert_eq!(err.error_type, "invalidResultReference");
601 assert_eq!(args, original);
602 }
603
604 // Oracle: RFC 6901 §4 — array index tokens with a leading zero (other than
605 // the single character "0") MUST be rejected as invalid.
606 #[test]
607 fn resolve_args_path_leading_zero_index_is_error() {
608 let prior = vec![(
609 "Foo/get".to_owned(),
610 json!({"ids": ["a", "b"]}),
611 "c0".to_owned(),
612 )];
613 let mut args = json!({
614 "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/ids/01"}
615 });
616 let original = args.clone();
617 let err = resolve_args(&mut args, &prior).unwrap_err();
618 assert_eq!(err.error_type, "invalidResultReference");
619 assert_eq!(args, original, "args must be unchanged on error");
620 }
621
622 // Oracle: RFC 6901 §3 — `~1` is the escape sequence for `/` and `~0` for `~`
623 // in JSON Pointer tokens.
624 #[test]
625 fn resolve_args_path_tilde_escaping() {
626 let prior = vec![(
627 "Foo/get".to_owned(),
628 json!({"a/b": "slash-value"}),
629 "c0".to_owned(),
630 )];
631 let mut args = json!({
632 "#val": {"resultOf": "c0", "name": "Foo/get", "path": "/a~1b"}
633 });
634 resolve_args(&mut args, &prior).expect("tilde-escaped path must resolve");
635 assert_eq!(args, json!({"val": "slash-value"}));
636 }
637
638 // Oracle: RFC 6901 §3 — `~0` is the escape sequence for `~`.
639 // Replacement order must be ~1 first then ~0; otherwise `~01` would
640 // incorrectly become `/` instead of `~1`.
641 #[test]
642 fn resolve_args_path_tilde0_escaping() {
643 let prior = vec![(
644 "Foo/get".to_owned(),
645 json!({"a~b": "tilde-value"}),
646 "c0".to_owned(),
647 )];
648 let mut args = json!({
649 "#val": {"resultOf": "c0", "name": "Foo/get", "path": "/a~0b"}
650 });
651 resolve_args(&mut args, &prior).expect("~0-escaped path must resolve");
652 assert_eq!(args, json!({"val": "tilde-value"}));
653 }
654
655 // Oracle: RFC 6901 §3 — `~01` must decode to the literal string `~1`,
656 // NOT to `/`. ~1 is replaced first (yielding `~1`), then ~0 on what
657 // remains would replace `~0` — but after the first pass `~01` → `~1`
658 // there is no `~0` left; the result is `/`.
659 // Wait — `~01`: replace ~1 first: `~01` has no `~1` at position 0 (it's `~0` then `1`).
660 // So `~01` → replace ~1 → no match → `~01` → replace ~0 → `~` → result: `~1`.
661 // i.e. `~01` decodes to `~1` (literal tilde followed by 1), NOT to `/`.
662 #[test]
663 fn resolve_args_path_tilde01_decodes_to_tilde1() {
664 let prior = vec![(
665 "Foo/get".to_owned(),
666 json!({"~1": "tilde-one-value"}),
667 "c0".to_owned(),
668 )];
669 let mut args = json!({
670 "#val": {"resultOf": "c0", "name": "Foo/get", "path": "/~01"}
671 });
672 resolve_args(&mut args, &prior).expect("~01 must decode to literal key ~1");
673 assert_eq!(args, json!({"val": "tilde-one-value"}));
674 }
675
676 // Oracle: RFC 8620 §3.7 — /list/*/threadId maps threadId from each list element.
677 #[test]
678 fn resolve_args_wildcard_maps_over_array() {
679 let prior = vec![(
680 "Thread/get".to_owned(),
681 json!({
682 "list": [{"threadId": "t1"}, {"threadId": "t2"}]
683 }),
684 "c0".to_owned(),
685 )];
686 let mut args =
687 json!({"#ids": {"resultOf": "c0", "name": "Thread/get", "path": "/list/*/threadId"}});
688 resolve_args(&mut args, &prior).expect("wildcard must resolve");
689 assert_eq!(args, json!({"ids": ["t1", "t2"]}));
690 }
691
692 // Oracle: Fastmail jmap-samples top-ten.py uses path '/ids/*' where `ids` is
693 // a flat string array. RFC 8620 §3.7 wildcard with empty `remaining` path
694 // must return a copy of the source array — each element maps to itself.
695 #[test]
696 fn resolve_args_wildcard_over_flat_string_array() {
697 // Simulates: Email/query → ids:["a","b","c"], then Email/get with
698 // #ids:{resultOf:"c0", name:"Email/query", path:"/ids/*"}.
699 let prior = vec![(
700 "Email/query".to_owned(),
701 json!({ "ids": ["a", "b", "c"] }),
702 "c0".to_owned(),
703 )];
704 let mut args = json!({"#ids": {"resultOf": "c0", "name": "Email/query", "path": "/ids/*"}});
705 resolve_args(&mut args, &prior).expect("flat-array wildcard must resolve");
706 // * over a flat string array with empty remaining path returns the same array.
707 assert_eq!(args, json!({"ids": ["a", "b", "c"]}));
708 }
709
710 // Oracle: RFC 8620 §3.7 — when wildcard result is an array, it is flattened.
711 #[test]
712 fn resolve_args_wildcard_flattens_array_results() {
713 let prior = vec![(
714 "Email/get".to_owned(),
715 json!({
716 "list": [{"emailIds": ["e1", "e2"]}, {"emailIds": ["e3"]}]
717 }),
718 "c0".to_owned(),
719 )];
720 let mut args =
721 json!({"#ids": {"resultOf": "c0", "name": "Email/get", "path": "/list/*/emailIds"}});
722 resolve_args(&mut args, &prior).expect("wildcard flatten must resolve");
723 assert_eq!(args, json!({"ids": ["e1", "e2", "e3"]}));
724 }
725
726 // Oracle: RFC 6901 §4 — basic path navigation.
727 #[test]
728 fn json_pointer_ext_plain_path() {
729 let v = json!({"a": {"b": 42}});
730 assert_eq!(json_pointer_ext(&v, "/a/b"), Some(json!(42)));
731 }
732
733 // Oracle: RFC 6901 §4 — empty path returns whole document.
734 #[test]
735 fn json_pointer_ext_empty_path_returns_root() {
736 let v = json!({"x": 1});
737 assert_eq!(json_pointer_ext(&v, ""), Some(v.clone()));
738 }
739
740 // Oracle: bd:JMAP-sc1b.95 — a path longer than MAX_JSON_POINTER_DEPTH
741 // tokens must be rejected as `None` (resolution failure) rather than
742 // walked recursively. The depth cap is a stack-DoS mitigation; the test
743 // builds a synthetic deep object and a matching deep path to confirm
744 // the cap fires before any real-world JMAP request shape would.
745 //
746 // The test does NOT use the code under test as its own oracle: it
747 // hand-builds a 1000-deep `{ "a": { "a": ... } }` document and a
748 // matching `/a/a/a/...` path, both via tight loops in the test body.
749 // The expected outcome (`None`) is derived from the documented depth
750 // cap, not from running the function.
751 #[test]
752 fn json_pointer_ext_rejects_deep_path() {
753 const DEPTH: usize = 1000;
754 // Build a nested object 1000 levels deep.
755 let mut value = json!(42);
756 for _ in 0..DEPTH {
757 value = json!({ "a": value });
758 }
759 // Build the matching pointer: "/a" repeated DEPTH times.
760 let path: String = "/a".repeat(DEPTH);
761 assert_eq!(
762 json_pointer_ext(&value, &path),
763 None,
764 "pointer with {DEPTH} tokens must be rejected by the depth cap"
765 );
766 }
767
768 // Oracle: paths up to MAX_JSON_POINTER_DEPTH tokens still resolve. This
769 // is the positive control for the depth cap: it confirms the cap fires
770 // strictly at the boundary, not for paths legitimate JMAP integrations
771 // will produce.
772 #[test]
773 fn json_pointer_ext_accepts_path_within_depth_cap() {
774 // Build an object of exactly MAX_JSON_POINTER_DEPTH-1 levels so the
775 // resolution succeeds (depth-1 increments fit within the cap).
776 const LEN: usize = MAX_JSON_POINTER_DEPTH - 1;
777 let mut value = json!("leaf");
778 for _ in 0..LEN {
779 value = json!({ "a": value });
780 }
781 let path: String = "/a".repeat(LEN);
782 assert_eq!(
783 json_pointer_ext(&value, &path),
784 Some(json!("leaf")),
785 "pointer with {LEN} tokens must still resolve under the depth cap"
786 );
787 }
788
789 // -----------------------------------------------------------------------
790 // check_known_capabilities
791 // -----------------------------------------------------------------------
792
793 // Oracle: RFC 8620 §3.3 — unknown capability URI returns unknownCapability.
794 #[test]
795 fn check_known_capabilities_unknown_uri_is_error() {
796 let req = JmapRequest::new(
797 vec![
798 "urn:ietf:params:jmap:core".into(),
799 "urn:example:unknown".into(),
800 ],
801 vec![],
802 None,
803 );
804 let known = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
805 let err = check_known_capabilities(&req, known).unwrap_err();
806 assert_eq!(
807 err.error_type, "unknownCapability",
808 "unrecognised URI must produce unknownCapability per RFC 8620 §3.3"
809 );
810 assert_eq!(
811 err.description.as_deref(),
812 Some("urn:example:unknown"),
813 "unknownCapability error must name the unrecognised URI in description"
814 );
815 }
816
817 // Oracle: RFC 8620 §3.3 — all known URIs accepted.
818 #[test]
819 fn check_known_capabilities_all_known_is_ok() {
820 let req = JmapRequest::new(
821 vec![
822 "urn:ietf:params:jmap:core".into(),
823 "urn:ietf:params:jmap:mail".into(),
824 ],
825 vec![],
826 None,
827 );
828 let known = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
829 check_known_capabilities(&req, known).expect("all URIs are in known — must return Ok");
830 }
831
832 // Oracle: boundary — empty using[] with any known set returns Ok.
833 #[test]
834 fn check_known_capabilities_empty_using_is_ok() {
835 let req = JmapRequest::new(vec![], vec![], None);
836 let known = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
837 check_known_capabilities(&req, known)
838 .expect("empty using[] must return Ok even when known is non-empty");
839 }
840}