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`].
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}