Skip to main content

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`].
131pub fn resolve_args(args: &mut Value, prior_responses: &[Invocation]) -> Result<(), JmapError> {
132    let Some(obj) = args.as_object_mut() else {
133        return Ok(()); // non-object args cannot contain #-key references
134    };
135
136    // Collect (#key, value) pairs up front; cannot borrow obj mutably while iterating.
137    // obj.len() is an upper bound (not all keys need the # prefix).
138    let mut ref_pairs: Vec<(String, Value)> = Vec::with_capacity(obj.len());
139    ref_pairs.extend(
140        obj.iter()
141            .filter(|(k, _)| k.starts_with('#'))
142            .map(|(k, v)| (k.clone(), v.clone())),
143    );
144
145    if ref_pairs.is_empty() {
146        return Ok(());
147    }
148
149    // Phase 1: resolve every reference read-only; args are not touched yet.
150    // If any step fails, return the error immediately without modifying args.
151    let mut resolutions: Vec<(String, String, Value)> = Vec::with_capacity(ref_pairs.len());
152
153    for (ref_key, ref_value) in ref_pairs {
154        let plain_key = ref_key[1..].to_owned();
155
156        // Parse the value as a ResultReference.
157        let rr: ResultReference = serde_json::from_value(ref_value).map_err(|e| {
158            JmapError::invalid_arguments(format!("invalid ResultReference for #{plain_key}: {e}"))
159        })?;
160
161        // Find the prior response by call-id (index 2 of the Invocation tuple).
162        let (prior_method, prior_value) = prior_responses
163            .iter()
164            .find(|(_, _, call_id)| call_id == &rr.result_of)
165            .map(|(method, value, _)| (method.as_str(), value))
166            .ok_or_else(JmapError::invalid_result_reference)?;
167
168        // Verify the name field matches the method name (RFC 8620 §3.7).
169        if rr.name != prior_method {
170            return Err(JmapError::invalid_result_reference());
171        }
172
173        // Apply the RFC 6901 JSON Pointer path with RFC 8620 §3.7 `*` wildcard.
174        let resolved = json_pointer_ext(prior_value, &rr.path)
175            .ok_or_else(JmapError::invalid_result_reference)?;
176
177        // Check for key conflict: plain_key must not already exist in args.
178        if obj.contains_key(&plain_key) {
179            return Err(JmapError::invalid_arguments(format!(
180                "argument key conflict: '{}' and '#{}' both present",
181                plain_key, plain_key
182            )));
183        }
184
185        resolutions.push((ref_key, plain_key, resolved));
186    }
187
188    // Phase 2: all resolutions succeeded — apply mutations atomically.
189    for (ref_key, plain_key, resolved) in resolutions {
190        obj.remove(&ref_key);
191        obj.insert(plain_key, resolved);
192    }
193
194    Ok(())
195}
196
197/// Maximum recursion depth for JSON Pointer resolution.
198///
199/// `json_pointer_ext` walks one token of the path per recursive call. A
200/// client-supplied ResultReference path can specify arbitrary depth; without
201/// a cap, an attacker can force unbounded recursion and crash the dispatcher
202/// worker via stack overflow (bd:JMAP-sc1b.95).
203///
204/// 32 levels comfortably exceeds any legitimate JMAP ResultReference shape
205/// (the deepest standard JMAP response — `Email/get` with nested
206/// `bodyStructure` — tops out around 6 levels), while keeping per-request
207/// stack use bounded.
208const MAX_JSON_POINTER_DEPTH: usize = 32;
209
210/// Apply a path to a JSON value, supporting the RFC 8620 §3.7 `*` wildcard extension.
211///
212/// This is RFC 6901 JSON Pointer extended with `*` as an array-map operator.
213/// When the current value is an array and the token is `*`, the remaining tokens
214/// are applied to each element; array results are flattened into the output.
215///
216/// Returns `None` if the path is malformed, the structure doesn't match, or
217/// the path exceeds [`MAX_JSON_POINTER_DEPTH`] tokens. The depth cap exists
218/// to bound stack use on adversarial input (bd:JMAP-sc1b.95).
219fn json_pointer_ext(value: &Value, path: &str) -> Option<Value> {
220    json_pointer_ext_inner(value, path, 0)
221}
222
223fn json_pointer_ext_inner(value: &Value, path: &str, depth: usize) -> Option<Value> {
224    if depth > MAX_JSON_POINTER_DEPTH {
225        // Reject deep pointers rather than walking them — the call site
226        // treats `None` as "resolution failed", which surfaces as a
227        // ResultReference error per RFC 8620 §3.7 and is the same
228        // behaviour the dispatcher already produces for any malformed path.
229        return None;
230    }
231    if path.is_empty() {
232        return Some(value.clone());
233    }
234    if !path.starts_with('/') {
235        return None;
236    }
237
238    // Split off the first token.
239    let after_slash = &path[1..];
240    let (token, remaining) = match after_slash.find('/') {
241        Some(pos) => (&after_slash[..pos], &after_slash[pos..]),
242        None => (after_slash, ""),
243    };
244
245    if token == "*" {
246        // RFC 8620 §3.7 wildcard: map over array, flatten array results.
247        let arr = value.as_array()?;
248        let mut result: Vec<Value> = Vec::new();
249        for item in arr {
250            match json_pointer_ext_inner(item, remaining, depth + 1) {
251                Some(Value::Array(inner)) => result.extend(inner),
252                Some(other) => result.push(other),
253                None => return None, // any failure = whole resolution fails
254            }
255        }
256        Some(Value::Array(result))
257    } else {
258        // RFC 6901: unescape ~1 → /, ~0 → ~ (in that order).
259        // Skip allocation when the token contains no ~ characters (common case).
260        let key: std::borrow::Cow<str> = if token.contains('~') {
261            token.replace("~1", "/").replace("~0", "~").into()
262        } else {
263            token.into()
264        };
265        let next = match value {
266            Value::Object(obj) => obj.get(key.as_ref())?,
267            Value::Array(arr) => {
268                // RFC 6901 §4: leading zeros are not allowed in array index tokens.
269                if key.len() > 1 && key.starts_with('0') {
270                    return None;
271                }
272                let idx: usize = key.parse().ok()?;
273                arr.get(idx)?
274            }
275            _ => return None,
276        };
277        json_pointer_ext_inner(next, remaining, depth + 1)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use serde_json::json;
285
286    // Oracle: RFC 8620 §3 (request format), §7.1 (error type strings).
287
288    #[test]
289    fn parse_request_valid() {
290        let body = json!({
291            "using": ["urn:ietf:params:jmap:core"],
292            "methodCalls": [
293                ["Foo/get", {"accountId": "a1"}, "0"]
294            ]
295        });
296        let req = parse_request(body, 16).expect("valid request must parse");
297        assert_eq!(req.using, vec!["urn:ietf:params:jmap:core"]);
298        assert_eq!(req.method_calls.len(), 1);
299    }
300
301    // Oracle: jmap-test-suite Q4 / error-empty-using — empty using[] must be
302    // accepted by parse_request; the dispatcher returns unknownMethod per call.
303    #[test]
304    fn parse_request_empty_using_is_ok() {
305        let body = json!({
306            "using": [],
307            "methodCalls": []
308        });
309        parse_request(body, 16)
310            .expect("empty using must be accepted — unknownMethod is dispatcher's job");
311    }
312
313    #[test]
314    fn parse_request_too_many_calls() {
315        let call = json!(["Foo/get", {}, "0"]);
316        let calls: Vec<_> = (0..5).map(|_| call.clone()).collect();
317        let body = json!({
318            "using": ["urn:ietf:params:jmap:core"],
319            "methodCalls": calls
320        });
321        let err = parse_request(body, 4).unwrap_err();
322        assert_eq!(
323            err.error_type, "limit",
324            "exceeding maxCallsInRequest must return limit per RFC 8620 §3.6.1"
325        );
326    }
327
328    #[test]
329    fn parse_request_at_max_calls_is_ok() {
330        let call = json!(["Foo/get", {}, "0"]);
331        let calls: Vec<_> = (0..4).map(|_| call.clone()).collect();
332        let body = json!({
333            "using": ["urn:ietf:params:jmap:core"],
334            "methodCalls": calls
335        });
336        parse_request(body, 4).expect("exactly max_calls must be accepted");
337    }
338
339    #[test]
340    fn parse_request_malformed_body() {
341        let body = json!("not an object");
342        let err = parse_request(body, 16).unwrap_err();
343        assert_eq!(
344            err.error_type, "notRequest",
345            "malformed body does not match Request type — must be notRequest per RFC 8620 §3.6.1"
346        );
347    }
348
349    // Oracle: RFC 8620 §3.7 — #ids resolves to prior response's value at path.
350    #[test]
351    fn resolve_args_basic() {
352        let prior = vec![(
353            "Foo/get".to_owned(),
354            json!({"list": [{"id": "x1"}], "state": "s0"}),
355            "c0".to_owned(),
356        )];
357        let mut args = json!({
358            "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/list/0/id"}
359        });
360        resolve_args(&mut args, &prior).expect("must resolve");
361        assert_eq!(args, json!({"ids": "x1"}));
362    }
363
364    // Oracle: RFC 8620 §3.7 — unknown resultOf → invalidResultReference.
365    #[test]
366    fn resolve_args_unknown_result_of() {
367        let prior: Vec<Invocation> = vec![];
368        let mut args = json!({
369            "#ids": {"resultOf": "missing", "name": "Foo/get", "path": "/ids"}
370        });
371        let original = args.clone();
372        let err = resolve_args(&mut args, &prior).unwrap_err();
373        assert_eq!(err.error_type, "invalidResultReference");
374        // args must be unchanged on error (atomicity).
375        assert_eq!(args, original);
376    }
377
378    // Oracle: RFC 8620 §3.7 — name mismatch → invalidResultReference.
379    #[test]
380    fn resolve_args_name_mismatch() {
381        let prior = vec![("Foo/get".to_owned(), json!({"ids": ["a"]}), "c0".to_owned())];
382        let mut args = json!({
383            "#ids": {"resultOf": "c0", "name": "Bar/get", "path": "/ids"}
384        });
385        let original = args.clone();
386        let err = resolve_args(&mut args, &prior).unwrap_err();
387        assert_eq!(err.error_type, "invalidResultReference");
388        assert_eq!(args, original);
389    }
390
391    // Oracle: RFC 8620 §3.7 — path not found → invalidResultReference.
392    #[test]
393    fn resolve_args_path_not_found() {
394        let prior = vec![("Foo/get".to_owned(), json!({"ids": ["a"]}), "c0".to_owned())];
395        let mut args = json!({
396            "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/nonexistent"}
397        });
398        let original = args.clone();
399        let err = resolve_args(&mut args, &prior).unwrap_err();
400        assert_eq!(err.error_type, "invalidResultReference");
401        assert_eq!(args, original);
402    }
403
404    // Oracle: atomicity — if one of two refs fails, args must be completely unchanged.
405    #[test]
406    fn resolve_args_atomic_on_partial_failure() {
407        let prior = vec![(
408            "Foo/get".to_owned(),
409            json!({"ids": ["a", "b"]}),
410            "c0".to_owned(),
411        )];
412        // #ids is valid; #properties references a non-existent call.
413        let mut args = json!({
414            "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/ids"},
415            "#properties": {"resultOf": "missing", "name": "Foo/get", "path": "/props"}
416        });
417        let original = args.clone();
418        let err = resolve_args(&mut args, &prior).unwrap_err();
419        assert_eq!(err.error_type, "invalidResultReference");
420        assert_eq!(args, original);
421    }
422
423    // Oracle: non-object args pass through unchanged.
424    #[test]
425    fn resolve_args_non_object_passthrough() {
426        let prior: Vec<Invocation> = vec![];
427        let mut args = json!("not-an-object");
428        resolve_args(&mut args, &prior).expect("non-object must not error");
429        assert_eq!(args, json!("not-an-object"));
430    }
431
432    // Oracle: no #-prefixed keys → args unchanged, Ok returned.
433    #[test]
434    fn resolve_args_no_ref_keys() {
435        let prior: Vec<Invocation> = vec![];
436        let mut args = json!({"ids": ["a", "b"]});
437        resolve_args(&mut args, &prior).expect("no ref keys must not error");
438        assert_eq!(args, json!({"ids": ["a", "b"]}));
439    }
440
441    // Oracle: kith-jmap deviation — unknown capability URIs are silently accepted
442    // at this layer; capability checking is the caller's responsibility.
443    #[test]
444    fn parse_request_unknown_capability_accepted() {
445        let body = json!({
446            "using": ["urn:ietf:params:jmap:core", "urn:example:unknown"],
447            "methodCalls": [
448                ["Foo/get", {}, "0"]
449            ]
450        });
451        let req = parse_request(body, 16).expect("unknown capability must be accepted");
452        assert_eq!(req.using.len(), 2);
453    }
454
455    // Oracle: RFC 8620 §3.3 — `using` is valid with any non-empty array.
456    #[test]
457    fn parse_request_core_only_accepted() {
458        let body = json!({
459            "using": ["urn:ietf:params:jmap:core"],
460            "methodCalls": [
461                ["Foo/get", {}, "0"]
462            ]
463        });
464        parse_request(body, 16).expect("core-only using must be accepted");
465    }
466
467    // Oracle: boundary condition — max_calls=0, one call → limit (RFC 8620 §3.6.1).
468    #[test]
469    fn parse_request_zero_max_calls_rejects_any_call() {
470        let body = json!({
471            "using": ["urn:ietf:params:jmap:core"],
472            "methodCalls": [
473                ["Foo/get", {}, "0"]
474            ]
475        });
476        let err = parse_request(body, 0).unwrap_err();
477        assert_eq!(
478            err.error_type, "limit",
479            "zero max_calls means any call exceeds limit — must be limit per RFC 8620 §3.6.1"
480        );
481    }
482
483    // Oracle: RFC 8620 §3.7 — multiple #-keys in the same args object all resolve
484    // independently against the same prior response.
485    #[test]
486    fn resolve_args_multiple_refs_all_resolve() {
487        let prior = vec![(
488            "Foo/get".to_owned(),
489            json!({"list": [{"id": "x1"}], "state": "s0"}),
490            "c0".to_owned(),
491        )];
492        let mut args = json!({
493            "#ids":   {"resultOf": "c0", "name": "Foo/get", "path": "/list"},
494            "#state": {"resultOf": "c0", "name": "Foo/get", "path": "/state"}
495        });
496        resolve_args(&mut args, &prior).expect("both refs must resolve");
497        // No #-keys must remain.
498        let obj = args.as_object().expect("must still be an object");
499        assert!(!obj.contains_key("#ids"), "#ids must be removed");
500        assert!(!obj.contains_key("#state"), "#state must be removed");
501        assert_eq!(args["ids"], json!([{"id": "x1"}]));
502        assert_eq!(args["state"], json!("s0"));
503    }
504
505    // Oracle: RFC 8620 §3.7 — having both `key` and `#key` in the same args
506    // object is an error (key conflict).
507    #[test]
508    fn resolve_args_key_conflict_is_error() {
509        let prior = vec![("Foo/get".to_owned(), json!({"ids": ["a"]}), "c0".to_owned())];
510        let mut args = json!({
511            "ids":  "existing",
512            "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/ids"}
513        });
514        let original = args.clone();
515        let err = resolve_args(&mut args, &prior).unwrap_err();
516        assert_eq!(err.error_type, "invalidArguments");
517        // args must be completely unchanged on error (atomicity).
518        assert_eq!(args, original);
519    }
520
521    // Oracle: RFC 8620 §3.7 — `#key` value must be a valid ResultReference object;
522    // a non-object value is rejected with invalidArguments.
523    #[test]
524    fn resolve_args_invalid_ref_value_is_error() {
525        let prior: Vec<Invocation> = vec![];
526        let mut args = json!({"#ids": "not-an-object"});
527        let original = args.clone();
528        let err = resolve_args(&mut args, &prior).unwrap_err();
529        assert_eq!(err.error_type, "invalidArguments");
530        assert_eq!(args, original);
531    }
532
533    // Oracle: RFC 8620 §3.7, JSON Pointer RFC 6901 §4 — path pointing to an
534    // array resolves to that array value.
535    #[test]
536    fn resolve_args_array_path_resolves_to_array() {
537        let prior = vec![(
538            "List/query".to_owned(),
539            json!({"ids": ["a", "b", "c"]}),
540            "c0".to_owned(),
541        )];
542        let mut args = json!({
543            "#ids": {"resultOf": "c0", "name": "List/query", "path": "/ids"}
544        });
545        resolve_args(&mut args, &prior).expect("array path must resolve");
546        assert_eq!(args, json!({"ids": ["a", "b", "c"]}));
547    }
548
549    // Oracle: RFC 8620 §3.7, JSON Pointer RFC 6901 §4 — multi-segment path
550    // drills into nested structures.
551    #[test]
552    fn resolve_args_nested_path_resolves() {
553        let prior = vec![(
554            "Foo/get".to_owned(),
555            json!({"list": [{"id": "deep1"}]}),
556            "c0".to_owned(),
557        )];
558        let mut args = json!({
559            "#id": {"resultOf": "c0", "name": "Foo/get", "path": "/list/0/id"}
560        });
561        resolve_args(&mut args, &prior).expect("nested path must resolve");
562        assert_eq!(args, json!({"id": "deep1"}));
563    }
564
565    // Oracle: RFC 6901 §7 — an array index that is out of bounds causes the
566    // pointer to fail, which maps to invalidResultReference.
567    #[test]
568    fn resolve_args_path_array_oob_is_error() {
569        let prior = vec![("Foo/get".to_owned(), json!({"ids": ["a"]}), "c0".to_owned())];
570        let mut args = json!({
571            "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/ids/5"}
572        });
573        let original = args.clone();
574        let err = resolve_args(&mut args, &prior).unwrap_err();
575        assert_eq!(err.error_type, "invalidResultReference");
576        assert_eq!(args, original);
577    }
578
579    // Oracle: RFC 6901 §4 — array index tokens with a leading zero (other than
580    // the single character "0") MUST be rejected as invalid.
581    #[test]
582    fn resolve_args_path_leading_zero_index_is_error() {
583        let prior = vec![(
584            "Foo/get".to_owned(),
585            json!({"ids": ["a", "b"]}),
586            "c0".to_owned(),
587        )];
588        let mut args = json!({
589            "#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/ids/01"}
590        });
591        let original = args.clone();
592        let err = resolve_args(&mut args, &prior).unwrap_err();
593        assert_eq!(err.error_type, "invalidResultReference");
594        assert_eq!(args, original, "args must be unchanged on error");
595    }
596
597    // Oracle: RFC 6901 §3 — `~1` is the escape sequence for `/` and `~0` for `~`
598    // in JSON Pointer tokens.
599    #[test]
600    fn resolve_args_path_tilde_escaping() {
601        let prior = vec![(
602            "Foo/get".to_owned(),
603            json!({"a/b": "slash-value"}),
604            "c0".to_owned(),
605        )];
606        let mut args = json!({
607            "#val": {"resultOf": "c0", "name": "Foo/get", "path": "/a~1b"}
608        });
609        resolve_args(&mut args, &prior).expect("tilde-escaped path must resolve");
610        assert_eq!(args, json!({"val": "slash-value"}));
611    }
612
613    // Oracle: RFC 6901 §3 — `~0` is the escape sequence for `~`.
614    // Replacement order must be ~1 first then ~0; otherwise `~01` would
615    // incorrectly become `/` instead of `~1`.
616    #[test]
617    fn resolve_args_path_tilde0_escaping() {
618        let prior = vec![(
619            "Foo/get".to_owned(),
620            json!({"a~b": "tilde-value"}),
621            "c0".to_owned(),
622        )];
623        let mut args = json!({
624            "#val": {"resultOf": "c0", "name": "Foo/get", "path": "/a~0b"}
625        });
626        resolve_args(&mut args, &prior).expect("~0-escaped path must resolve");
627        assert_eq!(args, json!({"val": "tilde-value"}));
628    }
629
630    // Oracle: RFC 6901 §3 — `~01` must decode to the literal string `~1`,
631    // NOT to `/`. ~1 is replaced first (yielding `~1`), then ~0 on what
632    // remains would replace `~0` — but after the first pass `~01` → `~1`
633    // there is no `~0` left; the result is `/`.
634    // Wait — `~01`: replace ~1 first: `~01` has no `~1` at position 0 (it's `~0` then `1`).
635    // So `~01` → replace ~1 → no match → `~01` → replace ~0 → `~` → result: `~1`.
636    // i.e. `~01` decodes to `~1` (literal tilde followed by 1), NOT to `/`.
637    #[test]
638    fn resolve_args_path_tilde01_decodes_to_tilde1() {
639        let prior = vec![(
640            "Foo/get".to_owned(),
641            json!({"~1": "tilde-one-value"}),
642            "c0".to_owned(),
643        )];
644        let mut args = json!({
645            "#val": {"resultOf": "c0", "name": "Foo/get", "path": "/~01"}
646        });
647        resolve_args(&mut args, &prior).expect("~01 must decode to literal key ~1");
648        assert_eq!(args, json!({"val": "tilde-one-value"}));
649    }
650
651    // Oracle: RFC 8620 §3.7 — /list/*/threadId maps threadId from each list element.
652    #[test]
653    fn resolve_args_wildcard_maps_over_array() {
654        let prior = vec![(
655            "Thread/get".to_owned(),
656            json!({
657                "list": [{"threadId": "t1"}, {"threadId": "t2"}]
658            }),
659            "c0".to_owned(),
660        )];
661        let mut args =
662            json!({"#ids": {"resultOf": "c0", "name": "Thread/get", "path": "/list/*/threadId"}});
663        resolve_args(&mut args, &prior).expect("wildcard must resolve");
664        assert_eq!(args, json!({"ids": ["t1", "t2"]}));
665    }
666
667    // Oracle: Fastmail jmap-samples top-ten.py uses path '/ids/*' where `ids` is
668    // a flat string array.  RFC 8620 §3.7 wildcard with empty `remaining` path
669    // must return a copy of the source array — each element maps to itself.
670    #[test]
671    fn resolve_args_wildcard_over_flat_string_array() {
672        // Simulates: Email/query → ids:["a","b","c"], then Email/get with
673        // #ids:{resultOf:"c0", name:"Email/query", path:"/ids/*"}.
674        let prior = vec![(
675            "Email/query".to_owned(),
676            json!({ "ids": ["a", "b", "c"] }),
677            "c0".to_owned(),
678        )];
679        let mut args = json!({"#ids": {"resultOf": "c0", "name": "Email/query", "path": "/ids/*"}});
680        resolve_args(&mut args, &prior).expect("flat-array wildcard must resolve");
681        // * over a flat string array with empty remaining path returns the same array.
682        assert_eq!(args, json!({"ids": ["a", "b", "c"]}));
683    }
684
685    // Oracle: RFC 8620 §3.7 — when wildcard result is an array, it is flattened.
686    #[test]
687    fn resolve_args_wildcard_flattens_array_results() {
688        let prior = vec![(
689            "Email/get".to_owned(),
690            json!({
691                "list": [{"emailIds": ["e1", "e2"]}, {"emailIds": ["e3"]}]
692            }),
693            "c0".to_owned(),
694        )];
695        let mut args =
696            json!({"#ids": {"resultOf": "c0", "name": "Email/get", "path": "/list/*/emailIds"}});
697        resolve_args(&mut args, &prior).expect("wildcard flatten must resolve");
698        assert_eq!(args, json!({"ids": ["e1", "e2", "e3"]}));
699    }
700
701    // Oracle: RFC 6901 §4 — basic path navigation.
702    #[test]
703    fn json_pointer_ext_plain_path() {
704        let v = json!({"a": {"b": 42}});
705        assert_eq!(json_pointer_ext(&v, "/a/b"), Some(json!(42)));
706    }
707
708    // Oracle: RFC 6901 §4 — empty path returns whole document.
709    #[test]
710    fn json_pointer_ext_empty_path_returns_root() {
711        let v = json!({"x": 1});
712        assert_eq!(json_pointer_ext(&v, ""), Some(v.clone()));
713    }
714
715    // Oracle: bd:JMAP-sc1b.95 — a path longer than MAX_JSON_POINTER_DEPTH
716    // tokens must be rejected as `None` (resolution failure) rather than
717    // walked recursively. The depth cap is a stack-DoS mitigation; the test
718    // builds a synthetic deep object and a matching deep path to confirm
719    // the cap fires before any real-world JMAP request shape would.
720    //
721    // The test does NOT use the code under test as its own oracle: it
722    // hand-builds a 1000-deep `{ "a": { "a": ... } }` document and a
723    // matching `/a/a/a/...` path, both via tight loops in the test body.
724    // The expected outcome (`None`) is derived from the documented depth
725    // cap, not from running the function.
726    #[test]
727    fn json_pointer_ext_rejects_deep_path() {
728        const DEPTH: usize = 1000;
729        // Build a nested object 1000 levels deep.
730        let mut value = json!(42);
731        for _ in 0..DEPTH {
732            value = json!({ "a": value });
733        }
734        // Build the matching pointer: "/a" repeated DEPTH times.
735        let path: String = "/a".repeat(DEPTH);
736        assert_eq!(
737            json_pointer_ext(&value, &path),
738            None,
739            "pointer with {DEPTH} tokens must be rejected by the depth cap"
740        );
741    }
742
743    // Oracle: paths up to MAX_JSON_POINTER_DEPTH tokens still resolve. This
744    // is the positive control for the depth cap: it confirms the cap fires
745    // strictly at the boundary, not for paths legitimate JMAP integrations
746    // will produce.
747    #[test]
748    fn json_pointer_ext_accepts_path_within_depth_cap() {
749        // Build an object of exactly MAX_JSON_POINTER_DEPTH-1 levels so the
750        // resolution succeeds (depth-1 increments fit within the cap).
751        const LEN: usize = MAX_JSON_POINTER_DEPTH - 1;
752        let mut value = json!("leaf");
753        for _ in 0..LEN {
754            value = json!({ "a": value });
755        }
756        let path: String = "/a".repeat(LEN);
757        assert_eq!(
758            json_pointer_ext(&value, &path),
759            Some(json!("leaf")),
760            "pointer with {LEN} tokens must still resolve under the depth cap"
761        );
762    }
763
764    // -----------------------------------------------------------------------
765    // check_known_capabilities
766    // -----------------------------------------------------------------------
767
768    // Oracle: RFC 8620 §3.3 — unknown capability URI returns unknownCapability.
769    #[test]
770    fn check_known_capabilities_unknown_uri_is_error() {
771        let req = JmapRequest::new(
772            vec![
773                "urn:ietf:params:jmap:core".into(),
774                "urn:example:unknown".into(),
775            ],
776            vec![],
777            None,
778        );
779        let known = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
780        let err = check_known_capabilities(&req, known).unwrap_err();
781        assert_eq!(
782            err.error_type, "unknownCapability",
783            "unrecognised URI must produce unknownCapability per RFC 8620 §3.3"
784        );
785        assert_eq!(
786            err.description.as_deref(),
787            Some("urn:example:unknown"),
788            "unknownCapability error must name the unrecognised URI in description"
789        );
790    }
791
792    // Oracle: RFC 8620 §3.3 — all known URIs accepted.
793    #[test]
794    fn check_known_capabilities_all_known_is_ok() {
795        let req = JmapRequest::new(
796            vec![
797                "urn:ietf:params:jmap:core".into(),
798                "urn:ietf:params:jmap:mail".into(),
799            ],
800            vec![],
801            None,
802        );
803        let known = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
804        check_known_capabilities(&req, known).expect("all URIs are in known — must return Ok");
805    }
806
807    // Oracle: boundary — empty using[] with any known set returns Ok.
808    #[test]
809    fn check_known_capabilities_empty_using_is_ok() {
810        let req = JmapRequest::new(vec![], vec![], None);
811        let known = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
812        check_known_capabilities(&req, known)
813            .expect("empty using[] must return Ok even when known is non-empty");
814    }
815}