|
| 1 | +//! JSON-safe conversion of arbitrary CBOR values. |
| 2 | +//! |
| 3 | +//! Used to surface authenticator-provided maps that have no typed model, such |
| 4 | +//! as the getAssertion `unsignedExtensionOutputs` map (response member 0x08), |
| 5 | +//! into WebAuthn client extension results. Per the WebAuthn JSON convention, |
| 6 | +//! binary (CBOR byte string) values are encoded as base64url. |
| 7 | +
|
| 8 | +use std::collections::BTreeMap; |
| 9 | + |
| 10 | +use serde_json::{Map, Number, Value as Json}; |
| 11 | + |
| 12 | +use super::Value; |
| 13 | + |
| 14 | +/// Converts a CBOR map into a JSON object, dropping entries whose key cannot be |
| 15 | +/// represented as a JSON string. |
| 16 | +pub(crate) fn map_to_json_object(map: &BTreeMap<Value, Value>) -> Map<String, Json> { |
| 17 | + map.iter() |
| 18 | + .filter_map(|(k, v)| key_to_string(k).map(|k| (k, value_to_json(v)))) |
| 19 | + .collect() |
| 20 | +} |
| 21 | + |
| 22 | +/// Converts an arbitrary CBOR value into a JSON-safe value. |
| 23 | +fn value_to_json(value: &Value) -> Json { |
| 24 | + match value { |
| 25 | + Value::Null => Json::Null, |
| 26 | + Value::Bool(b) => Json::Bool(*b), |
| 27 | + Value::Integer(i) => integer_to_json(*i), |
| 28 | + Value::Float(f) => Number::from_f64(*f).map(Json::Number).unwrap_or(Json::Null), |
| 29 | + Value::Bytes(b) => Json::String(base64_url::encode(b)), |
| 30 | + Value::Text(s) => Json::String(s.clone()), |
| 31 | + Value::Array(items) => Json::Array(items.iter().map(value_to_json).collect()), |
| 32 | + Value::Map(m) => Json::Object(map_to_json_object(m)), |
| 33 | + // Tags carry no WebAuthn meaning here; surface the tagged value itself. |
| 34 | + Value::Tag(_, inner) => value_to_json(inner), |
| 35 | + // Non-exhaustive enum: anything else has no JSON representation. |
| 36 | + _ => Json::Null, |
| 37 | + } |
| 38 | +} |
| 39 | + |
| 40 | +/// CBOR integers span -2^64..2^64-1, wider than any single JSON number type. |
| 41 | +/// Map what fits into `i64`/`u64`; fall back to a decimal string otherwise. |
| 42 | +fn integer_to_json(i: i128) -> Json { |
| 43 | + if let Ok(n) = i64::try_from(i) { |
| 44 | + Json::Number(n.into()) |
| 45 | + } else if let Ok(n) = u64::try_from(i) { |
| 46 | + Json::Number(n.into()) |
| 47 | + } else { |
| 48 | + Json::String(i.to_string()) |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +/// JSON object keys must be strings. Extension output maps use text keys; other |
| 53 | +/// scalar key types are stringified best-effort, and unsupported keys are |
| 54 | +/// dropped by the caller. |
| 55 | +fn key_to_string(key: &Value) -> Option<String> { |
| 56 | + match key { |
| 57 | + Value::Text(s) => Some(s.clone()), |
| 58 | + Value::Integer(i) => Some(i.to_string()), |
| 59 | + Value::Bytes(b) => Some(base64_url::encode(b)), |
| 60 | + Value::Bool(b) => Some(b.to_string()), |
| 61 | + _ => None, |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +#[cfg(test)] |
| 66 | +mod tests { |
| 67 | + use super::*; |
| 68 | + |
| 69 | + #[test] |
| 70 | + fn scalars_round_trip() { |
| 71 | + assert_eq!(value_to_json(&Value::Bool(true)), Json::Bool(true)); |
| 72 | + assert_eq!(value_to_json(&Value::Null), Json::Null); |
| 73 | + assert_eq!(value_to_json(&Value::Text("hi".into())), Json::from("hi")); |
| 74 | + assert_eq!(value_to_json(&Value::Integer(42)), Json::from(42)); |
| 75 | + assert_eq!(value_to_json(&Value::Integer(-7)), Json::from(-7)); |
| 76 | + } |
| 77 | + |
| 78 | + #[test] |
| 79 | + fn bytes_become_base64url() { |
| 80 | + let value = Value::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]); |
| 81 | + assert_eq!( |
| 82 | + value_to_json(&value), |
| 83 | + Json::from(base64_url::encode(&[0xDE, 0xAD, 0xBE, 0xEF])) |
| 84 | + ); |
| 85 | + } |
| 86 | + |
| 87 | + #[test] |
| 88 | + fn nested_array_and_map() { |
| 89 | + let mut inner = BTreeMap::new(); |
| 90 | + inner.insert(Value::Text("blob".into()), Value::Bytes(vec![0x01, 0x02])); |
| 91 | + let value = Value::Array(vec![Value::Integer(1), Value::Map(inner)]); |
| 92 | + |
| 93 | + let json = value_to_json(&value); |
| 94 | + let arr = json.as_array().unwrap(); |
| 95 | + assert_eq!(arr[0], Json::from(1)); |
| 96 | + assert_eq!( |
| 97 | + arr[1]["blob"], |
| 98 | + Json::from(base64_url::encode(&[0x01, 0x02])) |
| 99 | + ); |
| 100 | + } |
| 101 | + |
| 102 | + #[test] |
| 103 | + fn tag_surfaces_inner_value() { |
| 104 | + let value = Value::Tag(42, Box::new(Value::Text("tagged".into()))); |
| 105 | + assert_eq!(value_to_json(&value), Json::from("tagged")); |
| 106 | + } |
| 107 | + |
| 108 | + #[test] |
| 109 | + fn out_of_range_integer_becomes_string() { |
| 110 | + let big = i128::from(u64::MAX) + 1; |
| 111 | + assert_eq!( |
| 112 | + value_to_json(&Value::Integer(big)), |
| 113 | + Json::from(big.to_string()) |
| 114 | + ); |
| 115 | + } |
| 116 | + |
| 117 | + #[test] |
| 118 | + fn non_finite_float_becomes_null() { |
| 119 | + assert_eq!(value_to_json(&Value::Float(f64::NAN)), Json::Null); |
| 120 | + assert_eq!(value_to_json(&Value::Float(1.5)), Json::from(1.5)); |
| 121 | + } |
| 122 | + |
| 123 | + #[test] |
| 124 | + fn non_string_keys_are_dropped() { |
| 125 | + let mut map = BTreeMap::new(); |
| 126 | + map.insert(Value::Text("ok".into()), Value::Bool(true)); |
| 127 | + map.insert(Value::Array(vec![Value::Integer(1)]), Value::Bool(false)); |
| 128 | + |
| 129 | + let object = map_to_json_object(&map); |
| 130 | + assert_eq!(object.len(), 1); |
| 131 | + assert_eq!(object["ok"], Json::Bool(true)); |
| 132 | + } |
| 133 | +} |
0 commit comments