Skip to main content

act_types/
cbor.rs

1// CBOR <-> JSON/serde conversion utilities.
2//
3// Note: ciborium produces standard CBOR, not strict dCBOR (RFC 8949 §4.2).
4// For JSON-originating data this is practically deterministic, but does not
5// guarantee shortest integer encoding, sorted map keys, or preferred floats.
6
7/// Encode a serializable value as CBOR bytes.
8pub fn to_cbor<T: serde::Serialize>(value: &T) -> Vec<u8> {
9    let mut buf = Vec::new();
10    ciborium::into_writer(value, &mut buf).expect("CBOR serialization should not fail");
11    buf
12}
13
14/// Decode CBOR bytes into a deserializable value.
15pub fn from_cbor<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, CborError> {
16    ciborium::from_reader(bytes).map_err(|e| CborError(format!("CBOR decode failed: {e}")))
17}
18
19/// Convert a JSON value to a CBOR value, decoding the canonical `{"$bytes":…}`
20/// wrapper to a byte string and unescaping `$$`-prefixed map keys.
21fn json_to_cbor_value(v: &serde_json::Value) -> Result<ciborium::value::Value, CborError> {
22    use ciborium::value::Value as C;
23    Ok(match v {
24        serde_json::Value::Null => C::Null,
25        serde_json::Value::Bool(b) => C::Bool(*b),
26        serde_json::Value::Number(n) => {
27            if let Some(i) = n.as_i64() {
28                C::Integer(i.into())
29            } else if let Some(u) = n.as_u64() {
30                C::Integer(u.into())
31            } else if let Some(f) = n.as_f64() {
32                C::Float(f)
33            } else {
34                return Err(CborError("unrepresentable JSON number".into()));
35            }
36        }
37        serde_json::Value::String(s) => C::Text(s.clone()),
38        serde_json::Value::Array(arr) => {
39            let mut out = Vec::with_capacity(arr.len());
40            for item in arr {
41                out.push(json_to_cbor_value(item)?);
42            }
43            C::Array(out)
44        }
45        serde_json::Value::Object(map) => {
46            if let Some(bytes) = decode_bytes_wrapper(map)? {
47                C::Bytes(bytes)
48            } else {
49                let mut entries = Vec::with_capacity(map.len());
50                for (k, val) in map {
51                    entries.push((C::Text(unescape_key(k)), json_to_cbor_value(val)?));
52                }
53                C::Map(entries)
54            }
55        }
56    })
57}
58
59/// Convert a JSON value to CBOR bytes.
60pub fn json_to_cbor(value: &serde_json::Value) -> Result<Vec<u8>, CborError> {
61    let cbor = json_to_cbor_value(value)?;
62    let mut buf = Vec::new();
63    ciborium::into_writer(&cbor, &mut buf)
64        .map_err(|e| CborError(format!("JSON→CBOR encode failed: {e}")))?;
65    Ok(buf)
66}
67
68/// Convert a decoded CBOR value to a JSON value, wrapping byte strings as the
69/// canonical `{"$bytes": "<base64>"}` and escaping literal `$`-prefixed map keys.
70fn cbor_value_to_json(v: &ciborium::value::Value) -> Result<serde_json::Value, CborError> {
71    use ciborium::value::Value as C;
72    Ok(match v {
73        C::Null => serde_json::Value::Null,
74        C::Bool(b) => serde_json::Value::Bool(*b),
75        C::Integer(i) => {
76            let n: i128 = i128::from(*i);
77            if let Ok(i) = i64::try_from(n) {
78                serde_json::Value::Number(i.into())
79            } else if let Ok(u) = u64::try_from(n) {
80                serde_json::Value::Number(u.into())
81            } else {
82                return Err(CborError(format!(
83                    "CBOR integer {n} is outside JSON-safe range"
84                )));
85            }
86        }
87        C::Float(f) => serde_json::Number::from_f64(*f)
88            .map(serde_json::Value::Number)
89            .ok_or_else(|| CborError(format!("non-finite float cannot project to JSON: {f}")))?,
90        C::Text(s) => serde_json::Value::String(s.clone()),
91        C::Bytes(b) => {
92            use base64::Engine as _;
93            let mut obj = serde_json::Map::new();
94            obj.insert(
95                "$bytes".to_string(),
96                serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(b)),
97            );
98            serde_json::Value::Object(obj)
99        }
100        C::Array(arr) => {
101            let mut out = Vec::with_capacity(arr.len());
102            for item in arr {
103                out.push(cbor_value_to_json(item)?);
104            }
105            serde_json::Value::Array(out)
106        }
107        C::Map(entries) => {
108            let mut obj = serde_json::Map::new();
109            for (k, val) in entries {
110                let key = match k {
111                    C::Text(s) => escape_key(s),
112                    _ => {
113                        return Err(CborError(
114                            "non-string CBOR map key cannot project to JSON".into(),
115                        ));
116                    }
117                };
118                obj.insert(key, cbor_value_to_json(val)?);
119            }
120            serde_json::Value::Object(obj)
121        }
122        // CBOR tags are stripped; the tagged value's content is preserved. TODO: handle known tags (e.g. datetime).
123        C::Tag(_, inner) => cbor_value_to_json(inner)?,
124        _ => return Err(CborError("unsupported CBOR value type".into())),
125    })
126}
127
128/// Convert CBOR bytes to a JSON value.
129pub fn cbor_to_json(bytes: &[u8]) -> Result<serde_json::Value, CborError> {
130    let value: ciborium::value::Value = ciborium::from_reader(bytes)
131        .map_err(|e| CborError(format!("CBOR→JSON decode failed: {e}")))?;
132    cbor_value_to_json(&value)
133}
134
135/// Decode content-part data based on MIME type for JSON representation.
136///
137/// - `text/*`, `application/json`, `application/xml` — raw UTF-8 bytes → JSON string
138/// - `application/cbor` — CBOR-decoded to JSON value
139/// - everything else (image/*, octet-stream, etc.) — base64-encoded string
140pub fn decode_content_data(data: &[u8], mime_type: Option<&str>) -> serde_json::Value {
141    let mime = mime_type.unwrap_or("application/cbor");
142
143    if mime.starts_with("text/") || mime == "application/json" || mime == "application/xml" {
144        // Text-like: inline as string
145        serde_json::Value::String(String::from_utf8_lossy(data).into_owned())
146    } else if mime == "application/cbor" {
147        // Structured: CBOR → JSON value
148        cbor_to_json(data).unwrap_or_else(|_| {
149            use base64::Engine as _;
150            serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(data))
151        })
152    } else {
153        // Binary (image/*, octet-stream, pdf, etc.): base64
154        use base64::Engine as _;
155        serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(data))
156    }
157}
158
159/// Escape a literal CBOR map key that begins with `$` by prepending one more `$`.
160/// Keeps the `$`-prefixed namespace reserved for ACT JSON-projection tokens.
161fn escape_key(k: &str) -> String {
162    if k.starts_with('$') {
163        format!("${k}")
164    } else {
165        k.to_string()
166    }
167}
168
169/// Inverse of `escape_key`: a key beginning with `$$` is unescaped by one `$`.
170fn unescape_key(k: &str) -> String {
171    if k.starts_with("$$") {
172        k[1..].to_string()
173    } else {
174        k.to_string()
175    }
176}
177
178/// If `map` is exactly the canonical byte-string wrapper `{"$bytes": "<base64>"}`,
179/// return the decoded bytes. `Ok(None)` for any other object shape. Invalid base64
180/// inside a single-key `$bytes` object is a hard error (the shape is reserved).
181fn decode_bytes_wrapper(
182    map: &serde_json::Map<String, serde_json::Value>,
183) -> Result<Option<Vec<u8>>, CborError> {
184    if map.len() != 1 {
185        return Ok(None);
186    }
187    let Some(value) = map.get("$bytes") else {
188        return Ok(None);
189    };
190    // Single-member `$bytes` object is the reserved byte-string wrapper; its value
191    // MUST be a base64 string, otherwise the input is a malformed wrapper.
192    let serde_json::Value::String(b64) = value else {
193        return Err(CborError(
194            "$bytes wrapper value must be a base64 string".into(),
195        ));
196    };
197    use base64::Engine as _;
198    let bytes = base64::engine::general_purpose::STANDARD
199        .decode(b64)
200        .map_err(|e| CborError(format!("invalid base64 in $bytes: {e}")))?;
201    Ok(Some(bytes))
202}
203
204/// CBOR conversion error.
205#[derive(Debug, Clone)]
206pub struct CborError(pub String);
207
208impl std::fmt::Display for CborError {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        f.write_str(&self.0)
211    }
212}
213
214impl std::error::Error for CborError {}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use serde_json::json;
220
221    #[test]
222    fn escape_roundtrip_dollar_keys() {
223        assert_eq!(escape_key("name"), "name");
224        assert_eq!(escape_key("$bytes"), "$$bytes");
225        assert_eq!(escape_key("$$x"), "$$$x");
226        assert_eq!(unescape_key("name"), "name");
227        assert_eq!(unescape_key("$$bytes"), "$bytes");
228        assert_eq!(unescape_key("$$$x"), "$$x");
229    }
230
231    #[test]
232    fn bytes_wrapper_detected() {
233        let mut m = serde_json::Map::new();
234        m.insert(
235            "$bytes".into(),
236            serde_json::Value::String("aGVsbG8=".into()),
237        );
238        assert_eq!(decode_bytes_wrapper(&m).unwrap(), Some(b"hello".to_vec()));
239
240        let mut m2 = serde_json::Map::new();
241        m2.insert(
242            "$bytes".into(),
243            serde_json::Value::String("aGVsbG8=".into()),
244        );
245        m2.insert("x".into(), serde_json::Value::Null);
246        assert_eq!(decode_bytes_wrapper(&m2).unwrap(), None);
247
248        let mut m3 = serde_json::Map::new();
249        m3.insert("$bytes".into(), serde_json::Value::String("@@@".into()));
250        assert!(decode_bytes_wrapper(&m3).is_err());
251    }
252
253    #[test]
254    fn roundtrip_object() {
255        let input = json!({"a": 2, "b": 3});
256        let cbor = json_to_cbor(&input).unwrap();
257        let output = cbor_to_json(&cbor).unwrap();
258        assert_eq!(input, output);
259    }
260
261    #[test]
262    fn roundtrip_nested() {
263        let input = json!({"config": {"api_key": "abc123"}, "values": [1, 2, 3]});
264        let cbor = json_to_cbor(&input).unwrap();
265        let output = cbor_to_json(&cbor).unwrap();
266        assert_eq!(input, output);
267    }
268
269    #[test]
270    fn roundtrip_null() {
271        let input = json!(null);
272        let cbor = json_to_cbor(&input).unwrap();
273        let output = cbor_to_json(&cbor).unwrap();
274        assert_eq!(input, output);
275    }
276
277    #[test]
278    fn empty_bytes_is_error() {
279        assert!(cbor_to_json(&[]).is_err());
280    }
281
282    #[test]
283    fn generic_roundtrip() {
284        let input = 42u64;
285        let bytes = to_cbor(&input);
286        let output: u64 = from_cbor(&bytes).unwrap();
287        assert_eq!(input, output);
288    }
289
290    #[test]
291    fn decode_text_content() {
292        let data = b"hello world";
293        let result = decode_content_data(data, Some("text/plain"));
294        assert_eq!(result, json!("hello world"));
295    }
296
297    #[test]
298    fn decode_cbor_content() {
299        let data = to_cbor(&json!({"key": "value"}));
300        let result = decode_content_data(&data, None);
301        assert_eq!(result, json!({"key": "value"}));
302    }
303
304    #[test]
305    fn decode_json_content_as_text() {
306        let data = br#"{"pets": [1, 2, 3]}"#;
307        let result = decode_content_data(data, Some("application/json"));
308        assert_eq!(result, json!(r#"{"pets": [1, 2, 3]}"#));
309    }
310
311    #[test]
312    fn decode_invalid_cbor_falls_back_to_base64() {
313        let data = b"\xff\xfe";
314        let result = decode_content_data(data, Some("application/octet-stream"));
315        // Should be base64 string
316        assert!(result.is_string());
317    }
318
319    #[test]
320    fn decode_image_content_to_base64() {
321        let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes
322        let result = decode_content_data(&data, Some("image/png"));
323        assert!(result.is_string());
324        use base64::Engine as _;
325        let decoded = base64::engine::general_purpose::STANDARD
326            .decode(result.as_str().unwrap())
327            .unwrap();
328        assert_eq!(decoded, data);
329    }
330
331    #[test]
332    fn decode_octet_stream_to_base64() {
333        let data = vec![0xFF, 0xFE, 0x00];
334        let result = decode_content_data(&data, Some("application/octet-stream"));
335        assert!(result.is_string());
336        use base64::Engine as _;
337        let decoded = base64::engine::general_purpose::STANDARD
338            .decode(result.as_str().unwrap())
339            .unwrap();
340        assert_eq!(decoded, data);
341    }
342
343    #[test]
344    fn decode_html_as_text() {
345        let data = b"<h1>Hello</h1>";
346        let result = decode_content_data(data, Some("text/html"));
347        assert_eq!(result, json!("<h1>Hello</h1>"));
348    }
349
350    #[test]
351    fn decode_xml_as_text() {
352        let data = b"<root><item/></root>";
353        let result = decode_content_data(data, Some("application/xml"));
354        assert_eq!(result, json!("<root><item/></root>"));
355    }
356
357    fn cbor_of(v: &ciborium::value::Value) -> Vec<u8> {
358        let mut buf = Vec::new();
359        ciborium::into_writer(v, &mut buf).unwrap();
360        buf
361    }
362
363    #[test]
364    fn cbor_bytes_projects_to_dollar_bytes() {
365        let buf = cbor_of(&ciborium::value::Value::Bytes(b"hello".to_vec()));
366        assert_eq!(cbor_to_json(&buf).unwrap(), json!({"$bytes": "aGVsbG8="}));
367    }
368
369    #[test]
370    fn embedded_bytes_in_map_wrapped() {
371        let v = ciborium::value::Value::Map(vec![
372            (
373                ciborium::value::Value::Text("name".into()),
374                ciborium::value::Value::Text("x".into()),
375            ),
376            (
377                ciborium::value::Value::Text("blob".into()),
378                ciborium::value::Value::Bytes(vec![1, 2]),
379            ),
380        ]);
381        assert_eq!(
382            cbor_to_json(&cbor_of(&v)).unwrap(),
383            json!({"name": "x", "blob": {"$bytes": "AQI="}})
384        );
385    }
386
387    #[test]
388    fn literal_dollar_key_is_escaped_on_output() {
389        let v = ciborium::value::Value::Map(vec![(
390            ciborium::value::Value::Text("$bytes".into()),
391            ciborium::value::Value::Text("hello".into()),
392        )]);
393        assert_eq!(
394            cbor_to_json(&cbor_of(&v)).unwrap(),
395            json!({"$$bytes": "hello"})
396        );
397    }
398
399    #[test]
400    fn dollar_bytes_parses_to_cbor_bytes() {
401        let cbor = json_to_cbor(&json!({"$bytes": "aGVsbG8="})).unwrap();
402        let value: ciborium::value::Value = ciborium::from_reader(&cbor[..]).unwrap();
403        assert_eq!(value, ciborium::value::Value::Bytes(b"hello".to_vec()));
404    }
405
406    #[test]
407    fn bytes_roundtrip_through_json() {
408        let original = cbor_of(&ciborium::value::Value::Bytes(vec![0u8, 1, 2, 255]));
409        let json = cbor_to_json(&original).unwrap();
410        let back = json_to_cbor(&json).unwrap();
411        assert_eq!(original, back);
412    }
413
414    #[test]
415    fn escaped_key_roundtrip_through_json() {
416        let v = ciborium::value::Value::Map(vec![(
417            ciborium::value::Value::Text("$bytes".into()),
418            ciborium::value::Value::Text("hello".into()),
419        )]);
420        let json = cbor_to_json(&cbor_of(&v)).unwrap();
421        let back = json_to_cbor(&json).unwrap();
422        let value: ciborium::value::Value = ciborium::from_reader(&back[..]).unwrap();
423        assert_eq!(value, v);
424    }
425
426    #[test]
427    fn content_cbor_embeds_dollar_bytes() {
428        let data = cbor_of(&ciborium::value::Value::Map(vec![(
429            ciborium::value::Value::Text("thumb".into()),
430            ciborium::value::Value::Bytes(vec![1, 2]),
431        )]));
432        let result = decode_content_data(&data, Some("application/cbor"));
433        assert_eq!(result, json!({"thumb": {"$bytes": "AQI="}}));
434    }
435
436    #[test]
437    fn dollar_bytes_non_string_value_errors() {
438        assert!(json_to_cbor(&json!({"$bytes": 42})).is_err());
439    }
440
441    #[test]
442    fn non_finite_float_errors() {
443        let cbor = cbor_of(&ciborium::value::Value::Float(f64::NAN));
444        assert!(cbor_to_json(&cbor).is_err());
445    }
446}