Skip to main content

jmap_types/
id.rs

1//! RFC 8620 §1.2/§1.4 opaque string newtypes: [`Id`], [`UTCDate`], [`Date`], [`State`].
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Error returned by the fallible constructors [`Id::new_validated`],
7/// [`UTCDate::new_validated`], and [`State::new_validated`].
8#[derive(Debug, Clone, PartialEq, Eq)]
9#[non_exhaustive]
10pub struct ValidationError(pub String);
11
12impl fmt::Display for ValidationError {
13    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14        f.write_str(&self.0)
15    }
16}
17
18impl std::error::Error for ValidationError {}
19
20/// Opaque non-empty server-assigned identifier (RFC 8620 §1.2).
21///
22/// Character set: URL-safe base64 alphabet (A-Za-z0-9, `-`, `_`), max 255 octets.
23/// Clients MUST treat Id values as opaque strings — no parsing of structure.
24// #[non_exhaustive] prevents callers from pattern-matching the inner field
25// (e.g. `let Id(s) = id;`), preserving semver freedom to add fields later.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(transparent)]
28#[non_exhaustive]
29pub struct Id(String);
30
31/// RFC 3339 UTC timestamp string (RFC 8620 §1.4).
32///
33/// Format: `YYYY-MM-DDTHH:MM:SSZ` — time-offset MUST be `Z`, letters uppercase,
34/// fractional seconds omitted if zero. Example: `"2014-10-30T06:12:00Z"`.
35// #[non_exhaustive] prevents callers from pattern-matching the inner field,
36// preserving semver freedom to add fields later.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(transparent)]
39#[non_exhaustive]
40pub struct UTCDate(String);
41
42/// RFC 3339 date-time string with any timezone offset (RFC 8620 §1.4).
43///
44/// Format: `YYYY-MM-DDTHH:MM:SS±HH:MM` or `Z` suffix — any valid RFC 3339 offset,
45/// letters uppercase, fractional seconds omitted if zero.
46/// Example: `"2014-10-30T14:12:00+08:00"`.
47///
48/// Distinct from [`UTCDate`], which requires the time-offset to be `Z`.
49/// Use `Date` for fields derived from RFC 5322 email headers (e.g. `sentAt`),
50/// which commonly carry non-UTC offsets.
51// #[non_exhaustive] prevents callers from pattern-matching the inner field,
52// preserving semver freedom to add fields later.
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
54#[serde(transparent)]
55#[non_exhaustive]
56pub struct Date(String);
57
58/// Opaque server state token (RFC 8620 §1.2).
59///
60/// Returned by `/get` and `/changes` methods. Clients echo it back in
61/// `sinceState` / `ifInState` parameters. Treat as opaque — no structure assumed.
62// #[non_exhaustive] prevents callers from pattern-matching the inner field,
63// preserving semver freedom to add fields later.
64#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[serde(transparent)]
66#[non_exhaustive]
67pub struct State(String);
68
69/// Generates `Display`, `From<String>`, `From<&str>`, `AsRef<str>`,
70/// `PartialEq<str>`, `PartialEq<&str>`, and `into_inner` for a transparent
71/// `String` newtype.
72macro_rules! impl_string_newtype {
73    ($T:ident) => {
74        impl fmt::Display for $T {
75            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76                f.write_str(&self.0)
77            }
78        }
79        impl From<String> for $T {
80            fn from(s: String) -> Self {
81                Self(s)
82            }
83        }
84        impl From<&str> for $T {
85            fn from(s: &str) -> Self {
86                Self(s.to_owned())
87            }
88        }
89        impl AsRef<str> for $T {
90            fn as_ref(&self) -> &str {
91                &self.0
92            }
93        }
94        impl PartialEq<str> for $T {
95            fn eq(&self, other: &str) -> bool {
96                self.0 == other
97            }
98        }
99        impl PartialEq<&str> for $T {
100            fn eq(&self, other: &&str) -> bool {
101                self.0 == *other
102            }
103        }
104        impl std::borrow::Borrow<str> for $T {
105            fn borrow(&self) -> &str {
106                &self.0
107            }
108        }
109        impl $T {
110            /// Consumes the value and returns the inner `String`.
111            pub fn into_inner(self) -> String {
112                self.0
113            }
114        }
115    };
116}
117
118impl_string_newtype!(Id);
119impl_string_newtype!(UTCDate);
120impl_string_newtype!(Date);
121impl_string_newtype!(State);
122
123// ---------------------------------------------------------------------------
124// Fallible constructors — validate RFC 8620 constraints at the boundary.
125//
126// These are named constructors (not TryFrom impls) because Id/UTCDate/State
127// already implement From<String> and From<&str>.  Rust's blanket impl
128// `impl<T,U> TryFrom<U> where U: Into<T>` would make TryFrom<String>
129// infallible (Error = Infallible) via the existing From impl, making it
130// impossible to add a second, fallible TryFrom<String>.  Named constructors
131// achieve the same goal without the conflict.
132// ---------------------------------------------------------------------------
133
134/// Validate an [`Id`] string per RFC 8620 §1.2.
135///
136/// SAFE-CHAR = %x21 / %x23-7E (visible ASCII, no SPACE, no DEL, no DQUOTE).
137/// Must be non-empty and at most 255 bytes.
138fn validate_id(s: &str) -> Result<(), ValidationError> {
139    if s.is_empty() {
140        return Err(ValidationError("Id must not be empty".into()));
141    }
142    if s.len() > 255 {
143        return Err(ValidationError(format!(
144            "Id exceeds 255 bytes (got {})",
145            s.len()
146        )));
147    }
148    for ch in s.chars() {
149        let b = ch as u32;
150        // SAFE-CHAR: 0x21 through 0x7E, excluding 0x22 (DQUOTE).
151        if !(0x21..=0x7E).contains(&b) || b == 0x22 {
152            return Err(ValidationError(format!(
153                "Id contains invalid character {:?} (U+{b:04X})",
154                ch
155            )));
156        }
157    }
158    Ok(())
159}
160
161/// Validate a [`UTCDate`] string per RFC 8620 §1.4.
162///
163/// Required format: `YYYY-MM-DDTHH:MM:SSZ` (exactly 20 characters, `Z` suffix,
164/// all digit positions ASCII digits). No external crate needed.
165fn validate_utcdate(s: &str) -> Result<(), ValidationError> {
166    if s.len() != 20 {
167        return Err(ValidationError(format!(
168            "UTCDate must be exactly 20 characters (YYYY-MM-DDTHH:MM:SSZ), got {:?}",
169            s
170        )));
171    }
172    let b = s.as_bytes();
173    // Fixed separators: dashes, T, colons, Z.
174    if b[4] != b'-'
175        || b[7] != b'-'
176        || b[10] != b'T'
177        || b[13] != b':'
178        || b[16] != b':'
179        || b[19] != b'Z'
180    {
181        return Err(ValidationError(format!(
182            "UTCDate has wrong structure, expected YYYY-MM-DDTHH:MM:SSZ, got {:?}",
183            s
184        )));
185    }
186    // Digit positions: 0-3 (year), 5-6 (month), 8-9 (day),
187    //                  11-12 (hour), 14-15 (min), 17-18 (sec).
188    for &pos in &[0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18] {
189        if !b[pos].is_ascii_digit() {
190            return Err(ValidationError(format!(
191                "UTCDate position {} is not a digit in {:?}",
192                pos, s
193            )));
194        }
195    }
196    Ok(())
197}
198
199/// Validate a [`State`] string: must be non-empty.
200///
201/// RFC 8620 §1.2 does not restrict the character set for State beyond
202/// requiring it to be non-empty.
203fn validate_state(s: &str) -> Result<(), ValidationError> {
204    if s.is_empty() {
205        return Err(ValidationError("State must not be empty".into()));
206    }
207    Ok(())
208}
209
210impl Id {
211    /// Construct an [`Id`] with RFC 8620 §1.2 syntax validation.
212    ///
213    /// Rejects empty strings, strings longer than 255 bytes, and strings
214    /// containing characters outside the SAFE-CHAR set (`%x21 / %x23-7E` —
215    /// visible ASCII excluding `"`).
216    ///
217    /// Use [`Id::from`] when the value is known to be valid (e.g. a string
218    /// received from a JMAP server response).
219    pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
220        let s = s.into();
221        validate_id(&s)?;
222        Ok(Self(s))
223    }
224}
225
226impl UTCDate {
227    /// Construct a [`UTCDate`] with RFC 8620 §1.4 format validation.
228    ///
229    /// Requires exactly the format `YYYY-MM-DDTHH:MM:SSZ` (20 characters,
230    /// `Z` suffix, all numeric fields are ASCII digits).
231    ///
232    /// Use [`UTCDate::from`] when the value is known to be valid.
233    pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
234        let s = s.into();
235        validate_utcdate(&s)?;
236        Ok(Self(s))
237    }
238}
239
240impl State {
241    /// Construct a [`State`] with RFC 8620 §1.2 validation.
242    ///
243    /// Rejects empty strings. RFC 8620 §1.2 requires State to be non-empty;
244    /// no character-set restriction is imposed.
245    ///
246    /// Use [`State::from`] when the value is known to be valid.
247    pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
248        let s = s.into();
249        validate_state(&s)?;
250        Ok(Self(s))
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    // Oracle: RFC 8620 §1.2 — Id is a plain JSON string, not a wrapped object.
259    #[test]
260    fn id_serializes_as_plain_string() {
261        let id = Id("abc123".to_owned());
262        let json = serde_json::to_string(&id).expect("serialize Id");
263        assert_eq!(json, "\"abc123\"");
264    }
265
266    // Oracle: RFC 8620 §1.2 — Id round-trips through JSON.
267    #[test]
268    fn id_deserializes_from_plain_string() {
269        let id: Id = serde_json::from_str("\"abc123\"").expect("deserialize Id");
270        assert_eq!(id.as_ref(), "abc123");
271    }
272
273    // Oracle: RFC 8620 §1.4 example — "2014-10-30T06:12:00Z".
274    #[test]
275    fn utcdate_serializes_as_plain_string() {
276        let d = UTCDate("2014-10-30T06:12:00Z".to_owned());
277        let json = serde_json::to_string(&d).expect("serialize UTCDate");
278        assert_eq!(json, "\"2014-10-30T06:12:00Z\"");
279    }
280
281    // Oracle: RFC 8620 §3.4.1 fixture — sessionState value is "75128aab4b1b".
282    #[test]
283    fn state_serializes_as_plain_string() {
284        let s = State("75128aab4b1b".to_owned());
285        let json = serde_json::to_string(&s).expect("serialize State");
286        assert_eq!(json, "\"75128aab4b1b\"");
287    }
288
289    // Oracle: From<&str> trait contract.
290    #[test]
291    fn id_from_str() {
292        let id = Id::from("hello");
293        assert_eq!(id.as_ref(), "hello");
294    }
295
296    // Oracle: Display delegates to inner String.
297    #[test]
298    fn id_display() {
299        let id = Id("display-test".to_owned());
300        assert_eq!(id.to_string(), "display-test");
301    }
302
303    // Oracle: AsRef<str> returns the inner string.
304    #[test]
305    fn id_as_ref_str() {
306        let id = Id("ref-test".to_owned());
307        assert_eq!(id.as_ref(), "ref-test");
308    }
309
310    // Oracle: RFC 8620 §3.4.1 — State in sessionState field round-trips correctly.
311    #[test]
312    fn state_round_trip() {
313        let s = State("75128aab4b1b".to_owned());
314        let json = serde_json::to_string(&s).expect("serialize");
315        let s2: State = serde_json::from_str(&json).expect("deserialize");
316        assert_eq!(s, s2);
317    }
318
319    // Oracle: RFC 8620 §1.4 example — Date allows non-UTC offsets, unlike UTCDate.
320    #[test]
321    fn date_accepts_non_utc_offset() {
322        let d = Date("2014-10-30T14:12:00+08:00".to_owned());
323        let json = serde_json::to_string(&d).expect("serialize Date");
324        assert_eq!(json, "\"2014-10-30T14:12:00+08:00\"");
325        let d2: Date = serde_json::from_str(&json).expect("deserialize Date");
326        assert_eq!(d, d2);
327    }
328
329    // -----------------------------------------------------------------------
330    // new_validated / ValidationError tests
331    // Oracle for all: RFC 8620 §1.2 (Id, State) and §1.4 (UTCDate).
332    // -----------------------------------------------------------------------
333
334    /// Oracle: RFC 8620 §1.2 — Id must not be empty.
335    #[test]
336    fn id_new_validated_empty_fails() {
337        let err = Id::new_validated("").unwrap_err();
338        assert!(err.0.contains("empty"), "error must mention 'empty': {err}");
339    }
340
341    /// Oracle: RFC 8620 §1.2 SAFE-CHAR — space (0x20) is not allowed.
342    #[test]
343    fn id_new_validated_space_fails() {
344        let err = Id::new_validated("has space").unwrap_err();
345        assert!(err.0.contains("invalid character"), "{err}");
346    }
347
348    /// Oracle: RFC 8620 §1.2 SAFE-CHAR — double-quote (0x22) is excluded.
349    #[test]
350    fn id_new_validated_dquote_fails() {
351        let err = Id::new_validated("has\"quote").unwrap_err();
352        assert!(err.0.contains("invalid character"), "{err}");
353    }
354
355    /// Oracle: control character (0x01) is not in SAFE-CHAR.
356    #[test]
357    fn id_new_validated_control_char_fails() {
358        let err = Id::new_validated("has\x01ctrl").unwrap_err();
359        assert!(err.0.contains("invalid character"), "{err}");
360    }
361
362    /// Oracle: RFC 8620 §1.2 — max length 255 bytes.
363    #[test]
364    fn id_new_validated_too_long_fails() {
365        let long = "a".repeat(256);
366        assert!(Id::new_validated(long).is_err());
367    }
368
369    /// Oracle: valid printable ASCII Id succeeds and is preserved verbatim.
370    #[test]
371    fn id_new_validated_valid_succeeds() {
372        let id = Id::new_validated("abc123-_ABC").expect("valid Id must succeed");
373        assert_eq!(id.as_ref(), "abc123-_ABC");
374    }
375
376    /// Oracle: exactly 255-byte Id succeeds.
377    #[test]
378    fn id_new_validated_max_length_succeeds() {
379        let id255 = "a".repeat(255);
380        Id::new_validated(id255).expect("255-byte Id must succeed");
381    }
382
383    /// Oracle: RFC 8620 §1.4 example "2014-10-30T06:12:00Z" must succeed.
384    #[test]
385    fn utcdate_new_validated_valid_succeeds() {
386        let d = UTCDate::new_validated("2014-10-30T06:12:00Z").expect("valid UTCDate must succeed");
387        assert_eq!(d.as_ref(), "2014-10-30T06:12:00Z");
388    }
389
390    /// Oracle: UTC date without Z suffix is not RFC 8620 §1.4 format.
391    #[test]
392    fn utcdate_new_validated_no_z_fails() {
393        assert!(UTCDate::new_validated("2014-10-30T06:12:00+00:00").is_err());
394    }
395
396    /// Oracle: empty UTCDate fails.
397    #[test]
398    fn utcdate_new_validated_empty_fails() {
399        assert!(UTCDate::new_validated("").is_err());
400    }
401
402    /// Oracle: UTCDate with wrong length fails.
403    #[test]
404    fn utcdate_new_validated_wrong_length_fails() {
405        assert!(UTCDate::new_validated("2014-10-30").is_err());
406        // fractional seconds are not permitted in RFC 8620 §1.4 format.
407        assert!(UTCDate::new_validated("2014-10-30T06:12:00.000Z").is_err());
408    }
409
410    /// Oracle: non-digit in year position fails.
411    #[test]
412    fn utcdate_new_validated_non_digit_fails() {
413        assert!(UTCDate::new_validated("XXXX-10-30T06:12:00Z").is_err());
414    }
415
416    /// Oracle: RFC 8620 §1.2 — State must be non-empty.
417    #[test]
418    fn state_new_validated_empty_fails() {
419        let err = State::new_validated("").unwrap_err();
420        assert!(err.0.contains("empty"), "{err}");
421    }
422
423    /// Oracle: non-empty State string succeeds.
424    #[test]
425    fn state_new_validated_valid_succeeds() {
426        let s = State::new_validated("75128aab4b1b").expect("valid State must succeed");
427        assert_eq!(s.as_ref(), "75128aab4b1b");
428    }
429
430    /// Oracle: ValidationError implements std::error::Error and Display.
431    #[test]
432    fn validation_error_implements_error() {
433        let e = Id::new_validated("").unwrap_err();
434        let _: &dyn std::error::Error = &e;
435        assert!(!e.to_string().is_empty(), "error message must not be empty");
436        assert_eq!(format!("{e}"), e.0, "Display must show the inner message");
437    }
438}