use jmap_types::{Id, JmapError, UTCDate};
use serde_json::{Map, Value};
pub fn serialize_value<T: serde::Serialize>(val: T) -> Result<serde_json::Value, JmapError> {
serde_json::to_value(val).map_err(|e| JmapError::server_fail(e.to_string()))
}
#[deprecated(note = "renamed to serialize_value (bd:JMAP-wlip.21)")]
pub fn ser<T: serde::Serialize>(val: T) -> Result<serde_json::Value, JmapError> {
serialize_value(val)
}
pub fn not_found_json(ids: &[Id]) -> Value {
Value::Array(
ids.iter()
.map(|id| Value::String(id.as_ref().to_owned()))
.collect(),
)
}
pub fn optional_arg<T>(
args: &mut Map<String, Value>,
name: &str,
invalid_arguments_with: impl FnOnce() -> JmapError,
) -> Result<Option<T>, JmapError>
where
T: serde::de::DeserializeOwned,
{
match args.remove(name).unwrap_or(Value::Null) {
Value::Null => Ok(None),
v => Ok(Some(
serde_json::from_value(v).map_err(|_| invalid_arguments_with())?,
)),
}
}
pub fn bool_arg(args: &Map<String, Value>, key: &str, default: bool) -> bool {
args.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
}
pub fn take_bool_arg(args: &mut Map<String, Value>, key: &str, default: bool) -> bool {
args.remove(key)
.and_then(|v| v.as_bool())
.unwrap_or(default)
}
pub fn extract_account_id(args: Value) -> Result<(Id, Map<String, Value>), JmapError> {
let Value::Object(mut args) = args else {
return Err(JmapError::invalid_arguments(
"arguments must be an object containing accountId",
));
};
let raw = args
.remove("accountId")
.ok_or_else(|| JmapError::invalid_arguments("accountId is required"))?;
let s = raw
.as_str()
.ok_or_else(|| JmapError::invalid_arguments("accountId is required"))?;
let id = Id::new_validated(s)
.map_err(|e| JmapError::invalid_arguments(format!("accountId is not a valid Id: {e}")))?;
Ok((id, args))
}
pub fn now_utc_string() -> UTCDate {
now_utc_string_checked().expect("system clock returned an out-of-range value (bd:JMAP-jfia.30)")
}
pub fn now_utc_string_checked() -> Option<UTCDate> {
use std::time::SystemTime;
let (secs, millis) = signed_seconds_since_epoch(SystemTime::now())?;
let s = secs.rem_euclid(60);
let m = (secs / 60).rem_euclid(60);
let h = (secs / 3600).rem_euclid(24);
let days = secs.div_euclid(86400);
let (year, month, day) = civil_from_days(days)?;
Some(UTCDate::from(format!(
"{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{millis:03}Z"
)))
}
fn signed_seconds_since_epoch(now: std::time::SystemTime) -> Option<(i64, u32)> {
use std::time::UNIX_EPOCH;
match now.duration_since(UNIX_EPOCH) {
Ok(d) => {
let s = i64::try_from(d.as_secs()).ok()?;
Some((s, d.subsec_millis()))
}
Err(e) => {
let d = e.duration();
let s = i64::try_from(d.as_secs()).ok()?;
let neg = s.checked_neg()?;
Some((neg, d.subsec_millis()))
}
}
}
fn civil_from_days(z: i64) -> Option<(i32, u8, u8)> {
let z = z + 719_468;
let era: i64 = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let mo = if mp < 10 { mp + 3 } else { mp - 9 }; let yr = if mo <= 2 { y + 1 } else { y };
Some((
i32::try_from(yr).ok()?,
u8::try_from(mo).expect("month bounded by algorithm to [1, 12]"),
u8::try_from(d).expect("day bounded by algorithm to [1, 31]"),
))
}
pub(crate) const MAX_MERGE_PATCH_DEPTH: usize = 32;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergePatchError {
DepthExceeded,
}
impl std::fmt::Display for MergePatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DepthExceeded => write!(
f,
"merge patch nesting exceeds {MAX_MERGE_PATCH_DEPTH} levels"
),
}
}
}
impl std::error::Error for MergePatchError {}
pub fn json_merge_patch(target: &mut Value, patch: Value) -> Result<(), MergePatchError> {
json_merge_patch_inner(target, patch, 0)
}
fn json_merge_patch_inner(
target: &mut Value,
patch: Value,
depth: usize,
) -> Result<(), MergePatchError> {
if depth > MAX_MERGE_PATCH_DEPTH {
return Err(MergePatchError::DepthExceeded);
}
match patch {
Value::Object(patch_map) => {
if !target.is_object() {
*target = Value::Object(Map::new());
}
let target_map = target
.as_object_mut()
.expect("target was just set to Value::Object above");
for (key, patch_val) in patch_map {
if patch_val.is_null() {
target_map.remove(&key);
} else {
let entry = target_map.entry(key).or_insert(Value::Null);
json_merge_patch_inner(entry, patch_val, depth + 1)?;
}
}
}
other => *target = other,
}
Ok(())
}
#[must_use]
pub fn resolve_query_offset(position: i64, total: usize) -> usize {
if position >= 0 {
usize::try_from(position).unwrap_or(usize::MAX).min(total)
} else {
let neg = usize::try_from(position.saturating_neg()).unwrap_or(usize::MAX);
total.saturating_sub(neg)
}
}
pub fn enforce_max_objects_in_set(args: &Map<String, Value>, max: u64) -> Result<(), JmapError> {
let create_count = args
.get("create")
.and_then(|v| v.as_object())
.map_or(0u64, |m| m.len() as u64);
let update_count = args
.get("update")
.and_then(|v| v.as_object())
.map_or(0u64, |m| m.len() as u64);
let destroy_count = args
.get("destroy")
.and_then(|v| v.as_array())
.map_or(0u64, |a| a.len() as u64);
let count = create_count
.saturating_add(update_count)
.saturating_add(destroy_count);
if count > max {
return Err(JmapError::limit("maxObjectsInSet"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
civil_from_days, enforce_max_objects_in_set, extract_account_id, json_merge_patch,
now_utc_string, resolve_query_offset, MergePatchError, MAX_MERGE_PATCH_DEPTH,
};
use serde_json::json;
#[test]
fn extract_account_id_rejects_malformed_id() {
let err = extract_account_id(json!({ "accountId": "" }))
.expect_err("empty accountId must fail validation");
assert_eq!(err.error_type.as_str(), "invalidArguments");
let err = extract_account_id(json!({ "accountId": "my account" }))
.expect_err("space in accountId must fail validation");
assert_eq!(err.error_type.as_str(), "invalidArguments");
let err = extract_account_id(json!({ "accountId": "a\"b" }))
.expect_err("DQUOTE in accountId must fail validation");
assert_eq!(err.error_type.as_str(), "invalidArguments");
let long: String = "a".repeat(256);
let err = extract_account_id(json!({ "accountId": long }))
.expect_err("over-long accountId must fail validation");
assert_eq!(err.error_type.as_str(), "invalidArguments");
}
#[test]
fn extract_account_id_accepts_well_formed_id() {
let (id, rest) = extract_account_id(json!({
"accountId": "u123-abc_DEF",
"ids": ["e1", "e2"]
}))
.expect("well-formed accountId must pass validation");
assert_eq!(id.as_ref(), "u123-abc_DEF");
assert!(rest.contains_key("ids"));
assert!(
!rest.contains_key("accountId"),
"accountId must be removed from args after extraction"
);
}
#[test]
fn civil_from_days_known_dates() {
let cases: &[(i64, (i32, u8, u8))] = &[
(0, (1970, 1, 1)), (365, (1971, 1, 1)), (10957, (2000, 1, 1)), (11016, (2000, 2, 29)), (11017, (2000, 3, 1)), (19358, (2023, 1, 1)), (19722, (2023, 12, 31)), (19782, (2024, 2, 29)), (19783, (2024, 3, 1)), ];
for &(days, expected) in cases {
assert_eq!(
civil_from_days(days),
Some(expected),
"civil_from_days({days}) mismatch"
);
}
}
#[test]
fn civil_from_days_returns_none_on_year_overflow() {
let max_days = i64::MAX / 86400;
assert_eq!(
civil_from_days(max_days),
None,
"i64::MAX / 86400 days must overflow i32 year"
);
let min_days = i64::MIN / 86400;
assert_eq!(
civil_from_days(min_days),
None,
"i64::MIN / 86400 days must overflow i32 year"
);
let year_58m_days = 10_i64 * i64::from(i32::MAX);
let result = civil_from_days(year_58m_days);
assert!(
result.is_some(),
"i32-fitting year must return Some; got {result:?}"
);
}
#[test]
fn now_utc_string_checked_returns_some_on_sane_clock() {
use super::now_utc_string_checked;
let dt = now_utc_string_checked().expect(
"test host clock must be reasonable enough for now_utc_string_checked to succeed",
);
let s: &str = dt.as_ref();
assert_eq!(s.len(), 24, "wire shape must be 24 chars: {s:?}");
assert!(s.ends_with('Z'), "must end with Z: {s:?}");
}
#[test]
fn now_utc_string_matches_checked_variant_on_sane_clock() {
use super::now_utc_string_checked;
let panicky = now_utc_string();
let checked = now_utc_string_checked().expect("test host clock must be reasonable");
let panicky_s: &str = panicky.as_ref();
let checked_s: &str = checked.as_ref();
assert_eq!(
panicky_s.len(),
checked_s.len(),
"wire-format lengths must match: panicky={panicky_s:?} checked={checked_s:?}"
);
assert_eq!(
&panicky_s[..10],
&checked_s[..10],
"date portions must match: panicky={panicky_s:?} checked={checked_s:?}"
);
}
#[test]
fn now_utc_string_format() {
let dt = now_utc_string();
let s: &str = dt.as_ref();
assert_eq!(s.len(), 24, "unexpected length: {s}");
assert!(s.ends_with('Z'), "must end with Z: {s}");
assert_eq!(&s[4..5], "-", "missing year-month separator: {s}");
assert_eq!(&s[7..8], "-", "missing month-day separator: {s}");
assert_eq!(&s[10..11], "T", "missing date-time separator: {s}");
assert_eq!(&s[13..14], ":", "missing hour-minute separator: {s}");
assert_eq!(&s[16..17], ":", "missing minute-second separator: {s}");
assert_eq!(&s[19..20], ".", "missing decimal point before millis: {s}");
assert!(
s[20..23].chars().all(|c| c.is_ascii_digit()),
"milliseconds must be 3 digits: {s}"
);
assert!(
s.starts_with("20"),
"year should start with 20 in 21st century: {s}"
);
}
#[test]
fn json_merge_patch_does_not_stack_overflow() {
const DEPTH: usize = 1000;
let mut target = serde_json::json!({});
for _ in 0..DEPTH {
target = serde_json::json!({ "a": target });
}
let mut patch = serde_json::json!({});
for _ in 0..DEPTH {
patch = serde_json::json!({ "a": patch });
}
let err = json_merge_patch(&mut target, patch)
.expect_err("deep patch must surface DepthExceeded, not silently truncate");
assert_eq!(
err,
MergePatchError::DepthExceeded,
"deep patch must return MergePatchError::DepthExceeded"
);
}
#[test]
fn json_merge_patch_at_exact_cap_applies() {
let mut patch = serde_json::json!({ "leaf": "value" });
for _ in 0..(MAX_MERGE_PATCH_DEPTH - 1) {
patch = serde_json::json!({ "a": patch });
}
let mut target = serde_json::json!({});
json_merge_patch(&mut target, patch).expect("patch at the cap must apply");
let mut cursor = ⌖
for _ in 0..(MAX_MERGE_PATCH_DEPTH - 1) {
cursor = cursor.get("a").expect("each level must have 'a'");
}
assert_eq!(
cursor.get("leaf"),
Some(&serde_json::Value::String("value".to_owned())),
"the leaf field at exactly MAX_MERGE_PATCH_DEPTH must be applied"
);
}
#[test]
fn json_merge_patch_shallow_applies_normally() {
let mut target = serde_json::json!({ "a": 1, "b": { "c": 2 } });
let patch = serde_json::json!({ "b": { "c": 99, "d": 7 }, "e": null });
json_merge_patch(&mut target, patch).expect("shallow patch must succeed");
assert_eq!(
target,
serde_json::json!({ "a": 1, "b": { "c": 99, "d": 7 } }),
"RFC 7396 merge semantics broken at shallow depth"
);
}
#[test]
fn json_merge_patch_adds_nested_object_to_absent_field() {
let mut target = serde_json::json!({ "a": 1 });
let patch = serde_json::json!({ "b": { "c": 2 } });
json_merge_patch(&mut target, patch).expect("nested-add patch must succeed");
assert_eq!(
target,
serde_json::json!({ "a": 1, "b": { "c": 2 } }),
"patch must add the nested object at the previously-absent field"
);
}
#[test]
fn merge_patch_error_display() {
let err = MergePatchError::DepthExceeded;
let s = err.to_string();
assert!(
s.contains(&MAX_MERGE_PATCH_DEPTH.to_string()),
"Display must mention the cap value; got {s:?}"
);
assert!(
s.contains("merge patch"),
"Display must identify the error source; got {s:?}"
);
}
#[test]
fn enforce_max_objects_in_set_empty_args_is_ok() {
let args = json!({}).as_object().unwrap().clone();
enforce_max_objects_in_set(&args, 500).expect("empty args must be under any positive cap");
}
#[test]
fn enforce_max_objects_in_set_at_limit_is_ok() {
let mut create = serde_json::Map::new();
let mut update = serde_json::Map::new();
let mut destroy = Vec::new();
for i in 0..5 {
create.insert(format!("c{i}"), json!({}));
update.insert(format!("u{i}"), json!({}));
destroy.push(json!(format!("d{i}")));
}
let args = json!({
"create": create,
"update": update,
"destroy": destroy,
})
.as_object()
.unwrap()
.clone();
enforce_max_objects_in_set(&args, 15).expect("exact-boundary count must be allowed");
}
#[test]
fn enforce_max_objects_in_set_over_limit_returns_limit_error() {
let mut create = serde_json::Map::new();
let mut update = serde_json::Map::new();
let mut destroy = Vec::new();
for i in 0..5 {
create.insert(format!("c{i}"), json!({}));
update.insert(format!("u{i}"), json!({}));
}
for i in 0..6 {
destroy.push(json!(format!("d{i}")));
}
let args = json!({
"create": create,
"update": update,
"destroy": destroy,
})
.as_object()
.unwrap()
.clone();
let err = enforce_max_objects_in_set(&args, 15)
.expect_err("16-entry args against max=15 must fail");
let expected = jmap_types::JmapError::limit("maxObjectsInSet");
assert_eq!(
err.error_type.as_str(),
expected.error_type.as_str(),
"type must be \"limit\""
);
assert_eq!(
err.description.as_deref(),
Some("maxObjectsInSet"),
"description must name the exceeded cap"
);
}
#[test]
fn enforce_max_objects_in_set_ignores_non_object_create() {
let args = json!({
"create": null,
"update": null,
"destroy": ["id1"],
})
.as_object()
.unwrap()
.clone();
enforce_max_objects_in_set(&args, 1).expect("non-object create/update count as 0");
}
#[test]
fn enforce_max_objects_in_set_ignores_non_array_destroy() {
let args = json!({
"create": { "c0": {} },
"destroy": "not-an-array",
})
.as_object()
.unwrap()
.clone();
enforce_max_objects_in_set(&args, 1).expect("non-array destroy counts as 0");
}
#[test]
fn enforce_max_objects_in_set_sums_all_three() {
let args = json!({
"create": { "c0": {}, "c1": {}, "c2": {} },
"update": { "u0": {}, "u1": {}, "u2": {} },
"destroy": ["d0", "d1", "d2"],
})
.as_object()
.unwrap()
.clone();
let err =
enforce_max_objects_in_set(&args, 5).expect_err("sum-of-all-three must trip the cap");
assert_eq!(err.error_type.as_str(), "limit");
assert_eq!(err.description.as_deref(), Some("maxObjectsInSet"));
}
#[test]
fn resolve_query_offset_absolute() {
assert_eq!(resolve_query_offset(0, 100), 0);
assert_eq!(resolve_query_offset(1, 100), 1);
assert_eq!(resolve_query_offset(50, 100), 50);
assert_eq!(resolve_query_offset(99, 100), 99);
assert_eq!(resolve_query_offset(100, 100), 100);
assert_eq!(resolve_query_offset(101, 100), 100);
assert_eq!(resolve_query_offset(10_000, 100), 100);
assert_eq!(resolve_query_offset(i64::MAX, 100), 100);
}
#[test]
fn resolve_query_offset_end_relative() {
assert_eq!(resolve_query_offset(-1, 100), 99);
assert_eq!(resolve_query_offset(-2, 100), 98);
assert_eq!(resolve_query_offset(-99, 100), 1);
assert_eq!(resolve_query_offset(-100, 100), 0);
assert_eq!(resolve_query_offset(-101, 100), 0);
assert_eq!(resolve_query_offset(-10_000, 100), 0);
}
#[test]
fn resolve_query_offset_empty_total() {
assert_eq!(resolve_query_offset(0, 0), 0);
assert_eq!(resolve_query_offset(1, 0), 0);
assert_eq!(resolve_query_offset(-1, 0), 0);
assert_eq!(resolve_query_offset(i64::MAX, 0), 0);
assert_eq!(resolve_query_offset(i64::MIN, 0), 0);
}
#[test]
fn resolve_query_offset_i64_min_does_not_overflow() {
assert_eq!(resolve_query_offset(i64::MIN, 1), 0);
assert_eq!(resolve_query_offset(i64::MIN, 100), 0);
assert_eq!(resolve_query_offset(i64::MIN, i64::MAX as usize), 0);
let start = resolve_query_offset(i64::MIN, usize::MAX);
assert!(start <= usize::MAX);
assert_eq!(start, usize::MAX - (i64::MAX as usize));
assert_eq!(resolve_query_offset(i64::MAX, 0), 0);
assert_eq!(resolve_query_offset(i64::MAX, 1), 1);
}
#[test]
fn resolve_query_offset_truncation_resistant() {
for &pos in &[i64::MIN, -1_000_000, -1, 0, 1, 1_000_000, i64::MAX] {
for &total in &[0usize, 1, 100, 10_000] {
let start = resolve_query_offset(pos, total);
assert!(
start <= total,
"resolve_query_offset({pos}, {total}) = {start} violates start <= total"
);
}
}
}
}