baseid_mdl/
mdoc.rs

1//! mdoc (mobile document) types per ISO 18013-5.
2
3use baseid_core::error::SerializationError;
4use serde::{Deserialize, Serialize};
5
6/// A mobile document (mdoc) as defined in ISO 18013-5.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct MobileDocument {
9    /// Document type (e.g., "org.iso.18013.5.1.mDL").
10    pub doc_type: String,
11    /// Namespaced data elements.
12    pub namespaces: Namespaces,
13}
14
15/// Namespaced data elements within an mdoc.
16pub type Namespaces = std::collections::BTreeMap<String, Vec<DataElement>>;
17
18/// A single data element within a namespace.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct DataElement {
21    /// Element identifier (e.g., "family_name", "document_number").
22    pub identifier: String,
23    /// Element value (CBOR-encoded in the wire format).
24    pub value: serde_json::Value,
25}
26
27impl MobileDocument {
28    /// Serialize this document to CBOR bytes.
29    pub fn to_cbor(&self) -> baseid_core::Result<Vec<u8>> {
30        let mut buf = Vec::new();
31        ciborium::into_writer(self, &mut buf).map_err(|_| SerializationError::Cbor)?;
32        Ok(buf)
33    }
34
35    /// Deserialize a document from CBOR bytes.
36    pub fn from_cbor(bytes: &[u8]) -> baseid_core::Result<Self> {
37        ciborium::from_reader(bytes).map_err(|_| SerializationError::Cbor.into())
38    }
39}
40
41impl DataElement {
42    /// Encode this individual data element to CBOR bytes.
43    ///
44    /// Used for computing MSO digests — each element is independently
45    /// CBOR-encoded then hashed.
46    pub fn to_cbor(&self) -> baseid_core::Result<Vec<u8>> {
47        let mut buf = Vec::new();
48        ciborium::into_writer(self, &mut buf).map_err(|_| SerializationError::Cbor)?;
49        Ok(buf)
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use std::collections::BTreeMap;
57
58    fn sample_doc() -> MobileDocument {
59        let mut namespaces = BTreeMap::new();
60        namespaces.insert(
61            "org.iso.18013.5.1".to_string(),
62            vec![
63                DataElement {
64                    identifier: "family_name".to_string(),
65                    value: serde_json::json!("Doe"),
66                },
67                DataElement {
68                    identifier: "given_name".to_string(),
69                    value: serde_json::json!("John"),
70                },
71                DataElement {
72                    identifier: "document_number".to_string(),
73                    value: serde_json::json!("DL-123456"),
74                },
75            ],
76        );
77        MobileDocument {
78            doc_type: "org.iso.18013.5.1.mDL".to_string(),
79            namespaces,
80        }
81    }
82
83    #[test]
84    fn cbor_roundtrip() {
85        let doc = sample_doc();
86        let cbor = doc.to_cbor().unwrap();
87        let restored = MobileDocument::from_cbor(&cbor).unwrap();
88        assert_eq!(restored.doc_type, doc.doc_type);
89        assert_eq!(restored.namespaces.len(), 1);
90
91        let ns = &restored.namespaces["org.iso.18013.5.1"];
92        assert_eq!(ns.len(), 3);
93        assert_eq!(ns[0].identifier, "family_name");
94        assert_eq!(ns[0].value, serde_json::json!("Doe"));
95    }
96
97    #[test]
98    fn data_element_cbor() {
99        let elem = DataElement {
100            identifier: "family_name".to_string(),
101            value: serde_json::json!("Smith"),
102        };
103        let cbor = elem.to_cbor().unwrap();
104        assert!(!cbor.is_empty());
105
106        // Same element produces same CBOR
107        let cbor2 = elem.to_cbor().unwrap();
108        assert_eq!(cbor, cbor2);
109    }
110
111    // --- C3: CBOR interop via raw ciborium::Value parsing ---
112
113    #[test]
114    fn cbor_parseable_by_raw_ciborium() {
115        let doc = sample_doc();
116        let cbor = doc.to_cbor().unwrap();
117
118        // Parse as raw ciborium::Value — verifies valid CBOR structure
119        let raw: ciborium::Value = ciborium::from_reader(&cbor[..]).unwrap();
120
121        // Should be a CBOR map
122        let map = raw.as_map().expect("top-level should be a CBOR map");
123        assert!(map.len() >= 2, "should have doc_type and namespaces");
124
125        // Find doc_type
126        let doc_type_entry = map.iter().find(|(k, _)| k.as_text() == Some("doc_type"));
127        assert!(doc_type_entry.is_some(), "should have doc_type key");
128        let (_, doc_type_val) = doc_type_entry.unwrap();
129        assert_eq!(doc_type_val.as_text(), Some("org.iso.18013.5.1.mDL"));
130    }
131
132    #[test]
133    fn data_element_cbor_parseable_by_raw_ciborium() {
134        let elem = DataElement {
135            identifier: "given_name".to_string(),
136            value: serde_json::json!("Jane"),
137        };
138        let cbor = elem.to_cbor().unwrap();
139
140        // Parse raw — should be a map with "identifier" and "value" keys
141        let raw: ciborium::Value = ciborium::from_reader(&cbor[..]).unwrap();
142        let map = raw.as_map().expect("element should be a CBOR map");
143
144        let id_entry = map.iter().find(|(k, _)| k.as_text() == Some("identifier"));
145        assert!(id_entry.is_some());
146        assert_eq!(id_entry.unwrap().1.as_text(), Some("given_name"));
147
148        let val_entry = map.iter().find(|(k, _)| k.as_text() == Some("value"));
149        assert!(val_entry.is_some());
150        assert_eq!(val_entry.unwrap().1.as_text(), Some("Jane"));
151    }
152
153    #[test]
154    fn cbor_roundtrip_preserves_complex_values() {
155        let mut namespaces = BTreeMap::new();
156        namespaces.insert(
157            "org.iso.18013.5.1".to_string(),
158            vec![
159                DataElement {
160                    identifier: "age_over_18".to_string(),
161                    value: serde_json::json!(true),
162                },
163                DataElement {
164                    identifier: "age_in_years".to_string(),
165                    value: serde_json::json!(25),
166                },
167                DataElement {
168                    identifier: "portrait".to_string(),
169                    value: serde_json::json!(null),
170                },
171            ],
172        );
173        let doc = MobileDocument {
174            doc_type: "org.iso.18013.5.1.mDL".to_string(),
175            namespaces,
176        };
177
178        let cbor = doc.to_cbor().unwrap();
179        let restored = MobileDocument::from_cbor(&cbor).unwrap();
180
181        assert_eq!(
182            restored.namespaces["org.iso.18013.5.1"][0].value,
183            serde_json::json!(true)
184        );
185        assert_eq!(
186            restored.namespaces["org.iso.18013.5.1"][1].value,
187            serde_json::json!(25)
188        );
189        assert_eq!(
190            restored.namespaces["org.iso.18013.5.1"][2].value,
191            serde_json::json!(null)
192        );
193    }
194}