From 92e609d36711f7754bb8b2a1a1a7be96dfed6645 Mon Sep 17 00:00:00 2001 From: Not Herself Date: Thu, 12 Mar 2026 10:07:30 +0100 Subject: [PATCH] fix: handle AT Protocol $bytes type in json_to_ipld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json_to_ipld handled $link (โ†’ Ipld::Link) but treated $bytes objects as regular maps, producing a CBOR map (major type 5) instead of a byte string (major type 2). This caused downstream consumers expecting spec-compliant CBOR โ€” notably Jetstream's atdata.UnmarshalCBOR โ€” to fail with "decoding $byte value: illegal base64 data". Decode $bytes from standard base64 (RFC 4648 ยง4, padding optional) into Ipld::Bytes, matching the existing $link handling pattern. --- crates/tranquil-pds/src/util.rs | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/crates/tranquil-pds/src/util.rs b/crates/tranquil-pds/src/util.rs index c5371b5..5e42f2c 100644 --- a/crates/tranquil-pds/src/util.rs +++ b/crates/tranquil-pds/src/util.rs @@ -1,4 +1,6 @@ use axum::http::{HeaderMap, HeaderName}; +use base64::Engine as _; + use cid::Cid; use ipld_core::ipld::Ipld; use rand::Rng; @@ -8,6 +10,13 @@ use std::net::SocketAddr; use std::str::FromStr; use std::sync::OnceLock; +const BASE64_STANDARD_INDIFFERENT: base64::engine::GeneralPurpose = + base64::engine::GeneralPurpose::new( + &base64::alphabet::STANDARD, + base64::engine::GeneralPurposeConfig::new() + .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), + ); + const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567"; static DISCORD_BOT_USERNAME: OnceLock = OnceLock::new(); @@ -172,6 +181,13 @@ pub fn json_to_ipld(value: &JsonValue) -> Ipld { { return Ipld::Link(cid); } + if let Some(JsonValue::String(b64)) = obj.get("$bytes") + && obj.len() == 1 + { + if let Ok(bytes) = BASE64_STANDARD_INDIFFERENT.decode(b64) { + return Ipld::Bytes(bytes); + } + } let map: BTreeMap = obj .iter() .map(|(k, v)| (k.clone(), json_to_ipld(v))) @@ -347,6 +363,128 @@ mod tests { panic!("Failed to find CID link in parsed CBOR"); } + #[test] + fn test_json_to_ipld_bytes_simple() { + let json = serde_json::json!({ + "$bytes": "aGVsbG8gd29ybGQ=" + }); + let ipld = json_to_ipld(&json); + match ipld { + Ipld::Bytes(bytes) => { + assert_eq!(bytes, b"hello world"); + } + _ => panic!("Expected Ipld::Bytes, got {:?}", ipld), + } + } + + #[test] + fn test_json_to_ipld_bytes_empty() { + let json = serde_json::json!({ + "$bytes": "" + }); + let ipld = json_to_ipld(&json); + match ipld { + Ipld::Bytes(bytes) => { + assert!(bytes.is_empty()); + } + _ => panic!("Expected Ipld::Bytes, got {:?}", ipld), + } + } + + #[test] + fn test_json_to_ipld_bytes_with_special_base64_chars() { + let json = serde_json::json!({ + "$bytes": "ygoGIpnVb/HQTIZythM9t1iLHkoWY5OeeqlhD0JEEgqHedDSCxG8F1YfipZPMA3JzKG6ssWNzOmZ9iSSW0nDvmjJ5ldwwbgt" + }); + let ipld = json_to_ipld(&json); + match ipld { + Ipld::Bytes(bytes) => { + assert!(!bytes.is_empty()); + } + _ => panic!("Expected Ipld::Bytes, got {:?}", ipld), + } + } + + #[test] + fn test_json_to_ipld_bytes_unpadded() { + let padded = json_to_ipld(&serde_json::json!({ "$bytes": "aGVsbG8=" })); + let unpadded = json_to_ipld(&serde_json::json!({ "$bytes": "aGVsbG8" })); + match (&padded, &unpadded) { + (Ipld::Bytes(a), Ipld::Bytes(b)) => { + assert_eq!(a, b"hello"); + assert_eq!(b, b"hello"); + } + _ => panic!( + "Expected Ipld::Bytes for both, got {:?} / {:?}", + padded, unpadded + ), + } + } + + #[test] + fn test_json_to_ipld_bytes_produces_cbor_byte_string_not_map() { + let json = serde_json::json!({"$bytes": "SGVsbG8="}); + let ipld = json_to_ipld(&json); + let cbor = serde_ipld_dagcbor::to_vec(&ipld).expect("CBOR serialization failed"); + assert_eq!( + cbor[0] & 0xE0, + 0x40, + "expected CBOR byte string (major type 2), got major type {}", + cbor[0] >> 5 + ); + } + + #[test] + fn test_json_to_ipld_bytes_not_confused_with_extra_keys() { + let json = serde_json::json!({ + "$bytes": "aGVsbG8=", + "extra": "field" + }); + let ipld = json_to_ipld(&json); + match ipld { + Ipld::Map(_) => {} + _ => panic!( + "Expected Ipld::Map for $bytes with extra keys, got {:?}", + ipld + ), + } + } + + #[test] + fn test_json_to_ipld_bytes_nested_in_record() { + let record = serde_json::json!({ + "$type": "app.opake.grant", + "recipient": "did:plc:example", + "wrappedKey": { + "algo": "x25519-hkdf-a256kw", + "ciphertext": { + "$bytes": "ygoGIpnVb/HQTIZythM9t1iLHkoWY5OeeqlhD0JEEgqHedDSCxG8F1YfipZPMA3JzKG6ssWNzOmZ9iSSW0nDvmjJ5ldwwbgt" + } + }, + "encryptedMetadata": { + "ciphertext": { "$bytes": "aGVsbG8=" }, + "nonce": { "$bytes": "d29ybGQ=" } + } + }); + let ipld = json_to_ipld(&record); + let cbor_bytes = serde_ipld_dagcbor::to_vec(&ipld).expect("CBOR serialization failed"); + let parsed: Ipld = + serde_ipld_dagcbor::from_slice(&cbor_bytes).expect("CBOR deserialization failed"); + if let Ipld::Map(map) = &parsed + && let Some(Ipld::Map(wrapped)) = map.get("wrappedKey") + && let Some(Ipld::Bytes(ct)) = wrapped.get("ciphertext") + && let Some(Ipld::Map(meta)) = map.get("encryptedMetadata") + && let Some(Ipld::Bytes(meta_ct)) = meta.get("ciphertext") + && let Some(Ipld::Bytes(nonce)) = meta.get("nonce") + { + assert!(!ct.is_empty()); + assert_eq!(meta_ct, b"hello"); + assert_eq!(nonce, b"world"); + return; + } + panic!("Failed to find Bytes in parsed CBOR: {:?}", parsed); + } + #[test] fn test_parse_env_bool_true_values() { unsafe { std::env::set_var("TEST_PARSE_ENV_BOOL_1", "true") };