fix: handle AT Protocol $bytes type in json_to_ipld

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.
This commit is contained in:
Not Herself
2026-03-15 07:15:07 +00:00
committed by Tangled
parent 546d342136
commit 92e609d367
+138
View File
@@ -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<String> = 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<String, Ipld> = 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") };