neco-cbor 0.1.1

necosystems series CBOR / DAG-CBOR codec for no_std environments
Documentation
use alloc::borrow::ToOwned;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
use core::fmt;

use crate::cid_tag::{decode_cid_tag, encode_cid_tag};
use crate::CborValue;
use neco_cid::Cid;
use neco_json::JsonValue;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JsonBridgeError {
    NumberOverflow,
    NonTextMapKey,
    UnsupportedTag(u64),
    InvalidBase64(String),
    InvalidCidLink(String),
    FloatNotSupported,
}

impl fmt::Display for JsonBridgeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::NumberOverflow => f.write_str("number overflow"),
            Self::NonTextMapKey => f.write_str("CBOR map key must be text"),
            Self::UnsupportedTag(tag) => write!(f, "unsupported CBOR tag {tag}"),
            Self::InvalidBase64(value) => write!(f, "invalid base64 bytes: {value}"),
            Self::InvalidCidLink(value) => write!(f, "invalid CID link: {value}"),
            Self::FloatNotSupported => f.write_str("floating-point JSON numbers are not supported"),
        }
    }
}

impl core::error::Error for JsonBridgeError {}

pub fn cbor_from_json(json: &JsonValue) -> Result<CborValue, JsonBridgeError> {
    match json {
        JsonValue::Null => Ok(CborValue::Null),
        JsonValue::Bool(value) => Ok(CborValue::Bool(*value)),
        JsonValue::String(value) => Ok(CborValue::Text(value.clone())),
        JsonValue::Number(value) => cbor_from_number(*value),
        JsonValue::Array(values) => values
            .iter()
            .map(cbor_from_json)
            .collect::<Result<Vec<_>, _>>()
            .map(CborValue::Array),
        JsonValue::Object(fields) => {
            if let Some(single) = decode_special_object(fields)? {
                return Ok(single);
            }
            fields
                .iter()
                .map(|(key, value)| Ok((CborValue::Text(key.clone()), cbor_from_json(value)?)))
                .collect::<Result<Vec<_>, JsonBridgeError>>()
                .map(CborValue::Map)
        }
    }
}

pub fn cbor_to_json(cbor: &CborValue) -> Result<JsonValue, JsonBridgeError> {
    match cbor {
        CborValue::Unsigned(value) => {
            if *value > i64::MAX as u64 {
                return Err(JsonBridgeError::NumberOverflow);
            }
            Ok(JsonValue::Number(*value as f64))
        }
        CborValue::Negative(value) => Ok(JsonValue::Number(*value as f64)),
        CborValue::Bytes(value) => Ok(JsonValue::Object(vec![(
            "$bytes".to_owned(),
            JsonValue::String(neco_base64::encode(value)),
        )])),
        CborValue::Text(value) => Ok(JsonValue::String(value.clone())),
        CborValue::Array(values) => values
            .iter()
            .map(cbor_to_json)
            .collect::<Result<Vec<_>, _>>()
            .map(JsonValue::Array),
        CborValue::Map(entries) => entries
            .iter()
            .map(|(key, value)| {
                let key = match key {
                    CborValue::Text(text) => text.clone(),
                    _ => return Err(JsonBridgeError::NonTextMapKey),
                };
                Ok((key, cbor_to_json(value)?))
            })
            .collect::<Result<Vec<_>, JsonBridgeError>>()
            .map(JsonValue::Object),
        CborValue::Tag(tag, _) => {
            if *tag != 42 {
                return Err(JsonBridgeError::UnsupportedTag(*tag));
            }
            let cid = decode_cid_tag(cbor)
                .map_err(|error| JsonBridgeError::InvalidCidLink(error.to_string()))?;
            Ok(JsonValue::Object(vec![(
                "$link".to_owned(),
                JsonValue::String(cid.to_multibase(neco_cid::Base::Base32Lower)),
            )]))
        }
        CborValue::Bool(value) => Ok(JsonValue::Bool(*value)),
        CborValue::Null => Ok(JsonValue::Null),
    }
}

fn cbor_from_number(value: f64) -> Result<CborValue, JsonBridgeError> {
    if !value.is_finite() {
        return Err(JsonBridgeError::NumberOverflow);
    }
    if value.fract() != 0.0 {
        return Err(JsonBridgeError::FloatNotSupported);
    }
    if value < i64::MIN as f64 || value > u64::MAX as f64 {
        return Err(JsonBridgeError::NumberOverflow);
    }
    if value < 0.0 {
        return Ok(CborValue::Negative(value as i64));
    }
    Ok(CborValue::Unsigned(value as u64))
}

fn decode_special_object(
    fields: &[(String, JsonValue)],
) -> Result<Option<CborValue>, JsonBridgeError> {
    if fields.len() != 1 {
        return Ok(None);
    }

    let (key, value) = &fields[0];
    match key.as_str() {
        "$bytes" => {
            let encoded = value
                .as_str()
                .ok_or_else(|| JsonBridgeError::InvalidBase64(format!("{value:?}")))?;
            let bytes = neco_base64::decode(encoded)
                .map_err(|error| JsonBridgeError::InvalidBase64(error.to_string()))?;
            Ok(Some(CborValue::Bytes(bytes)))
        }
        "$link" => {
            let cid = value
                .as_str()
                .ok_or_else(|| JsonBridgeError::InvalidCidLink(format!("{value:?}")))?;
            let cid = Cid::from_multibase(cid)
                .map_err(|error| JsonBridgeError::InvalidCidLink(error.to_string()))?;
            Ok(Some(encode_cid_tag(&cid)))
        }
        _ => Ok(None),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloc::borrow::ToOwned;
    use alloc::vec;

    fn link_json(cid: &str) -> JsonValue {
        JsonValue::Object(vec![(
            "$link".to_owned(),
            JsonValue::String(cid.to_owned()),
        )])
    }

    #[test]
    fn roundtrip_integers_strings_bools_null() {
        let value = JsonValue::Array(vec![
            JsonValue::Number(7.0),
            JsonValue::Number(-2.0),
            JsonValue::String("hello".to_owned()),
            JsonValue::Bool(true),
            JsonValue::Null,
        ]);

        let encoded = cbor_from_json(&value).expect("encode");
        let decoded = cbor_to_json(&encoded).expect("decode");

        assert_eq!(decoded, value);
    }

    #[test]
    fn roundtrip_array_and_object() {
        let value = JsonValue::Object(vec![
            ("name".to_owned(), JsonValue::String("alice".to_owned())),
            (
                "items".to_owned(),
                JsonValue::Array(vec![
                    JsonValue::Number(1.0),
                    JsonValue::Object(vec![("ok".to_owned(), JsonValue::Bool(false))]),
                ]),
            ),
        ]);

        let encoded = cbor_from_json(&value).expect("encode");
        let decoded = cbor_to_json(&encoded).expect("decode");

        assert_eq!(decoded, value);
    }

    #[test]
    fn roundtrip_bytes_via_base64() {
        let value = JsonValue::Object(vec![(
            "$bytes".to_owned(),
            JsonValue::String("aGVsbG8=".to_owned()),
        )]);

        let encoded = cbor_from_json(&value).expect("encode");
        assert_eq!(encoded, CborValue::Bytes(b"hello".to_vec()));

        let decoded = cbor_to_json(&encoded).expect("decode");
        assert_eq!(decoded, value);
    }

    #[test]
    fn roundtrip_cid_link() {
        let cid = neco_cid::Cid::compute(neco_cid::Codec::Raw, b"json-cbor-link")
            .to_multibase(neco_cid::Base::Base32Lower);
        let value = link_json(&cid);

        let encoded = cbor_from_json(&value).expect("encode");
        let decoded = cbor_to_json(&encoded).expect("decode");

        assert_eq!(decoded, value);
    }

    #[test]
    fn error_float_not_supported() {
        let error = cbor_from_json(&JsonValue::Number(1.5)).expect_err("error");
        assert_eq!(error, JsonBridgeError::FloatNotSupported);
    }

    #[test]
    fn error_number_overflow_to_cbor() {
        let error = cbor_from_json(&JsonValue::Number(1e40)).expect_err("error");
        assert_eq!(error, JsonBridgeError::NumberOverflow);
    }

    #[test]
    fn error_number_overflow_from_cbor() {
        let error = cbor_to_json(&CborValue::Unsigned((i64::MAX as u64) + 1)).expect_err("error");
        assert_eq!(error, JsonBridgeError::NumberOverflow);
    }

    #[test]
    fn error_non_text_map_key() {
        let error = cbor_to_json(&CborValue::Map(vec![(
            CborValue::Unsigned(1),
            CborValue::Text("value".to_owned()),
        )]))
        .expect_err("error");
        assert_eq!(error, JsonBridgeError::NonTextMapKey);
    }

    #[test]
    fn roundtrip_negative_integer() {
        let value = JsonValue::Number(-42.0);
        let encoded = cbor_from_json(&value).expect("encode");
        assert_eq!(encoded, CborValue::Negative(-42));
        let decoded = cbor_to_json(&encoded).expect("decode");
        assert_eq!(decoded, value);
    }
}