Skip to main content

fakecloud_core/
cbor.rs

1//! Smithy RPC v2 CBOR (`smithy.protocols#rpcv2Cbor`) wire support.
2//!
3//! An rpcv2Cbor request is `POST /service/{ServiceName}/operation/{Operation}`
4//! with the `smithy-protocol: rpc-v2-cbor` header and a CBOR-encoded input
5//! structure as the body. Responses carry the same header, `Content-Type:
6//! application/cbor`, and a CBOR body; errors are a CBOR map holding `__type`
7//! plus the error structure's members.
8//!
9//! fakecloud's JSON-protocol handlers already take a JSON input document, so
10//! the dispatcher decodes the CBOR body into the equivalent awsJson document
11//! ([`decode_to_json`]: tag-1 timestamps become epoch seconds, byte strings
12//! become base64 strings) and routes the request through the service's JSON
13//! path. A service that knows its output schema returns a ready CBOR body
14//! (content type [`CBOR_CONTENT_TYPE`]); any JSON response is transcoded
15//! generically with [`json_to_cbor`].
16
17use base64::Engine;
18use serde_json::{Map, Number, Value};
19
20pub use ciborium::Value as CborValue;
21
22/// `Content-Type` of every rpcv2Cbor request and response body.
23pub const CBOR_CONTENT_TYPE: &str = "application/cbor";
24/// Header naming the Smithy protocol on rpcv2Cbor requests and responses.
25pub const SMITHY_PROTOCOL_HEADER: &str = "smithy-protocol";
26/// Value of [`SMITHY_PROTOCOL_HEADER`] for rpcv2Cbor.
27pub const RPC_V2_CBOR: &str = "rpc-v2-cbor";
28
29/// CBOR tag for an epoch-seconds date/time (RFC 8949 section 3.4.2), the only
30/// timestamp encoding rpcv2Cbor allows.
31const EPOCH_TIMESTAMP_TAG: u64 = 1;
32
33/// Split an rpcv2Cbor request path `/service/{ServiceName}/operation/{Operation}`
34/// into `(ServiceName, Operation)`. Smithy allows a path prefix before
35/// `/service/`, so the match is anchored on the trailing four segments.
36pub fn parse_rpc_v2_path(path: &str) -> Option<(&str, &str)> {
37    let segs: Vec<&str> = path.trim_end_matches('/').split('/').collect();
38    match segs.as_slice() {
39        [.., "service", service, "operation", operation]
40            if !service.is_empty() && !operation.is_empty() =>
41        {
42            Some((service, operation))
43        }
44        _ => None,
45    }
46}
47
48/// Decode an rpcv2Cbor request body into the awsJson document a JSON handler
49/// reads. An empty body is an empty input structure.
50pub fn decode_to_json(body: &[u8]) -> Result<Value, String> {
51    if body.is_empty() {
52        return Ok(Value::Object(Map::new()));
53    }
54    let value: CborValue = ciborium::from_reader(body).map_err(|e| e.to_string())?;
55    cbor_to_json(value)
56}
57
58fn cbor_to_json(value: CborValue) -> Result<Value, String> {
59    Ok(match value {
60        CborValue::Null => Value::Null,
61        CborValue::Bool(b) => Value::Bool(b),
62        CborValue::Integer(i) => {
63            let i = i128::from(i);
64            if let Ok(v) = i64::try_from(i) {
65                Value::Number(v.into())
66            } else if let Ok(v) = u64::try_from(i) {
67                Value::Number(v.into())
68            } else {
69                return Err(format!("integer {i} out of range"));
70            }
71        }
72        CborValue::Float(f) => float_to_json(f),
73        CborValue::Text(s) => Value::String(s),
74        // awsJson carries blobs as base64 strings.
75        CborValue::Bytes(b) => Value::String(base64::engine::general_purpose::STANDARD.encode(b)),
76        CborValue::Array(items) => Value::Array(
77            items
78                .into_iter()
79                .map(cbor_to_json)
80                .collect::<Result<_, _>>()?,
81        ),
82        CborValue::Map(entries) => {
83            let mut obj = Map::new();
84            for (k, v) in entries {
85                let CborValue::Text(key) = k else {
86                    return Err("map keys must be text strings".to_string());
87                };
88                // rpcv2Cbor serializes an absent member by omitting it; a
89                // null is treated the same way, as awsJson handlers expect.
90                if v.is_null() {
91                    continue;
92                }
93                obj.insert(key, cbor_to_json(v)?);
94            }
95            Value::Object(obj)
96        }
97        // A tag-1 timestamp is epoch seconds, which is exactly the awsJson
98        // timestamp representation. Other tags carry no meaning to the
99        // handlers, so their content is used as-is.
100        CborValue::Tag(_, inner) => cbor_to_json(*inner)?,
101        other => return Err(format!("unsupported CBOR value {other:?}")),
102    })
103}
104
105/// awsJson renders non-finite doubles as the strings `NaN`, `Infinity` and
106/// `-Infinity`.
107fn float_to_json(f: f64) -> Value {
108    match Number::from_f64(f) {
109        Some(n) => Value::Number(n),
110        None if f.is_nan() => Value::String("NaN".to_string()),
111        None if f > 0.0 => Value::String("Infinity".to_string()),
112        None => Value::String("-Infinity".to_string()),
113    }
114}
115
116/// An epoch-seconds timestamp as rpcv2Cbor encodes it: tag 1 over a double.
117/// A float (rather than an integer for whole seconds) keeps millisecond
118/// precision and is what every SDK's decoder accepts.
119pub fn timestamp(epoch_seconds: f64) -> CborValue {
120    CborValue::Tag(
121        EPOCH_TIMESTAMP_TAG,
122        Box::new(CborValue::Float(epoch_seconds)),
123    )
124}
125
126/// Encode a CBOR value to bytes, with definite lengths and every float as a
127/// 64-bit double. (A generic encoder shrinks floats to half/single precision
128/// when lossless, but SDK decoders built without half-float support reject a
129/// 16-bit float where the schema says `double`.)
130pub fn encode(value: &CborValue) -> Vec<u8> {
131    let mut out = Vec::new();
132    encode_into(value, &mut out);
133    out
134}
135
136/// Write a CBOR item head: major type plus argument, shortest form.
137fn write_head(major: u8, arg: u64, out: &mut Vec<u8>) {
138    let m = major << 5;
139    if arg < 24 {
140        out.push(m | arg as u8);
141    } else if arg <= u8::MAX as u64 {
142        out.push(m | 24);
143        out.push(arg as u8);
144    } else if arg <= u16::MAX as u64 {
145        out.push(m | 25);
146        out.extend_from_slice(&(arg as u16).to_be_bytes());
147    } else if arg <= u32::MAX as u64 {
148        out.push(m | 26);
149        out.extend_from_slice(&(arg as u32).to_be_bytes());
150    } else {
151        out.push(m | 27);
152        out.extend_from_slice(&arg.to_be_bytes());
153    }
154}
155
156fn encode_into(value: &CborValue, out: &mut Vec<u8>) {
157    match value {
158        CborValue::Integer(i) => {
159            let i = i128::from(*i);
160            if i >= 0 {
161                write_head(0, i as u64, out);
162            } else {
163                write_head(1, (-1 - i) as u64, out);
164            }
165        }
166        CborValue::Bytes(b) => {
167            write_head(2, b.len() as u64, out);
168            out.extend_from_slice(b);
169        }
170        CborValue::Text(t) => {
171            write_head(3, t.len() as u64, out);
172            out.extend_from_slice(t.as_bytes());
173        }
174        CborValue::Array(items) => {
175            write_head(4, items.len() as u64, out);
176            for item in items {
177                encode_into(item, out);
178            }
179        }
180        CborValue::Map(entries) => {
181            write_head(5, entries.len() as u64, out);
182            for (k, v) in entries {
183                encode_into(k, out);
184                encode_into(v, out);
185            }
186        }
187        CborValue::Tag(tag, inner) => {
188            write_head(6, *tag, out);
189            encode_into(inner, out);
190        }
191        CborValue::Bool(false) => out.push(0xf4),
192        CborValue::Bool(true) => out.push(0xf5),
193        CborValue::Null => out.push(0xf6),
194        CborValue::Float(f) => {
195            out.push(0xfb);
196            out.extend_from_slice(&f.to_be_bytes());
197        }
198        // Not produced by fakecloud; encode as null rather than panic.
199        _ => out.push(0xf6),
200    }
201}
202
203/// Schema-less transcoding of an awsJson document into CBOR, for services that
204/// do not render CBOR themselves: integers stay integers, other numbers become
205/// floats, strings stay text. Timestamps and blobs keep their awsJson form
206/// (number / base64 text) since their shape type is not known here.
207pub fn json_to_cbor(value: &Value) -> CborValue {
208    match value {
209        Value::Null => CborValue::Null,
210        Value::Bool(b) => CborValue::Bool(*b),
211        Value::Number(n) => {
212            if let Some(i) = n.as_i64() {
213                CborValue::Integer(i.into())
214            } else if let Some(u) = n.as_u64() {
215                CborValue::Integer(u.into())
216            } else {
217                CborValue::Float(n.as_f64().unwrap_or(0.0))
218            }
219        }
220        Value::String(s) => CborValue::Text(s.clone()),
221        Value::Array(items) => CborValue::Array(items.iter().map(json_to_cbor).collect()),
222        Value::Object(obj) => CborValue::Map(
223            obj.iter()
224                .map(|(k, v)| (CborValue::Text(k.clone()), json_to_cbor(v)))
225                .collect(),
226        ),
227    }
228}
229
230/// The CBOR body of an rpcv2Cbor error: `__type` names the error, `message`
231/// carries its text, and any extra members are added alongside (parsed as
232/// JSON when they hold a JSON document, as for the awsJson error body).
233pub fn error_body(code: &str, message: &str, extra_fields: &[(String, String)]) -> Vec<u8> {
234    let mut entries = vec![
235        (
236            CborValue::Text("__type".to_string()),
237            CborValue::Text(code.to_string()),
238        ),
239        (
240            CborValue::Text("message".to_string()),
241            CborValue::Text(message.to_string()),
242        ),
243    ];
244    for (key, value) in extra_fields {
245        let parsed = serde_json::from_str::<Value>(value)
246            .map(|v| json_to_cbor(&v))
247            .unwrap_or_else(|_| CborValue::Text(value.clone()));
248        entries.push((CborValue::Text(key.clone()), parsed));
249    }
250    encode(&CborValue::Map(entries))
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn cbor(v: &CborValue) -> Vec<u8> {
258        encode(v)
259    }
260
261    fn text(s: &str) -> CborValue {
262        CborValue::Text(s.to_string())
263    }
264
265    #[test]
266    fn parses_rpc_v2_path() {
267        assert_eq!(
268            parse_rpc_v2_path("/service/GraniteServiceVersion20100801/operation/GetMetricData"),
269            Some(("GraniteServiceVersion20100801", "GetMetricData"))
270        );
271        assert_eq!(
272            parse_rpc_v2_path("/prefix/service/Svc/operation/Op"),
273            Some(("Svc", "Op"))
274        );
275        assert_eq!(parse_rpc_v2_path("/service/Svc"), None);
276        assert_eq!(parse_rpc_v2_path("/service//operation/Op"), None);
277        assert_eq!(parse_rpc_v2_path("/"), None);
278    }
279
280    #[test]
281    fn decodes_empty_body_as_empty_object() {
282        assert_eq!(decode_to_json(b"").unwrap(), serde_json::json!({}));
283    }
284
285    #[test]
286    fn decodes_typed_members_to_aws_json() {
287        let body = cbor(&CborValue::Map(vec![
288            (text("Namespace"), text("App")),
289            (text("Period"), CborValue::Integer(60.into())),
290            (text("Value"), CborValue::Float(1.5)),
291            (text("Enabled"), CborValue::Bool(true)),
292            (text("StartTime"), timestamp(1_577_836_800.0)),
293            (text("EndTime"), timestamp(1_577_836_800.5)),
294            (text("Blob"), CborValue::Bytes(b"hi".to_vec())),
295            (text("Absent"), CborValue::Null),
296            (
297                text("Values"),
298                CborValue::Array(vec![CborValue::Float(1.0), CborValue::Float(f64::NAN)]),
299            ),
300        ]));
301        let json = decode_to_json(&body).unwrap();
302        assert_eq!(json["Namespace"], "App");
303        assert_eq!(json["Period"], 60);
304        assert_eq!(json["Value"], 1.5);
305        assert_eq!(json["Enabled"], true);
306        assert_eq!(json["StartTime"], 1_577_836_800.0);
307        assert_eq!(json["EndTime"], 1_577_836_800.5);
308        assert_eq!(json["Blob"], "aGk=");
309        assert!(json.get("Absent").is_none());
310        assert_eq!(json["Values"][0], 1.0);
311        assert_eq!(json["Values"][1], "NaN");
312    }
313
314    #[test]
315    fn rejects_malformed_cbor() {
316        assert!(decode_to_json(&[0xff, 0x00]).is_err());
317        // Non-text map key.
318        let body = cbor(&CborValue::Map(vec![(
319            CborValue::Integer(1.into()),
320            text("x"),
321        )]));
322        assert!(decode_to_json(&body).is_err());
323    }
324
325    #[test]
326    fn timestamp_is_tag_one_over_a_double() {
327        assert_eq!(
328            timestamp(10.0),
329            CborValue::Tag(1, Box::new(CborValue::Float(10.0)))
330        );
331        assert_eq!(
332            encode(&timestamp(10.0)),
333            [&[0xc1, 0xfb][..], &10.0f64.to_be_bytes()].concat()
334        );
335    }
336
337    #[test]
338    fn encoder_round_trips_and_keeps_doubles_wide() {
339        assert_eq!(
340            encode(&CborValue::Float(1.0)),
341            [&[0xfb][..], &1.0f64.to_be_bytes()].concat()
342        );
343        assert_eq!(
344            encode(&CborValue::Integer(500.into())),
345            vec![0x19, 0x01, 0xf4]
346        );
347        assert_eq!(encode(&CborValue::Integer((-1).into())), vec![0x20]);
348        let value = CborValue::Map(vec![
349            (text("n"), CborValue::Integer(u64::MAX.into())),
350            (text("neg"), CborValue::Integer(i64::MIN.into())),
351            (text("b"), CborValue::Bytes(vec![1, 2, 3])),
352            (
353                text("l"),
354                CborValue::Array(vec![CborValue::Bool(true), CborValue::Null]),
355            ),
356            (text("s"), text(&"x".repeat(300))),
357            (text("t"), timestamp(1.5)),
358        ]);
359        let back: CborValue = ciborium::from_reader(encode(&value).as_slice()).unwrap();
360        assert_eq!(back, value);
361    }
362
363    #[test]
364    fn json_to_cbor_keeps_number_kinds() {
365        let v = json_to_cbor(&serde_json::json!({"a": 1, "b": 1.5, "c": "s", "d": [true]}));
366        let back: CborValue = ciborium::from_reader(encode(&v).as_slice()).unwrap();
367        let CborValue::Map(entries) = back else {
368            panic!("expected map");
369        };
370        let get = |k: &str| {
371            entries
372                .iter()
373                .find(|(key, _)| key == &text(k))
374                .map(|(_, v)| v.clone())
375                .unwrap()
376        };
377        assert_eq!(get("a"), CborValue::Integer(1.into()));
378        assert_eq!(get("b"), CborValue::Float(1.5));
379        assert_eq!(get("c"), text("s"));
380        assert_eq!(get("d"), CborValue::Array(vec![CborValue::Bool(true)]));
381    }
382
383    #[test]
384    fn error_body_has_type_and_message() {
385        let body = error_body(
386            "InvalidParameterValue",
387            "bad",
388            &[("Extra".to_string(), "{\"k\":1}".to_string())],
389        );
390        let json = decode_to_json(&body).unwrap();
391        assert_eq!(json["__type"], "InvalidParameterValue");
392        assert_eq!(json["message"], "bad");
393        assert_eq!(json["Extra"]["k"], 1);
394    }
395}