Skip to main content

jmap_server/
helpers.rs

1//! Shared helper utilities for JMAP method handlers.
2
3use jmap_types::{Id, JmapError};
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`].
8pub fn ser<T: serde::Serialize>(val: T) -> Result<serde_json::Value, JmapError> {
9    serde_json::to_value(val).map_err(|e| JmapError::server_fail(e.to_string()))
10}
11
12/// Convert a slice of [`Id`]s to a JSON `notFound` value.
13///
14/// RFC 8620 §5.1 specifies `notFound` as `Id[]` — always an array, never
15/// `null`. Returns an empty array when all requested ids were found.
16pub fn not_found_json(ids: &[Id]) -> Value {
17    Value::Array(
18        ids.iter()
19            .map(|id| Value::String(id.as_ref().to_owned()))
20            .collect(),
21    )
22}
23
24/// Extract `accountId` from a JMAP method arguments envelope and return both
25/// the extracted [`Id`] and the remaining argument map.
26///
27/// The caller passes the full `args: Value` from the method invocation by
28/// value; this function destructures it once, so handlers do not have to
29/// repeat the `let Value::Object(mut args) = args else { ... }` pattern after
30/// every call.
31///
32/// Returns `invalidArguments` with the message "arguments must be an object
33/// containing accountId" when `args` is not a JSON object, and the same error
34/// type with the message "accountId is required" when the field is missing or
35/// not a string.
36pub fn extract_account_id(args: Value) -> Result<(Id, Map<String, Value>), JmapError> {
37    let Value::Object(args) = args else {
38        return Err(JmapError::invalid_arguments(
39            "arguments must be an object containing accountId",
40        ));
41    };
42    match args.get("accountId").and_then(|v| v.as_str()) {
43        Some(s) => {
44            let id = Id::from(s);
45            Ok((id, args))
46        }
47        None => Err(JmapError::invalid_arguments("accountId is required")),
48    }
49}
50
51/// Return the current UTC instant formatted as an RFC 3339 string with
52/// millisecond precision (`YYYY-MM-DDTHH:MM:SS.mmmZ`).
53///
54/// Uses `std::time::SystemTime` so no external dependency is needed.
55///
56/// Pre-epoch handling: if `duration_since(UNIX_EPOCH)` fails (system clock
57/// drifted before the epoch), this function uses the absolute duration from
58/// `UNIX_EPOCH.duration_since(now)` but negates the seconds — producing a
59/// timestamp in the range 1969-12-31T… through 1970-01-01T00:00:00Z. This
60/// is still monotonically increasing for subsequent calls and never silently
61/// produces 1970-01-01T00:00:00.000Z for a clock that is merely slightly behind.
62pub fn now_utc_string() -> String {
63    use std::time::{SystemTime, UNIX_EPOCH};
64
65    let now = SystemTime::now();
66    let (secs, millis): (i64, u32) = match now.duration_since(UNIX_EPOCH) {
67        Ok(d) => (d.as_secs() as i64, d.subsec_millis()),
68        Err(e) => {
69            // Clock is before the Unix epoch — negate so we get a real (negative)
70            // epoch offset rather than silently returning 1970-01-01T00:00:00Z.
71            let d = e.duration();
72            (-(d.as_secs() as i64), d.subsec_millis())
73        }
74    };
75
76    let s = secs.rem_euclid(60);
77    let m = (secs / 60).rem_euclid(60);
78    let h = (secs / 3600).rem_euclid(24);
79    let days = secs.div_euclid(86400);
80    let (year, month, day) = civil_from_days(days);
81
82    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{millis:03}Z")
83}
84
85/// Convert a count of days since the Unix epoch (1970-01-01) to a proleptic
86/// Gregorian (year, month, day) triple.
87///
88/// Algorithm by Howard Hinnant (public domain):
89/// <https://howardhinnant.github.io/date_algorithms.html>
90fn civil_from_days(z: i64) -> (i32, u8, u8) {
91    let z = z + 719_468;
92    let era: i64 = if z >= 0 { z } else { z - 146_096 } / 146_097;
93    let doe = z - era * 146_097; // [0, 146096]
94    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
95    let y = yoe + era * 400;
96    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
97    let mp = (5 * doy + 2) / 153; // [0, 11]
98    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
99    let mo = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
100    let yr = if mo <= 2 { y + 1 } else { y };
101    (yr as i32, mo as u8, d as u8)
102}
103
104/// Maximum recursion depth for [`json_merge_patch`] application.
105///
106/// Beyond this depth the patch is silently ignored at the affected sub-tree:
107/// the target value at that level is left unchanged. Mitigates stack DoS
108/// from adversarial `PatchObject` values (bd:JMAP-sc1b.97). 32 levels
109/// comfortably exceeds any legitimate JMAP `/set update` shape — the
110/// deepest standard JMAP `/set update` shape (Email with nested
111/// `bodyStructure`) tops out around 6 levels.
112pub const MAX_MERGE_PATCH_DEPTH: usize = 32;
113
114/// Apply a JSON Merge Patch (RFC 7396) to `target` in-place.
115///
116/// Used by every `*-server` backend's `update_object` implementation
117/// to merge a sparse `/set update` patch into the stored serialized
118/// object. Extracted from per-crate copies in bd:JMAP-sc1b.103 — keep
119/// edits here so all five reference backends stay byte-identical.
120///
121/// Patches deeper than [`MAX_MERGE_PATCH_DEPTH`] are silently truncated
122/// to bound stack use on adversarial input (bd:JMAP-sc1b.97). Below the
123/// cap the behaviour is exactly RFC 7396.
124pub fn json_merge_patch(target: &mut Value, patch: Value) {
125    json_merge_patch_inner(target, patch, 0);
126}
127
128fn json_merge_patch_inner(target: &mut Value, patch: Value, depth: usize) {
129    if depth > MAX_MERGE_PATCH_DEPTH {
130        return;
131    }
132    match patch {
133        Value::Object(patch_map) => {
134            // Per RFC 7396 §2: "If the target value is not a JSON object,
135            // the resulting value will be the merge patch." We therefore
136            // reset a non-Object target to an empty Object before merging
137            // — this is reachable when a Patch creates a nested field that
138            // is absent from the target (the parent recursion frame inserted
139            // Value::Null as a placeholder).
140            if !target.is_object() {
141                *target = Value::Object(Map::new());
142            }
143            let target_map = target
144                .as_object_mut()
145                .expect("target was just set to Value::Object above");
146            for (key, patch_val) in patch_map {
147                if patch_val.is_null() {
148                    target_map.remove(&key);
149                } else {
150                    let entry = target_map.entry(key).or_insert(Value::Null);
151                    json_merge_patch_inner(entry, patch_val, depth + 1);
152                }
153            }
154        }
155        other => *target = other,
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::{civil_from_days, json_merge_patch, now_utc_string};
162
163    /// Test vectors derived independently with Python's `datetime.date` module.
164    /// `days` is the count of days since 1970-01-01.
165    #[test]
166    fn civil_from_days_known_dates() {
167        let cases: &[(i64, (i32, u8, u8))] = &[
168            (0, (1970, 1, 1)),       // Unix epoch
169            (365, (1971, 1, 1)),     // one year later (1970 is not a leap year)
170            (10957, (2000, 1, 1)),   // Y2K
171            (11016, (2000, 2, 29)),  // leap day in a century-divisible leap year
172            (11017, (2000, 3, 1)),   // day after the leap day (era boundary in algorithm)
173            (19358, (2023, 1, 1)),   // a recent non-leap year start
174            (19722, (2023, 12, 31)), // end of 2023
175            (19782, (2024, 2, 29)),  // leap day in 2024
176            (19783, (2024, 3, 1)),   // day after 2024 leap day
177        ];
178
179        for &(days, expected) in cases {
180            assert_eq!(
181                civil_from_days(days),
182                expected,
183                "civil_from_days({days}) mismatch"
184            );
185        }
186    }
187
188    #[test]
189    fn now_utc_string_format() {
190        let s = now_utc_string();
191        // Must match YYYY-MM-DDTHH:MM:SS.mmmZ (24 chars)
192        assert_eq!(s.len(), 24, "unexpected length: {s}");
193        assert!(s.ends_with('Z'), "must end with Z: {s}");
194        assert_eq!(&s[4..5], "-", "missing year-month separator: {s}");
195        assert_eq!(&s[7..8], "-", "missing month-day separator: {s}");
196        assert_eq!(&s[10..11], "T", "missing date-time separator: {s}");
197        assert_eq!(&s[13..14], ":", "missing hour-minute separator: {s}");
198        assert_eq!(&s[16..17], ":", "missing minute-second separator: {s}");
199        assert_eq!(&s[19..20], ".", "missing decimal point before millis: {s}");
200        // milliseconds are 3 decimal digits
201        assert!(
202            s[20..23].chars().all(|c| c.is_ascii_digit()),
203            "milliseconds must be 3 digits: {s}"
204        );
205        assert!(
206            s.starts_with("20"),
207            "year should start with 20 in 21st century: {s}"
208        );
209    }
210
211    // -----------------------------------------------------------------------
212    // json_merge_patch (RFC 7396)
213    //
214    // Test oracles are hand-built JSON values derived from RFC 7396 §2 and §3
215    // examples, plus the regression case from bd:JMAP-sc1b.87. No oracle is
216    // computed by the function under test (test-integrity rule from
217    // workspace AGENTS.md).
218    // -----------------------------------------------------------------------
219
220    /// Oracle: bd:JMAP-sc1b.97 — a 1000-deep merge patch must NOT crash
221    /// via stack overflow. The depth cap silently truncates beyond
222    /// [`MAX_MERGE_PATCH_DEPTH`], so the call returns; the topmost
223    /// levels are applied, and the deeper levels are ignored.
224    ///
225    /// The test does not use the function as its own oracle: the input
226    /// is hand-built (a 1000-deep `{ "a": { "a": ... { "a": {} } } }`
227    /// chain where every level is Object, matching the structural
228    /// shape of a real PatchObject — the documented latent panic from
229    /// bd:JMAP-sc1b.87 only fires on non-Object leaves, which a typed
230    /// PatchObject cannot produce). The assertion only checks that the
231    /// call completes without panicking and without overflowing the
232    /// stack. A pre-fix recursion-unlimited implementation would
233    /// overflow before returning.
234    #[test]
235    fn json_merge_patch_does_not_stack_overflow() {
236        const DEPTH: usize = 1000;
237        let mut target = serde_json::json!({});
238        for _ in 0..DEPTH {
239            target = serde_json::json!({ "a": target });
240        }
241        let mut patch = serde_json::json!({});
242        for _ in 0..DEPTH {
243            patch = serde_json::json!({ "a": patch });
244        }
245        json_merge_patch(&mut target, patch);
246        assert!(
247            target.is_object(),
248            "after a deeply-nested merge patch, target must remain a JSON object; got {target:?}"
249        );
250    }
251
252    /// Oracle: a shallow merge patch under the cap still applies
253    /// normally. Positive control paired with the stack-overflow test
254    /// above to prove the depth cap only fires at the boundary, not on
255    /// every call.
256    #[test]
257    fn json_merge_patch_shallow_applies_normally() {
258        let mut target = serde_json::json!({ "a": 1, "b": { "c": 2 } });
259        let patch = serde_json::json!({ "b": { "c": 99, "d": 7 }, "e": null });
260        json_merge_patch(&mut target, patch);
261        assert_eq!(
262            target,
263            serde_json::json!({ "a": 1, "b": { "c": 99, "d": 7 } }),
264            "RFC 7396 merge semantics broken at shallow depth"
265        );
266    }
267
268    /// Regression: a Patch that adds a nested Object to a previously-
269    /// absent field used to panic with `expect("merge patch target
270    /// must be an object")` because the parent recursion frame
271    /// inserted Value::Null as the placeholder, then recursed into
272    /// Null with an Object patch.
273    ///
274    /// Per RFC 7396 §2 the correct behaviour is to reset the non-Object
275    /// target to an empty Object and merge into it. Oracle is hand-
276    /// derived from RFC 7396 §2's pseudocode:
277    /// `Target[Name] = MergePatch(Target[Name], Value)` where
278    /// MergePatch resets a non-Object target to `{}`.
279    #[test]
280    fn json_merge_patch_adds_nested_object_to_absent_field() {
281        let mut target = serde_json::json!({ "a": 1 });
282        let patch = serde_json::json!({ "b": { "c": 2 } });
283        json_merge_patch(&mut target, patch);
284        assert_eq!(
285            target,
286            serde_json::json!({ "a": 1, "b": { "c": 2 } }),
287            "patch must add the nested object at the previously-absent field"
288        );
289    }
290}