Skip to main content

jmap_server/
helpers.rs

1//! Shared helper utilities for JMAP method handlers.
2
3use jmap_types::{Id, JmapError, UTCDate};
4use serde_json::{Map, Value};
5
6/// Serialize any [`serde::Serialize`] type to a [`serde_json::Value`],
7/// mapping serialization errors to [`JmapError::server_fail`].
8///
9/// Used by every `*-server` handler to project a typed domain object
10/// (e.g. `Email`, `Mailbox`, `Chat`) into the wire-format `list` /
11/// `created` payload.
12pub fn serialize_value<T: serde::Serialize>(val: T) -> Result<serde_json::Value, JmapError> {
13    serde_json::to_value(val).map_err(|e| JmapError::server_fail(e.to_string()))
14}
15
16/// Deprecated alias for [`serialize_value`] (bd:JMAP-wlip.21).
17///
18/// The opaque 3-letter name was hard to read in consumer code
19/// (`let v = ser(x)?;` left readers grepping three crates to learn
20/// what `ser` did) and collided with the common local-variable name
21/// `ser`. Use [`serialize_value`] instead. This alias is preserved
22/// for one release as a deprecation runway; it will be removed in
23/// the next major.
24// bd:JMAP-jfia.6 — the `since` field was previously set to "0.1.3"
25// while the crate version was still 0.1.2, which rendered as
26// "deprecated in the FUTURE" in cargo doc / docs.rs. Drop `since`
27// until the release that ships the renaming actually goes out; the
28// version-pinned form will be reintroduced when 0.1.3 is published.
29#[deprecated(note = "renamed to serialize_value (bd:JMAP-wlip.21)")]
30pub fn ser<T: serde::Serialize>(val: T) -> Result<serde_json::Value, JmapError> {
31    serialize_value(val)
32}
33
34/// Convert a slice of [`Id`]s to a JSON `notFound` value.
35///
36/// RFC 8620 §5.1 specifies `notFound` as `Id[]` — always an array, never
37/// `null`. Returns an empty array when all requested ids were found.
38///
39/// Equivalent to `serde_json::to_value(ids)` but threads through
40/// `Value::Array` directly so the call site is infallible (bd:JMAP-wlip.28).
41pub fn not_found_json(ids: &[Id]) -> Value {
42    Value::Array(
43        ids.iter()
44            .map(|id| Value::String(id.as_ref().to_owned()))
45            .collect(),
46    )
47}
48
49/// Extract an optional, deserializable argument from a method-arguments
50/// envelope (bd:JMAP-wlip.32).
51///
52/// Looks up `name` in `args`, removing it. The result is:
53///
54/// - `Ok(None)` if the key is absent OR is present with `Value::Null`
55///   (RFC 8620 §3.3 treats absent and explicit-null the same for
56///   optional fields).
57/// - `Ok(Some(value))` if the key is present and the value
58///   deserializes successfully into `T`.
59/// - `Err(invalid_arguments_with(name))` if the value is present but
60///   fails to deserialize. The error message is built by the caller-
61///   supplied `invalid_arguments_with` so the resulting `JmapError`
62///   carries a domain-specific description ("ids must be an Id
63///   array", "filter must be a Filter object", etc.).
64///
65/// Collapses six near-identical
66/// `match args.remove(...).unwrap_or(Value::Null) { Value::Null => None,
67/// v => Some(serde_json::from_value(v)...) }` blocks in `handlers.rs`
68/// into one-liners.
69///
70/// # Interaction with `ResultReference` resolution (bd:JMAP-jfia.15)
71///
72/// [`crate::parse::resolve_args`] replaces a `#key` with the value
73/// produced by walking the JSON Pointer path against a prior
74/// response. RFC 6901 §4 makes no distinction between "key absent"
75/// and "key present with `null`", so the resolved value CAN be
76/// `Value::Null`. This function then treats that resolved-null
77/// identically to "key was not sent". The behaviour is
78/// RFC-compliant — both are "optional argument not provided" — but
79/// the asymmetry between an explicit `null` argument and a
80/// `#key`-resolved `null` argument may surprise a handler author
81/// who expects "I asked for the value via a ResultReference, so it
82/// must have been there". If a handler needs to distinguish
83/// resolved-null from unsent, it has to read the raw `args` map
84/// before calling this helper.
85pub fn optional_arg<T>(
86    args: &mut Map<String, Value>,
87    name: &str,
88    invalid_arguments_with: impl FnOnce() -> JmapError,
89) -> Result<Option<T>, JmapError>
90where
91    T: serde::de::DeserializeOwned,
92{
93    match args.remove(name).unwrap_or(Value::Null) {
94        Value::Null => Ok(None),
95        v => Ok(Some(
96            serde_json::from_value(v).map_err(|_| invalid_arguments_with())?,
97        )),
98    }
99}
100
101/// Read an optional boolean argument from a method-arguments envelope without
102/// removing it (bd:JMAP-ic0j.39).
103///
104/// Returns `default` if `key` is absent, if the value is not a boolean, or if
105/// the value is `Value::Null`. This is the canonical workspace helper for
106/// JMAP's "permissive parsing" convention on optional boolean controls
107/// (RFC 8620 §3.6.1 — unknown / non-typed values coerce to the spec default).
108///
109/// Use [`take_bool_arg`] if the caller wants to consume the entry as part of
110/// a "walk the args map removing as we go" pattern.
111///
112/// Collapses ~50 near-identical
113/// `args.get(K).and_then(|v| v.as_bool()).unwrap_or(default)` blocks across
114/// the extension-server family into single-line calls.
115pub fn bool_arg(args: &Map<String, Value>, key: &str, default: bool) -> bool {
116    args.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
117}
118
119/// Read an optional boolean argument from a method-arguments envelope,
120/// removing it from the map (bd:JMAP-ic0j.39).
121///
122/// Returns `default` if `key` is absent, if the value is not a boolean, or if
123/// the value is `Value::Null`. Mirrors the semantics of [`bool_arg`] but
124/// consumes the entry from `args`. Matches the canonical mail-server
125/// "remove-as-we-go" parsing pattern.
126pub fn take_bool_arg(args: &mut Map<String, Value>, key: &str, default: bool) -> bool {
127    args.remove(key)
128        .and_then(|v| v.as_bool())
129        .unwrap_or(default)
130}
131
132/// Extract `accountId` from a JMAP method arguments envelope and return both
133/// the extracted [`Id`] and the remaining argument map.
134///
135/// The caller passes the full `args: Value` from the method invocation by
136/// value; this function destructures it once, so handlers do not have to
137/// repeat the `let Value::Object(mut args) = args else { ... }` pattern after
138/// every call.
139///
140/// # Errors
141///
142/// Returns `invalidArguments` with:
143///
144/// - `"arguments must be an object containing accountId"` when `args` is
145///   not a JSON object.
146/// - `"accountId is required"` when the field is missing or not a string.
147/// - `"accountId is not a valid Id: <reason>"` when the field is a string
148///   but does not satisfy the RFC 8620 §1.2 Id grammar
149///   (`Id::new_validated`). Catches empty strings, strings longer than
150///   255 bytes, and strings containing characters outside the SAFE-CHAR
151///   set (`%x21 / %x23-7E` — visible ASCII excluding `"`).
152///   bd:JMAP-wlip.5 closed the previous silent-pass-through behaviour
153///   where a malformed accountId reached the backend's `account_exists`
154///   call and surfaced as either `notFound` or a storage-layer parse
155///   error, depending on the backend.
156pub fn extract_account_id(args: Value) -> Result<(Id, Map<String, Value>), JmapError> {
157    let Value::Object(mut args) = args else {
158        return Err(JmapError::invalid_arguments(
159            "arguments must be an object containing accountId",
160        ));
161    };
162    // Remove (not get) the accountId entry so the returned args map no
163    // longer carries it (bd:JMAP-jfia.9). This matches optional_arg's
164    // remove-and-consume semantics and prevents downstream handlers
165    // from re-parsing the validated id or seeing it as an unexpected
166    // residual key.
167    let raw = args
168        .remove("accountId")
169        .ok_or_else(|| JmapError::invalid_arguments("accountId is required"))?;
170    let s = raw
171        .as_str()
172        .ok_or_else(|| JmapError::invalid_arguments("accountId is required"))?;
173    let id = Id::new_validated(s)
174        .map_err(|e| JmapError::invalid_arguments(format!("accountId is not a valid Id: {e}")))?;
175    Ok((id, args))
176}
177
178/// Return the current UTC instant as an [`UTCDate`] (RFC 3339,
179/// millisecond precision, format `YYYY-MM-DDTHH:MM:SS.mmmZ`).
180///
181/// Uses `std::time::SystemTime` so no external dependency is needed.
182///
183/// Returns a typed [`UTCDate`] rather than a `String` (bd:JMAP-wlip.20)
184/// so callers do not need to wrap the result in
185/// `UTCDate::from(now_utc_string().as_str())`. The function name is
186/// retained for back-compat across the workspace's many call sites.
187///
188/// The string the [`UTCDate`] wraps does not pass
189/// [`UTCDate::new_validated`] because that validator requires exactly
190/// the 20-char `YYYY-MM-DDTHH:MM:SSZ` form (no millis) and this helper
191/// emits the 24-char form with millis. The workspace convention is to
192/// use the 24-char form on the wire — consumers wanting strict
193/// validation should construct their own `UTCDate::new_validated` value
194/// from a `chrono`-formatted source.
195///
196/// Pre-epoch handling: `SystemTime::now().duration_since(UNIX_EPOCH)`
197/// fails on clocks drifted before the epoch. The function uses
198/// `Err::duration()` to recover the magnitude and negates the seconds
199/// before formatting; the result is a correct RFC 3339 timestamp
200/// anywhere from the BCE proleptic Gregorian range up to
201/// `1970-01-01T00:00:00.000Z`. The rem_euclid / div_euclid math at
202/// the format step handles arbitrarily-large negative offsets, not
203/// only sub-day drifts. Pre-epoch correctness is best-effort —
204/// `SystemTime` is non-monotonic and the JMAP spec does not require
205/// pre-epoch timestamps. (bd:JMAP-wlip.11 corrected the previous
206/// docstring's incorrect "1969-12-31T… through 1970-01-01T00:00:00Z"
207/// range claim.)
208///
209/// Clock-overflow handling: on a corrupted clock (system clock
210/// reporting a Duration whose `as_secs()` exceeds `i64::MAX`,
211/// `civil_from_days` reporting a year outside `i32`, or any other
212/// `SystemTime` failure mode), this function **panics**. The
213/// previous sentinel-string behaviour (`UTCDate::from("clock-out-of-range")`)
214/// was an idiom-grade defect (bd:JMAP-jfia.30): the sentinel was not a
215/// valid wire-format timestamp, had no in-band signal to distinguish
216/// it from a real value, and could silently propagate into JSON
217/// responses, audit logs, and database rows.
218///
219/// Callers that need to handle clock failure without panicking MUST
220/// use [`now_utc_string_checked`], which returns `Option<UTCDate>`.
221/// Long-running daemons, schedulers, and persistence layers SHOULD
222/// prefer the checked variant; one-shot tools and request handlers
223/// MAY accept the panic since clock corruption is unrecoverable and
224/// the dispatcher's `task::spawn` isolation already converts the
225/// panic into a `serverFail` invocation rather than crashing the
226/// process.
227///
228/// # Panics
229///
230/// Panics if `SystemTime::now()` cannot be expressed as an RFC 3339
231/// timestamp — the same conditions under which
232/// [`now_utc_string_checked`] returns `None`.
233pub fn now_utc_string() -> UTCDate {
234    now_utc_string_checked().expect("system clock returned an out-of-range value (bd:JMAP-jfia.30)")
235}
236
237/// Return the current UTC instant as an [`UTCDate`] (RFC 3339,
238/// millisecond precision, format `YYYY-MM-DDTHH:MM:SS.mmmZ`), or
239/// `None` if the system clock cannot be expressed as an RFC 3339
240/// timestamp.
241///
242/// Added in bd:JMAP-jfia.30 to replace the previous sentinel-string
243/// failure mode of [`now_utc_string`] with a typed `Option` shape.
244/// Callers that want to react to a clock fault (audit-log
245/// timestamps, last-seen markers, retention sweeps) SHOULD use this
246/// variant; callers for whom a panic at the first sign of clock
247/// corruption is acceptable MAY use [`now_utc_string`] directly.
248///
249/// Returns `None` when:
250/// - `SystemTime::now().duration_since(UNIX_EPOCH).as_secs()`
251///   exceeds `i64::MAX` (only reachable on a corrupted clock —
252///   approx ±292 billion years from epoch).
253/// - The negation of a pre-epoch duration overflows `i64`
254///   (unreachable on a `try_from`-validated input but checked
255///   defensively).
256/// - `civil_from_days` reports a year outside `i32`
257///   (bd:JMAP-jfia.2 — between the i32-year boundary and the
258///   i64::MAX-secs cap).
259///
260/// # Why `Option<UTCDate>` and not `Result<UTCDate, ClockError>` (bd:JMAP-jfia.38)
261///
262/// The three failure modes are all "corrupted clock" — each one is
263/// physically unreachable on a sane host (years 5.7M-to-292B,
264/// `try_from`-impossible negation, `i32`-overflowing year). A caller
265/// that wants to branch on which physical mechanism fired would be
266/// branching on states that don't happen. The shapes the workspace
267/// uses elsewhere for typed-variant-per-mode (`SetError`,
268/// `BackendChangesError`, `BackendSetError`, `MergePatchError`) all
269/// carry failure modes that DO occur in normal operation —
270/// `notFound`, `tooManyChanges`, `invalidPatch`, `depthExceeded`.
271/// The clock-corruption modes are different in kind. Erasing the
272/// discriminator here trades a hypothetical observability win for a
273/// cleaner contract: "the clock is unusable for RFC 3339, abandon
274/// timestamping." A future need for per-mode telemetry can be added
275/// non-breakingly as a parallel helper (e.g. `now_utc_string_diagnose
276/// -> Result<UTCDate, ClockError>`) without disturbing this shape.
277pub fn now_utc_string_checked() -> Option<UTCDate> {
278    use std::time::SystemTime;
279
280    let (secs, millis) = signed_seconds_since_epoch(SystemTime::now())?;
281
282    let s = secs.rem_euclid(60);
283    let m = (secs / 60).rem_euclid(60);
284    let h = (secs / 3600).rem_euclid(24);
285    let days = secs.div_euclid(86400);
286    let (year, month, day) = civil_from_days(days)?;
287
288    Some(UTCDate::from(format!(
289        "{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{millis:03}Z"
290    )))
291}
292
293/// Convert a [`SystemTime`] into `(signed_seconds_since_epoch, millis)`,
294/// returning `None` when the system clock cannot be expressed as an
295/// `i64` second count.
296///
297/// Extracted from [`now_utc_string_checked`] in bd:JMAP-jfia.11 to
298/// flatten the previous three-level nested match
299/// (`duration_since` → `try_from` → `checked_neg`) into one
300/// linear function the reader can scan top-to-bottom. The
301/// `.checked_neg()` branch is unreachable on a `try_from`-validated
302/// input (its output is `[0, i64::MAX]` so negation cannot underflow)
303/// but is kept for defence in depth — the failure path stays total.
304fn signed_seconds_since_epoch(now: std::time::SystemTime) -> Option<(i64, u32)> {
305    use std::time::UNIX_EPOCH;
306    match now.duration_since(UNIX_EPOCH) {
307        Ok(d) => {
308            let s = i64::try_from(d.as_secs()).ok()?;
309            Some((s, d.subsec_millis()))
310        }
311        Err(e) => {
312            // Pre-epoch clock — negate after the fallible widen so we
313            // can return a real (negative) epoch offset rather than
314            // silently snapping to 1970-01-01T00:00:00Z.
315            let d = e.duration();
316            let s = i64::try_from(d.as_secs()).ok()?;
317            let neg = s.checked_neg()?;
318            Some((neg, d.subsec_millis()))
319        }
320    }
321}
322
323/// Convert a count of days since the Unix epoch (1970-01-01) to a proleptic
324/// Gregorian (year, month, day) triple, or `None` if the resulting year
325/// does not fit in `i32`.
326///
327/// Algorithm by Howard Hinnant (public domain):
328/// <https://howardhinnant.github.io/date_algorithms.html>
329///
330/// The year-narrowing cast is fallible because the algorithm's intermediate
331/// `y` value is bounded by `i64::MAX / 146_097 ≈ ±6.3e13`, only a subset
332/// of which fits in `i32` (~±2.1e9 years). For a sane `SystemTime`-derived
333/// input we stay well inside `i32::MIN..=i32::MAX`, but the outer
334/// `now_utc_string` only rejects `u64` seconds exceeding `i64::MAX`
335/// (bd:JMAP-wlip.27 sentinel) — inputs between the i32-year boundary
336/// (~5.7e6 years from epoch) and that sentinel reach this function and
337/// previously panicked the dispatcher worker (bd:JMAP-jfia.2). Returning
338/// `None` lets the caller fall through to its own sentinel rather than
339/// taking down the task.
340///
341/// Month and day cannot fail: the algorithm's modular structure pins them
342/// to `[1, 12]` and `[1, 31]` respectively, narrow casts handled with
343/// `try_from(...).expect(...)` documenting the invariant.
344fn civil_from_days(z: i64) -> Option<(i32, u8, u8)> {
345    let z = z + 719_468;
346    let era: i64 = if z >= 0 { z } else { z - 146_096 } / 146_097;
347    let doe = z - era * 146_097; // [0, 146096]
348    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
349    let y = yoe + era * 400;
350    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
351    let mp = (5 * doy + 2) / 153; // [0, 11]
352    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
353    let mo = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
354    let yr = if mo <= 2 { y + 1 } else { y };
355    Some((
356        i32::try_from(yr).ok()?,
357        u8::try_from(mo).expect("month bounded by algorithm to [1, 12]"),
358        u8::try_from(d).expect("day bounded by algorithm to [1, 31]"),
359    ))
360}
361
362/// Maximum recursion depth for [`json_merge_patch`] application.
363///
364/// Beyond this depth [`json_merge_patch`] returns
365/// [`MergePatchError::DepthExceeded`] without applying any further levels.
366/// Mitigates stack DoS from adversarial `PatchObject` values
367/// (bd:JMAP-sc1b.97). 32 levels comfortably exceeds any legitimate JMAP
368/// `/set update` shape — the deepest standard JMAP `/set update` shape
369/// (Email with nested `bodyStructure`) tops out around 6 levels, so the
370/// cap fires only on adversarial input.
371///
372/// Crate-private (bd:JMAP-wlip.4): consumers see the cap-exceeded
373/// behaviour via [`MergePatchError::DepthExceeded`], not by reading the
374/// constant directly. The crate reserves the right to tighten the
375/// value (e.g. 32 → 16) without a major-version bump because the
376/// contract is "the function may return DepthExceeded", not "the cap
377/// is exactly N".
378pub(crate) const MAX_MERGE_PATCH_DEPTH: usize = 32;
379
380/// Error returned by [`json_merge_patch`] when a patch cannot be applied.
381///
382/// Marked `#[non_exhaustive]` so future RFC 7396 failure modes (e.g. a
383/// size cap in addition to the depth cap) can be added without an API
384/// break.
385#[non_exhaustive]
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub enum MergePatchError {
388    /// The patch nests deeper than the crate's `MAX_MERGE_PATCH_DEPTH`
389    /// DoS-guard cap.
390    ///
391    /// Callers SHOULD map this to
392    /// [`SetError`](crate::SetError) with
393    /// [`SetErrorType::InvalidPatch`](crate::SetErrorType::InvalidPatch)
394    /// and MUST discard any partially-mutated `target` rather than
395    /// persisting it — see the contract on [`json_merge_patch`].
396    ///
397    /// # Why no `reached` / `max` payload (bd:JMAP-jfia.39)
398    ///
399    /// The variant is intentionally unit-shaped. The cap value is
400    /// available in the `Display` impl (which interpolates
401    /// `MAX_MERGE_PATCH_DEPTH`) for log-line and user-message use.
402    /// A typed payload `{ reached: usize, max: usize }` was
403    /// considered and rejected for now because (a) no current
404    /// consumer in the workspace destructures the reached depth or
405    /// the cap programmatically — all 6 call sites map to
406    /// `SetErrorType::InvalidPatch` with a generic description; and
407    /// (b) changing a unit variant to a struct variant is a breaking
408    /// pattern-match change even on a `#[non_exhaustive]` enum (the
409    /// existing `if let Err(MergePatchError::DepthExceeded)` pattern
410    /// would stop compiling). If a future consumer needs
411    /// programmatic access to the depth, the additive evolution is
412    /// to add a new variant alongside (e.g.
413    /// `DepthExceededDetail { reached: usize, max: usize }`) and
414    /// emit the new variant from `json_merge_patch`. The unit
415    /// variant would then become unreachable but remain valid
416    /// pattern syntax for any pinned-version consumer.
417    DepthExceeded,
418}
419
420impl std::fmt::Display for MergePatchError {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        match self {
423            Self::DepthExceeded => write!(
424                f,
425                "merge patch nesting exceeds {MAX_MERGE_PATCH_DEPTH} levels"
426            ),
427        }
428    }
429}
430
431impl std::error::Error for MergePatchError {}
432
433/// Apply a JSON Merge Patch (RFC 7396) to `target` in-place.
434///
435/// Used by every `*-server` backend's `update_object` implementation
436/// to merge a sparse `/set update` patch into the stored serialized
437/// object. Extracted from per-crate copies in bd:JMAP-sc1b.103 — keep
438/// edits here so all five reference backends stay byte-identical.
439///
440/// # Errors
441///
442/// Returns [`MergePatchError::DepthExceeded`] when the patch nests
443/// deeper than the crate's internal `MAX_MERGE_PATCH_DEPTH` DoS-guard
444/// cap (added in bd:JMAP-sc1b.97, made non-silent in bd:JMAP-wlip.1).
445/// The exact value is intentionally not exposed; consumers see the
446/// behaviour via the typed error rather than reading the constant.
447/// Below the cap the behaviour is exactly RFC 7396 and the call always
448/// returns `Ok(())`.
449///
450/// # Partial-mutation contract
451///
452/// On `Err(DepthExceeded)`, `target` may have been mutated up to the
453/// level where the cap fired — RFC 7396 merging is applied recursively
454/// in place and the function does not roll back on error. Callers MUST
455/// discard `target` rather than persist it. The standard pattern in
456/// every `*-server` `update_object` impl is to operate on a `.clone()`
457/// of the stored value and only `insert(...)` it back on `Ok(())`; that
458/// pattern is naturally safe because the stored value is left untouched
459/// on error.
460pub fn json_merge_patch(target: &mut Value, patch: Value) -> Result<(), MergePatchError> {
461    json_merge_patch_inner(target, patch, 0)
462}
463
464fn json_merge_patch_inner(
465    target: &mut Value,
466    patch: Value,
467    depth: usize,
468) -> Result<(), MergePatchError> {
469    if depth > MAX_MERGE_PATCH_DEPTH {
470        return Err(MergePatchError::DepthExceeded);
471    }
472    match patch {
473        Value::Object(patch_map) => {
474            // Per RFC 7396 §2: "If the target value is not a JSON object,
475            // the resulting value will be the merge patch." We therefore
476            // reset a non-Object target to an empty Object before merging
477            // — this is reachable when a Patch creates a nested field that
478            // is absent from the target (the parent recursion frame inserted
479            // Value::Null as a placeholder).
480            if !target.is_object() {
481                *target = Value::Object(Map::new());
482            }
483            let target_map = target
484                .as_object_mut()
485                .expect("target was just set to Value::Object above");
486            for (key, patch_val) in patch_map {
487                if patch_val.is_null() {
488                    target_map.remove(&key);
489                } else {
490                    let entry = target_map.entry(key).or_insert(Value::Null);
491                    json_merge_patch_inner(entry, patch_val, depth + 1)?;
492                }
493            }
494        }
495        other => *target = other,
496    }
497    Ok(())
498}
499
500/// Resolve a request-side `position` argument (RFC 8620 §5.5) into a
501/// 0-based start index suitable for slicing the full ordered result
502/// list (bd:JMAP-qz9v.48).
503///
504/// `position` is the signed offset from the `/query` request. The
505/// foundation handler at [`crate::handlers::handle_query`] parses it
506/// via [`serde_json::Value::as_i64`], so the value reaching the
507/// backend is any `i64` (including `i64::MIN`). Non-negative values
508/// are absolute offsets from the start of the result list; negative
509/// values are end-relative offsets per RFC 8620 §5.5 ("`-1` represents
510/// the last entry, `-2` the second to last, and so on").
511///
512/// Returns a `usize` clamped to `[0, total]`. The clamp at the high
513/// end matches the slice-safety property: `all_ids[start..]` is
514/// guaranteed not to panic regardless of the input `position`.
515///
516/// # Edge cases this helper exists to centralize
517///
518/// 1. `position == 0` → `0`.
519/// 2. `position > total` → `total` (yields an empty page, matching
520///    the RFC's silent-clamp semantics — `/query` does not error on
521///    out-of-range positions).
522/// 3. `position == i64::MIN` → handled via [`i64::saturating_neg`],
523///    which returns `i64::MAX` rather than overflowing. The resulting
524///    `usize::MAX`-magnitude offset is then absorbed by
525///    [`usize::saturating_sub`].
526/// 4. `position` exceeding `usize::MAX` on a 32-bit target (`i64`
527///    values above `2^32 - 1`) → clamped to `total` rather than
528///    truncated. The previous workspace idiom (`position as usize`)
529///    silently wrapped the high bits on 32-bit, returning a small
530///    `start` index that did not match the caller's intent.
531///
532/// # Why this lives in the foundation
533///
534/// The pattern is identical across every extension server's `/query`
535/// backend impl. Centralizing it eliminates the per-crate
536/// `(position as usize)` / `(position.saturating_neg() as usize)`
537/// idiom that clippy::pedantic flags as `cast_possible_truncation` +
538/// `cast_sign_loss`, and prevents future siblings from re-introducing
539/// the strictly-worse `(-position) as usize` variant (which panics on
540/// `i64::MIN` in debug builds and wraps in release).
541///
542/// # Example
543///
544/// ```
545/// use jmap_server::resolve_query_offset;
546///
547/// // Absolute offset.
548/// assert_eq!(resolve_query_offset(0, 100), 0);
549/// assert_eq!(resolve_query_offset(25, 100), 25);
550///
551/// // End-relative offset.
552/// assert_eq!(resolve_query_offset(-1, 100), 99);
553/// assert_eq!(resolve_query_offset(-100, 100), 0);
554///
555/// // Out-of-range clamps to total.
556/// assert_eq!(resolve_query_offset(1_000, 100), 100);
557/// assert_eq!(resolve_query_offset(-1_000, 100), 0);
558///
559/// // i64::MIN does not overflow.
560/// assert_eq!(resolve_query_offset(i64::MIN, 100), 0);
561/// ```
562#[must_use]
563pub fn resolve_query_offset(position: i64, total: usize) -> usize {
564    if position >= 0 {
565        // `i64 >= 0` → fits in `u64`. `usize::try_from` returns the
566        // value on 64-bit targets and `Err` on 32-bit targets only
567        // when the magnitude exceeds `usize::MAX`; saturating to
568        // `usize::MAX` is then clamped to `total` by `.min`.
569        usize::try_from(position).unwrap_or(usize::MAX).min(total)
570    } else {
571        // `saturating_neg` maps `i64::MIN` to `i64::MAX` instead of
572        // overflowing. Every other negative `i64` negates exactly.
573        let neg = usize::try_from(position.saturating_neg()).unwrap_or(usize::MAX);
574        total.saturating_sub(neg)
575    }
576}
577
578/// Enforce RFC 8620 §5.3 `maxObjectsInSet` cap at the top of a `/set`
579/// handler (bd:JMAP-ayoz.41.1).
580///
581/// Counts entries in the wire `create` (object), `update` (object), and
582/// `destroy` (array) arguments. Returns
583/// [`JmapError::limit("maxObjectsInSet")`][JmapError::limit] — which maps
584/// to HTTP 400 + wire `type: "limit"` via [`crate::response::error_status`]
585/// — when the sum exceeds `max`.
586///
587/// Call at the top of every `handle_*_set` after the `account_exists`
588/// gate and before any per-entry processing. A request carrying
589/// megabytes of `/set` ops is rejected before the handler touches the
590/// storage layer:
591///
592/// ```ignore
593/// let (account_id, args) = extract_account_id(args)?;
594/// if !backend.account_exists(caller, &account_id).await
595///     .map_err(|e| server_fail_from_backend(&e))?
596/// {
597///     return Err(JmapError::account_not_found());
598/// }
599/// jmap_server::helpers::enforce_max_objects_in_set(
600///     &args,
601///     backend.max_objects_in_set(caller, &account_id),
602/// )?;
603/// ```
604///
605/// # Why this lives in the foundation
606///
607/// `maxObjectsInSet` is a RFC 8620 §5.3 base-protocol cap, not an
608/// extension concept. Every `jmap-*-server` extension's `handle_*_set`
609/// needs the same check; the helper is the single source of truth so a
610/// future revision (different error shape, additional counting rule,
611/// alternate semantics for non-object `create` / `update`) lands in one
612/// place instead of being propagated through 28 handler sites.
613///
614/// # Counting rules
615///
616/// - `create` is counted as `args["create"].as_object()?.len()` —
617///   missing key, `null`, or non-object types count as 0.
618/// - `update` is counted the same way (RFC 8620 §5.3 `update` is
619///   `Id[PatchObject]`).
620/// - `destroy` is counted as `args["destroy"].as_array()?.len()` —
621///   missing key, `null`, or non-array types count as 0.
622///
623/// Wire-shape validation of the individual `create` / `update` /
624/// `destroy` arguments belongs to the per-handler argument parsing,
625/// not to this cap-enforcement helper. A non-object `create` survives
626/// the cap check (counts as 0) and is rejected by the handler's
627/// downstream `args.remove("create")` match arm. Conversely, a
628/// well-formed but over-limit `create` is rejected here before the
629/// handler runs.
630///
631/// # Errors
632///
633/// Returns `JmapError::limit("maxObjectsInSet")` when the sum exceeds
634/// `max`. The HTTP layer maps this to a 400 response with the limit
635/// name in the RFC 7807 `"limit"` field.
636pub fn enforce_max_objects_in_set(args: &Map<String, Value>, max: u64) -> Result<(), JmapError> {
637    let create_count = args
638        .get("create")
639        .and_then(|v| v.as_object())
640        .map_or(0u64, |m| m.len() as u64);
641    let update_count = args
642        .get("update")
643        .and_then(|v| v.as_object())
644        .map_or(0u64, |m| m.len() as u64);
645    let destroy_count = args
646        .get("destroy")
647        .and_then(|v| v.as_array())
648        .map_or(0u64, |a| a.len() as u64);
649    // u64 saturation: each count is bounded by serde_json's body parse
650    // (Map::len / Vec::len are usize; on 64-bit targets usize == u64,
651    // on 32-bit targets usize < u64). The sum cannot exceed
652    // 3 * u32::MAX even on 32-bit hosts, well within u64 range.
653    let count = create_count
654        .saturating_add(update_count)
655        .saturating_add(destroy_count);
656    if count > max {
657        return Err(JmapError::limit("maxObjectsInSet"));
658    }
659    Ok(())
660}
661
662#[cfg(test)]
663mod tests {
664    use super::{
665        civil_from_days, enforce_max_objects_in_set, extract_account_id, json_merge_patch,
666        now_utc_string, resolve_query_offset, MergePatchError, MAX_MERGE_PATCH_DEPTH,
667    };
668    use serde_json::json;
669
670    /// Oracle (bd:JMAP-wlip.5): a malformed accountId — empty string,
671    /// containing forbidden ASCII characters, or exceeding 255 bytes —
672    /// MUST surface as `invalidArguments`, not silently pass through to
673    /// the backend's `account_exists` call.
674    ///
675    /// Test vectors hand-built from RFC 8620 §1.2's Id grammar
676    /// (SAFE-CHAR = `%x21 / %x23-7E`).
677    #[test]
678    fn extract_account_id_rejects_malformed_id() {
679        // Empty string.
680        let err = extract_account_id(json!({ "accountId": "" }))
681            .expect_err("empty accountId must fail validation");
682        assert_eq!(err.error_type.as_str(), "invalidArguments");
683
684        // Contains a space (0x20 — outside SAFE-CHAR's 0x21+ lower bound).
685        let err = extract_account_id(json!({ "accountId": "my account" }))
686            .expect_err("space in accountId must fail validation");
687        assert_eq!(err.error_type.as_str(), "invalidArguments");
688
689        // Contains a DQUOTE (0x22 — explicitly excluded by SAFE-CHAR).
690        let err = extract_account_id(json!({ "accountId": "a\"b" }))
691            .expect_err("DQUOTE in accountId must fail validation");
692        assert_eq!(err.error_type.as_str(), "invalidArguments");
693
694        // 256 bytes — exceeds the 255 cap.
695        let long: String = "a".repeat(256);
696        let err = extract_account_id(json!({ "accountId": long }))
697            .expect_err("over-long accountId must fail validation");
698        assert_eq!(err.error_type.as_str(), "invalidArguments");
699    }
700
701    /// Oracle (bd:JMAP-wlip.5): a well-formed accountId passes
702    /// validation and is returned intact. Positive control paired with
703    /// the rejection test above.
704    ///
705    /// Also pins (bd:JMAP-jfia.9): the returned args map MUST NOT
706    /// contain accountId. The helper consumes the field rather than
707    /// leaving it as a residual key for downstream handlers to
708    /// re-parse or surface as "unexpected key".
709    #[test]
710    fn extract_account_id_accepts_well_formed_id() {
711        let (id, rest) = extract_account_id(json!({
712            "accountId": "u123-abc_DEF",
713            "ids": ["e1", "e2"]
714        }))
715        .expect("well-formed accountId must pass validation");
716        assert_eq!(id.as_ref(), "u123-abc_DEF");
717        // Remaining args still contain the unrelated keys.
718        assert!(rest.contains_key("ids"));
719        // accountId MUST have been removed from the args map
720        // (bd:JMAP-jfia.9). matches optional_arg's consume semantics.
721        assert!(
722            !rest.contains_key("accountId"),
723            "accountId must be removed from args after extraction"
724        );
725    }
726
727    /// Test vectors derived independently with Python's `datetime.date` module.
728    /// `days` is the count of days since 1970-01-01.
729    #[test]
730    fn civil_from_days_known_dates() {
731        let cases: &[(i64, (i32, u8, u8))] = &[
732            (0, (1970, 1, 1)),       // Unix epoch
733            (365, (1971, 1, 1)),     // one year later (1970 is not a leap year)
734            (10957, (2000, 1, 1)),   // Y2K
735            (11016, (2000, 2, 29)),  // leap day in a century-divisible leap year
736            (11017, (2000, 3, 1)),   // day after the leap day (era boundary in algorithm)
737            (19358, (2023, 1, 1)),   // a recent non-leap year start
738            (19722, (2023, 12, 31)), // end of 2023
739            (19782, (2024, 2, 29)),  // leap day in 2024
740            (19783, (2024, 3, 1)),   // day after 2024 leap day
741        ];
742
743        for &(days, expected) in cases {
744            assert_eq!(
745                civil_from_days(days),
746                Some(expected),
747                "civil_from_days({days}) mismatch"
748            );
749        }
750    }
751
752    /// Oracle (bd:JMAP-jfia.2): civil_from_days MUST return None rather
753    /// than panic on inputs whose computed year overflows i32. The
754    /// Hinnant algorithm's intermediate `y = yoe + era * 400` value is
755    /// bounded by `i64::MAX / 146_097 ≈ ±6.3e13`, only a thin slice of
756    /// which fits in i32. The outer `now_utc_string_checked`
757    /// u64→i64 sentinel only catches u64 seconds exceeding `i64::MAX`,
758    /// so corrupted-clock inputs between the i32-year boundary and
759    /// i64::MAX reach this function. A panic here would surface as
760    /// serverFail under dispatcher task::spawn isolation — degraded,
761    /// but a contract violation versus the function's "fallible
762    /// without panicking" contract.
763    ///
764    /// Test vectors: the maximum days-from-epoch derived from
765    /// i64::MAX seconds (the regime that reaches civil_from_days
766    /// after passing the outer u64→i64 sentinel), and the symmetric
767    /// negative case. These years are deep enough into the i64
768    /// algorithm range to definitely overflow i32. Plus a
769    /// non-overflowing positive control just below the threshold to
770    /// prove the boundary check fires only when warranted.
771    #[test]
772    fn civil_from_days_returns_none_on_year_overflow() {
773        // i64::MAX / 86400 ≈ 1.07e14 days → year ≈ 2.92e11, well past i32::MAX (~2.15e9).
774        let max_days = i64::MAX / 86400;
775        assert_eq!(
776            civil_from_days(max_days),
777            None,
778            "i64::MAX / 86400 days must overflow i32 year"
779        );
780
781        // i64::MIN / 86400 ≈ -1.07e14 days — symmetric negative case.
782        let min_days = i64::MIN / 86400;
783        assert_eq!(
784            civil_from_days(min_days),
785            None,
786            "i64::MIN / 86400 days must overflow i32 year"
787        );
788
789        // Positive control: a far-future but i32-fitting year should
790        // still succeed. Year ~58_798_075 (from 10 * i32::MAX days)
791        // fits in i32 with room to spare, so this MUST return Some.
792        let year_58m_days = 10_i64 * i64::from(i32::MAX);
793        let result = civil_from_days(year_58m_days);
794        assert!(
795            result.is_some(),
796            "i32-fitting year must return Some; got {result:?}"
797        );
798    }
799
800    /// Oracle (bd:JMAP-jfia.30): now_utc_string_checked MUST return
801    /// `Some(UTCDate)` on a sane clock (every host the test runs on
802    /// in practice). The civil_from_days_returns_none_on_year_overflow
803    /// test above pins the underlying None-on-corrupted-clock
804    /// behaviour at the algorithm level; this test pins the
805    /// happy-path contract: a sane clock yields the typed wire format,
806    /// not a sentinel string.
807    #[test]
808    fn now_utc_string_checked_returns_some_on_sane_clock() {
809        use super::now_utc_string_checked;
810        let dt = now_utc_string_checked().expect(
811            "test host clock must be reasonable enough for now_utc_string_checked to succeed",
812        );
813        let s: &str = dt.as_ref();
814        assert_eq!(s.len(), 24, "wire shape must be 24 chars: {s:?}");
815        assert!(s.ends_with('Z'), "must end with Z: {s:?}");
816    }
817
818    /// Oracle (bd:JMAP-jfia.30): now_utc_string (the panicking variant)
819    /// MUST agree with now_utc_string_checked on a sane clock — i.e.
820    /// the .expect() in now_utc_string does not introduce a wire-format
821    /// discrepancy versus the Option-returning variant.
822    #[test]
823    fn now_utc_string_matches_checked_variant_on_sane_clock() {
824        use super::now_utc_string_checked;
825        let panicky = now_utc_string();
826        let checked = now_utc_string_checked().expect("test host clock must be reasonable");
827        // Both calls observe SystemTime::now() at slightly different
828        // instants; the seconds part can differ if the test runs across
829        // a second boundary. Compare the prefix up to the seconds
830        // resolution.
831        let panicky_s: &str = panicky.as_ref();
832        let checked_s: &str = checked.as_ref();
833        assert_eq!(
834            panicky_s.len(),
835            checked_s.len(),
836            "wire-format lengths must match: panicky={panicky_s:?} checked={checked_s:?}"
837        );
838        // Compare the date portion only (YYYY-MM-DD, 10 chars).
839        assert_eq!(
840            &panicky_s[..10],
841            &checked_s[..10],
842            "date portions must match: panicky={panicky_s:?} checked={checked_s:?}"
843        );
844    }
845
846    #[test]
847    fn now_utc_string_format() {
848        // bd:JMAP-wlip.20 — return type is UTCDate; AsRef<str> gives
849        // the underlying wire-format string for shape assertions.
850        let dt = now_utc_string();
851        let s: &str = dt.as_ref();
852        // Must match YYYY-MM-DDTHH:MM:SS.mmmZ (24 chars)
853        assert_eq!(s.len(), 24, "unexpected length: {s}");
854        assert!(s.ends_with('Z'), "must end with Z: {s}");
855        assert_eq!(&s[4..5], "-", "missing year-month separator: {s}");
856        assert_eq!(&s[7..8], "-", "missing month-day separator: {s}");
857        assert_eq!(&s[10..11], "T", "missing date-time separator: {s}");
858        assert_eq!(&s[13..14], ":", "missing hour-minute separator: {s}");
859        assert_eq!(&s[16..17], ":", "missing minute-second separator: {s}");
860        assert_eq!(&s[19..20], ".", "missing decimal point before millis: {s}");
861        // milliseconds are 3 decimal digits
862        assert!(
863            s[20..23].chars().all(|c| c.is_ascii_digit()),
864            "milliseconds must be 3 digits: {s}"
865        );
866        assert!(
867            s.starts_with("20"),
868            "year should start with 20 in 21st century: {s}"
869        );
870    }
871
872    // -----------------------------------------------------------------------
873    // json_merge_patch (RFC 7396)
874    //
875    // Test oracles are hand-built JSON values derived from RFC 7396 §2 and §3
876    // examples, plus the regression case from bd:JMAP-sc1b.87. No oracle is
877    // computed by the function under test (test-integrity rule from
878    // workspace AGENTS.md).
879    // -----------------------------------------------------------------------
880
881    /// Oracle: bd:JMAP-sc1b.97 — a 1000-deep merge patch must NOT crash
882    /// via stack overflow. The depth cap returns
883    /// [`MergePatchError::DepthExceeded`] beyond [`MAX_MERGE_PATCH_DEPTH`]
884    /// rather than recursing further.
885    ///
886    /// The test does not use the function as its own oracle: the input
887    /// is hand-built (a 1000-deep `{ "a": { "a": ... { "a": {} } } }`
888    /// chain where every level is Object, matching the structural
889    /// shape of a real PatchObject — the documented latent panic from
890    /// bd:JMAP-sc1b.87 only fires on non-Object leaves, which a typed
891    /// PatchObject cannot produce). The assertion checks that the
892    /// call completes without panicking AND that it surfaces the
893    /// depth-exceeded error rather than silently succeeding (the
894    /// pre-bd:JMAP-wlip.1 silent-truncation bug).
895    #[test]
896    fn json_merge_patch_does_not_stack_overflow() {
897        const DEPTH: usize = 1000;
898        let mut target = serde_json::json!({});
899        for _ in 0..DEPTH {
900            target = serde_json::json!({ "a": target });
901        }
902        let mut patch = serde_json::json!({});
903        for _ in 0..DEPTH {
904            patch = serde_json::json!({ "a": patch });
905        }
906        let err = json_merge_patch(&mut target, patch)
907            .expect_err("deep patch must surface DepthExceeded, not silently truncate");
908        assert_eq!(
909            err,
910            MergePatchError::DepthExceeded,
911            "deep patch must return MergePatchError::DepthExceeded"
912        );
913    }
914
915    /// Oracle: bd:JMAP-wlip.1 — a patch at exactly [`MAX_MERGE_PATCH_DEPTH`]
916    /// levels (the deepest legal patch) MUST apply successfully. The cap
917    /// fires only when the patch tries to recurse one level beyond. The
918    /// expected target shape is hand-built level-by-level from the same
919    /// counter the patch uses, so the oracle is independent of the
920    /// recursion under test.
921    #[test]
922    fn json_merge_patch_at_exact_cap_applies() {
923        // Build a patch nested exactly MAX_MERGE_PATCH_DEPTH levels deep.
924        // Outermost level is depth=1; innermost leaf-Object is at depth
925        // MAX_MERGE_PATCH_DEPTH. The first depth-cap check fires at
926        // depth == MAX_MERGE_PATCH_DEPTH + 1, so this is the deepest
927        // patch that still applies.
928        let mut patch = serde_json::json!({ "leaf": "value" });
929        for _ in 0..(MAX_MERGE_PATCH_DEPTH - 1) {
930            patch = serde_json::json!({ "a": patch });
931        }
932        let mut target = serde_json::json!({});
933        json_merge_patch(&mut target, patch).expect("patch at the cap must apply");
934        // Walk the resulting target down its 'a' chain to verify the
935        // leaf field landed.
936        let mut cursor = &target;
937        for _ in 0..(MAX_MERGE_PATCH_DEPTH - 1) {
938            cursor = cursor.get("a").expect("each level must have 'a'");
939        }
940        assert_eq!(
941            cursor.get("leaf"),
942            Some(&serde_json::Value::String("value".to_owned())),
943            "the leaf field at exactly MAX_MERGE_PATCH_DEPTH must be applied"
944        );
945    }
946
947    /// Oracle: a shallow merge patch under the cap still applies
948    /// normally. Positive control paired with the stack-overflow test
949    /// above to prove the depth cap only fires at the boundary, not on
950    /// every call.
951    #[test]
952    fn json_merge_patch_shallow_applies_normally() {
953        let mut target = serde_json::json!({ "a": 1, "b": { "c": 2 } });
954        let patch = serde_json::json!({ "b": { "c": 99, "d": 7 }, "e": null });
955        json_merge_patch(&mut target, patch).expect("shallow patch must succeed");
956        assert_eq!(
957            target,
958            serde_json::json!({ "a": 1, "b": { "c": 99, "d": 7 } }),
959            "RFC 7396 merge semantics broken at shallow depth"
960        );
961    }
962
963    /// Regression: a Patch that adds a nested Object to a previously-
964    /// absent field used to panic with `expect("merge patch target
965    /// must be an object")` because the parent recursion frame
966    /// inserted Value::Null as the placeholder, then recursed into
967    /// Null with an Object patch.
968    ///
969    /// Per RFC 7396 §2 the correct behaviour is to reset the non-Object
970    /// target to an empty Object and merge into it. Oracle is hand-
971    /// derived from RFC 7396 §2's pseudocode:
972    /// `Target[Name] = MergePatch(Target[Name], Value)` where
973    /// MergePatch resets a non-Object target to `{}`.
974    #[test]
975    fn json_merge_patch_adds_nested_object_to_absent_field() {
976        let mut target = serde_json::json!({ "a": 1 });
977        let patch = serde_json::json!({ "b": { "c": 2 } });
978        json_merge_patch(&mut target, patch).expect("nested-add patch must succeed");
979        assert_eq!(
980            target,
981            serde_json::json!({ "a": 1, "b": { "c": 2 } }),
982            "patch must add the nested object at the previously-absent field"
983        );
984    }
985
986    /// Oracle: [`MergePatchError`] implements [`std::error::Error`] and
987    /// has a stable Display form referencing the cap value. Pinning the
988    /// Display string keeps the error message stable across refactors;
989    /// the cap value is interpolated from the public constant so this
990    /// test does not need updating if the cap changes.
991    #[test]
992    fn merge_patch_error_display() {
993        let err = MergePatchError::DepthExceeded;
994        let s = err.to_string();
995        assert!(
996            s.contains(&MAX_MERGE_PATCH_DEPTH.to_string()),
997            "Display must mention the cap value; got {s:?}"
998        );
999        assert!(
1000            s.contains("merge patch"),
1001            "Display must identify the error source; got {s:?}"
1002        );
1003    }
1004
1005    // ------------------------------------------------------------------
1006    // enforce_max_objects_in_set (bd:JMAP-ayoz.41.1) — RFC 8620 §5.3
1007    //
1008    // Oracles for all tests below are hand-built JmapError comparisons
1009    // and hand-built JSON args; the helper itself is never the oracle.
1010    // ------------------------------------------------------------------
1011
1012    /// Oracle (RFC 8620 §5.3): an empty `/set` args envelope (no
1013    /// `create` / `update` / `destroy` keys) MUST pass any positive cap.
1014    #[test]
1015    fn enforce_max_objects_in_set_empty_args_is_ok() {
1016        let args = json!({}).as_object().unwrap().clone();
1017        enforce_max_objects_in_set(&args, 500).expect("empty args must be under any positive cap");
1018    }
1019
1020    /// Oracle (RFC 8620 §5.3): at the exact boundary count == max, the
1021    /// helper MUST return Ok. The cap is enforced as strictly-greater
1022    /// (`count > max`), not greater-or-equal.
1023    #[test]
1024    fn enforce_max_objects_in_set_at_limit_is_ok() {
1025        // 5 + 5 + 5 = 15 entries against max=15 must pass.
1026        let mut create = serde_json::Map::new();
1027        let mut update = serde_json::Map::new();
1028        let mut destroy = Vec::new();
1029        for i in 0..5 {
1030            create.insert(format!("c{i}"), json!({}));
1031            update.insert(format!("u{i}"), json!({}));
1032            destroy.push(json!(format!("d{i}")));
1033        }
1034        let args = json!({
1035            "create": create,
1036            "update": update,
1037            "destroy": destroy,
1038        })
1039        .as_object()
1040        .unwrap()
1041        .clone();
1042        enforce_max_objects_in_set(&args, 15).expect("exact-boundary count must be allowed");
1043    }
1044
1045    /// Oracle (RFC 8620 §5.3 + §3.6.1): one entry over the cap MUST
1046    /// return JmapError of type "limit" with description "maxObjectsInSet".
1047    /// The wire shape is verified by hand-building the expected
1048    /// JmapError independently of the helper.
1049    #[test]
1050    fn enforce_max_objects_in_set_over_limit_returns_limit_error() {
1051        // 5 + 5 + 6 = 16 entries against max=15 must fail.
1052        let mut create = serde_json::Map::new();
1053        let mut update = serde_json::Map::new();
1054        let mut destroy = Vec::new();
1055        for i in 0..5 {
1056            create.insert(format!("c{i}"), json!({}));
1057            update.insert(format!("u{i}"), json!({}));
1058        }
1059        for i in 0..6 {
1060            destroy.push(json!(format!("d{i}")));
1061        }
1062        let args = json!({
1063            "create": create,
1064            "update": update,
1065            "destroy": destroy,
1066        })
1067        .as_object()
1068        .unwrap()
1069        .clone();
1070        let err = enforce_max_objects_in_set(&args, 15)
1071            .expect_err("16-entry args against max=15 must fail");
1072        // Independent oracle — hand-built JmapError, not derived from
1073        // the function under test.
1074        let expected = jmap_types::JmapError::limit("maxObjectsInSet");
1075        assert_eq!(
1076            err.error_type.as_str(),
1077            expected.error_type.as_str(),
1078            "type must be \"limit\""
1079        );
1080        assert_eq!(
1081            err.description.as_deref(),
1082            Some("maxObjectsInSet"),
1083            "description must name the exceeded cap"
1084        );
1085    }
1086
1087    /// Oracle (RFC 8620 §5.3): a non-object `create` argument (e.g.
1088    /// `null` or a string) counts as 0 — wire-shape validation belongs
1089    /// to the per-handler argument parsing, not this cap helper. The
1090    /// handler will reject the malformed `create` downstream; this
1091    /// helper only counts well-formed entries.
1092    #[test]
1093    fn enforce_max_objects_in_set_ignores_non_object_create() {
1094        let args = json!({
1095            "create": null,
1096            "update": null,
1097            "destroy": ["id1"],
1098        })
1099        .as_object()
1100        .unwrap()
1101        .clone();
1102        // Only the 1 destroy entry counts.
1103        enforce_max_objects_in_set(&args, 1).expect("non-object create/update count as 0");
1104    }
1105
1106    /// Oracle (RFC 8620 §5.3): a non-array `destroy` argument counts
1107    /// as 0. Same rationale as the create/update test above.
1108    #[test]
1109    fn enforce_max_objects_in_set_ignores_non_array_destroy() {
1110        let args = json!({
1111            "create": { "c0": {} },
1112            "destroy": "not-an-array",
1113        })
1114        .as_object()
1115        .unwrap()
1116        .clone();
1117        // Only the 1 create entry counts.
1118        enforce_max_objects_in_set(&args, 1).expect("non-array destroy counts as 0");
1119    }
1120
1121    /// Oracle (RFC 8620 §5.3): the cap counts the SUM of all three
1122    /// sources. A request whose individual create/update/destroy
1123    /// counts are each below the cap can still trip the cap when
1124    /// summed.
1125    #[test]
1126    fn enforce_max_objects_in_set_sums_all_three() {
1127        // 3 + 3 + 3 = 9; against max=5, must fail because the SUM > max
1128        // even though each individual count <= max.
1129        let args = json!({
1130            "create": { "c0": {}, "c1": {}, "c2": {} },
1131            "update": { "u0": {}, "u1": {}, "u2": {} },
1132            "destroy": ["d0", "d1", "d2"],
1133        })
1134        .as_object()
1135        .unwrap()
1136        .clone();
1137        let err =
1138            enforce_max_objects_in_set(&args, 5).expect_err("sum-of-all-three must trip the cap");
1139        assert_eq!(err.error_type.as_str(), "limit");
1140        assert_eq!(err.description.as_deref(), Some("maxObjectsInSet"));
1141    }
1142
1143    /// Oracle (RFC 8620 §5.5 + bd:JMAP-qz9v.48): `position` is a signed
1144    /// offset where non-negative is absolute and negative is
1145    /// end-relative. All hand-computed values below come from reading
1146    /// the spec text, NOT from the implementation under test.
1147    #[test]
1148    fn resolve_query_offset_absolute() {
1149        // Non-negative position: absolute offset, clamped at total.
1150        assert_eq!(resolve_query_offset(0, 100), 0);
1151        assert_eq!(resolve_query_offset(1, 100), 1);
1152        assert_eq!(resolve_query_offset(50, 100), 50);
1153        assert_eq!(resolve_query_offset(99, 100), 99);
1154        assert_eq!(resolve_query_offset(100, 100), 100);
1155        // Past-the-end → clamped to total (RFC 8620 §5.5 silent-clamp).
1156        assert_eq!(resolve_query_offset(101, 100), 100);
1157        assert_eq!(resolve_query_offset(10_000, 100), 100);
1158        assert_eq!(resolve_query_offset(i64::MAX, 100), 100);
1159    }
1160
1161    #[test]
1162    fn resolve_query_offset_end_relative() {
1163        // RFC 8620 §5.5: "-1 represents the last entry, -2 the second
1164        // to last". Implemented as `total - |position|`, saturating at
1165        // 0 from below.
1166        assert_eq!(resolve_query_offset(-1, 100), 99);
1167        assert_eq!(resolve_query_offset(-2, 100), 98);
1168        assert_eq!(resolve_query_offset(-99, 100), 1);
1169        assert_eq!(resolve_query_offset(-100, 100), 0);
1170        // Past-the-start → clamped to 0.
1171        assert_eq!(resolve_query_offset(-101, 100), 0);
1172        assert_eq!(resolve_query_offset(-10_000, 100), 0);
1173    }
1174
1175    /// Oracle: with total=0 (empty result list), every position
1176    /// resolves to 0 (the only valid index, and a no-op slice).
1177    #[test]
1178    fn resolve_query_offset_empty_total() {
1179        assert_eq!(resolve_query_offset(0, 0), 0);
1180        assert_eq!(resolve_query_offset(1, 0), 0);
1181        assert_eq!(resolve_query_offset(-1, 0), 0);
1182        assert_eq!(resolve_query_offset(i64::MAX, 0), 0);
1183        assert_eq!(resolve_query_offset(i64::MIN, 0), 0);
1184    }
1185
1186    /// Oracle: `i64::MIN` would overflow `-position` (the previous
1187    /// workspace idiom at mail-server `memory/mod.rs:980`) but
1188    /// `saturating_neg` returns `i64::MAX`. The resulting end-relative
1189    /// offset of `i64::MAX` is well past any realistic `total`, so for
1190    /// any realistic total the result is 0.
1191    ///
1192    /// For the synthetic case `total == usize::MAX`, the offset stays
1193    /// within the saturation range and the result is
1194    /// `usize::MAX - i64::MAX`, which on a 64-bit target is exactly
1195    /// `0x8000_0000_0000_0000`. The point of the regression guard is
1196    /// that the call does NOT panic (which it would under the prior
1197    /// `(-position) as usize` idiom in debug builds).
1198    #[test]
1199    fn resolve_query_offset_i64_min_does_not_overflow() {
1200        assert_eq!(resolve_query_offset(i64::MIN, 1), 0);
1201        assert_eq!(resolve_query_offset(i64::MIN, 100), 0);
1202        assert_eq!(resolve_query_offset(i64::MIN, i64::MAX as usize), 0);
1203        // Synthetic upper bound: the answer is computable but not 0.
1204        // What matters is no overflow / no panic, and start <= total.
1205        let start = resolve_query_offset(i64::MIN, usize::MAX);
1206        assert!(start <= usize::MAX);
1207        assert_eq!(start, usize::MAX - (i64::MAX as usize));
1208        // And `i64::MAX` on the positive side is also handled.
1209        assert_eq!(resolve_query_offset(i64::MAX, 0), 0);
1210        assert_eq!(resolve_query_offset(i64::MAX, 1), 1);
1211    }
1212
1213    /// Oracle: the bead's specific concern was that `position as usize`
1214    /// truncates on 32-bit targets. On 64-bit (this test host) the
1215    /// `i64`-to-`usize` conversion is lossless within `[0, i64::MAX]`,
1216    /// so the truncation cannot be directly demonstrated; what we CAN
1217    /// verify is that the helper uses `usize::try_from` semantics, so
1218    /// a value above `i64::MAX` (only reachable here via direct cast
1219    /// from an unsigned constant) clamps to `total` rather than
1220    /// wrapping. The `i64::MIN` case in the prior test is the real
1221    /// regression guard.
1222    #[test]
1223    fn resolve_query_offset_truncation_resistant() {
1224        // The slice-safety property: `total..=total` is always a valid
1225        // start index for `slice[start..]` (yields empty slice). The
1226        // helper MUST return a value in `0..=total`.
1227        for &pos in &[i64::MIN, -1_000_000, -1, 0, 1, 1_000_000, i64::MAX] {
1228            for &total in &[0usize, 1, 100, 10_000] {
1229                let start = resolve_query_offset(pos, total);
1230                assert!(
1231                    start <= total,
1232                    "resolve_query_offset({pos}, {total}) = {start} violates start <= total"
1233                );
1234            }
1235        }
1236    }
1237}