Skip to main content

jmap_contacts_types/
collision.rs

1//! Runtime detection of vendor-extras key collisions with typed fields.
2//!
3//! The workspace extras-preservation policy (see workspace AGENTS.md)
4//! exposes a `#[serde(flatten)] extra: serde_json::Map<String, Value>`
5//! field on every data-object type. A caller who programmatically
6//! inserts a key into `extra` that matches one of the typed
7//! wire-format field names produces a **duplicate JSON object key** on
8//! serialize. RFC 8259 ยง4 leaves duplicate-key handling
9//! implementation-defined; cross-implementation behavior varies
10//! (serde_json last-wins, some validators reject, audit-log scrapers
11//! may sample the first occurrence).
12//!
13//! The `validate_extras()` method on each affected type is a defensive
14//! pre-serialize check that returns [`CollisionError`] if any `extra`
15//! key shadows a typed field. Recommended usage:
16//!
17//! ```rust
18//! # use jmap_contacts_types::{ContactCard, CollisionError};
19//! # let card = ContactCard::default();
20//! card.validate_extras()?;
21//! let wire = serde_json::to_string(&card).unwrap();
22//! # Ok::<(), CollisionError>(())
23//! ```
24//!
25//! This helper exists because Rust cannot enforce "do not insert this
26//! key" at compile time on a `pub` `serde_json::Map`. JMAP-glx8.25.
27
28use std::fmt;
29
30/// Error returned by `validate_extras()` when the `extra` map contains
31/// one or more keys that shadow typed wire-format fields on the same
32/// struct. The colliding keys are reported in deterministic
33/// alphabetical order.
34///
35/// `#[non_exhaustive]` matches the workspace pattern set by
36/// `jmap_mail_types::KeywordError` and the foundation error types
37/// (`JmapError`, `MethodResponseError`, `SetError`): a future field or
38/// variant addition (for example, an `account_id` field to disambiguate
39/// cross-account collisions) must remain a non-breaking semver change.
40#[non_exhaustive]
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct CollisionError {
43    /// The colliding keys, sorted alphabetically for stable error
44    /// messages and snapshot tests.
45    keys: Vec<String>,
46}
47
48impl CollisionError {
49    /// Construct a `CollisionError` from an iterator of colliding key
50    /// names. The keys are deduplicated and sorted alphabetically.
51    pub(crate) fn new<I, S>(keys: I) -> Self
52    where
53        I: IntoIterator<Item = S>,
54        S: Into<String>,
55    {
56        let mut keys: Vec<String> = keys.into_iter().map(Into::into).collect();
57        keys.sort();
58        keys.dedup();
59        Self { keys }
60    }
61
62    /// The colliding key names, in alphabetical order.
63    #[must_use]
64    pub fn keys(&self) -> &[String] {
65        &self.keys
66    }
67}
68
69impl fmt::Display for CollisionError {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(
72            f,
73            "vendor-extras key(s) collide with typed wire-format field(s): {}",
74            self.keys.join(", ")
75        )
76    }
77}
78
79impl std::error::Error for CollisionError {}
80
81/// Internal helper: check a `serde_json::Map` against a slice of
82/// reserved typed field names. Returns `Ok(())` when no key in the
83/// map matches a reserved name; otherwise returns a `CollisionError`
84/// listing every collision.
85pub(crate) fn check(
86    extra: &serde_json::Map<String, serde_json::Value>,
87    reserved: &[&str],
88) -> Result<(), CollisionError> {
89    let collisions: Vec<&str> = reserved
90        .iter()
91        .copied()
92        .filter(|name| extra.contains_key(*name))
93        .collect();
94    if collisions.is_empty() {
95        Ok(())
96    } else {
97        Err(CollisionError::new(collisions))
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn collision_error_display_lists_keys_alphabetically() {
107        let err = CollisionError::new(["uid", "id", "version"]);
108        assert_eq!(err.keys(), &["id", "uid", "version"]);
109        let msg = err.to_string();
110        assert!(msg.contains("id, uid, version"), "got: {msg}");
111    }
112
113    #[test]
114    fn collision_error_dedups_duplicate_input_keys() {
115        let err = CollisionError::new(["id", "id", "uid"]);
116        assert_eq!(err.keys(), &["id", "uid"]);
117    }
118
119    #[test]
120    fn check_returns_ok_on_clean_extras() {
121        let mut extra = serde_json::Map::new();
122        extra.insert("acmeCorpFoo".into(), serde_json::json!("bar"));
123        assert!(check(&extra, &["id", "uid"]).is_ok());
124    }
125
126    #[test]
127    fn check_returns_ok_on_empty_extras() {
128        let extra = serde_json::Map::new();
129        assert!(check(&extra, &["id", "uid"]).is_ok());
130    }
131
132    #[test]
133    fn check_returns_err_on_single_collision() {
134        let mut extra = serde_json::Map::new();
135        extra.insert("uid".into(), serde_json::json!("attacker"));
136        let err = check(&extra, &["id", "uid"]).expect_err("collision must error");
137        assert_eq!(err.keys(), &["uid"]);
138    }
139
140    #[test]
141    fn check_returns_err_on_multiple_collisions() {
142        let mut extra = serde_json::Map::new();
143        extra.insert("uid".into(), serde_json::json!("a"));
144        extra.insert("id".into(), serde_json::json!("b"));
145        extra.insert("acmeCorpFoo".into(), serde_json::json!("c"));
146        let err = check(&extra, &["id", "uid", "version"]).expect_err("collisions must error");
147        // Both colliding reserved names returned, alphabetically.
148        assert_eq!(err.keys(), &["id", "uid"]);
149    }
150}