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}