Skip to main content

fxcp_core/
cid.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2
3//! BLAKE3 multicodec CID encoding for content-addressed interoperability.
4//!
5//! Encodes/decodes BLAKE3 digests as IPLD-compatible CID v1 identifiers.
6//! Zero external dependencies beyond `hex` (already in fxcp-core).
7//!
8//! # CID v1 wire format
9//!
10//! ```text
11//! raw binary:      [0x01, 0x55, 0x1e, 0x20, <32 bytes>]        = 36 bytes
12//! blake3-hashseq:  [0x01, 0x80, 0x01, 0x1e, 0x20, <32 bytes>]  = 37 bytes
13//! ```
14//!
15//! Multibase string prefixes: `b` = base32lower (no padding), `f` = base16lower.
16
17// -- Constants ----------------------------------------------------------------
18
19/// CID v1 prefix for raw binary content + BLAKE3-256 multihash.
20pub const CID_V1_RAW_BLAKE3_PREFIX: [u8; 4] = [0x01, 0x55, 0x1e, 0x20];
21
22/// CID v1 prefix for blake3-hashseq (iroh-compatible collections).
23/// `0x80` requires 2-byte unsigned varint: `[0x80, 0x01]`.
24pub const CID_V1_HASHSEQ_BLAKE3_PREFIX: [u8; 5] = [0x01, 0x80, 0x01, 0x1e, 0x20];
25
26/// Total byte length of a raw BLAKE3 CID v1 (prefix + 32-byte digest).
27pub const CID_RAW_BLAKE3_LEN: usize = 36;
28
29/// Total byte length of a hashseq BLAKE3 CID v1.
30pub const CID_HASHSEQ_BLAKE3_LEN: usize = 37;
31
32/// Multicodec code for BLAKE3 multihash function.
33pub const MULTICODEC_BLAKE3: u64 = 0x1e;
34
35/// Multicodec code for raw binary content type.
36pub const MULTICODEC_RAW: u64 = 0x55;
37
38/// Multicodec code for blake3-hashseq (iroh collections).
39pub const MULTICODEC_BLAKE3_HASHSEQ: u64 = 0x80;
40
41// -- Error type ---------------------------------------------------------------
42
43/// Errors from CID encoding/decoding operations.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum CidError {
46    /// Invalid hex string input.
47    InvalidHex(String),
48    /// Invalid CID structure.
49    InvalidCid(String),
50    /// Unrecognized multibase prefix.
51    InvalidMultibase(String),
52    /// CID prefix does not match expected BLAKE3 raw or hashseq.
53    WrongPrefix,
54    /// Digest length is not 32 bytes.
55    WrongDigestLength,
56}
57
58impl std::fmt::Display for CidError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::InvalidHex(msg) => write!(f, "invalid hex: {msg}"),
62            Self::InvalidCid(msg) => write!(f, "invalid CID: {msg}"),
63            Self::InvalidMultibase(msg) => write!(f, "invalid multibase: {msg}"),
64            Self::WrongPrefix => f.write_str("CID prefix is not BLAKE3 raw or hashseq"),
65            Self::WrongDigestLength => f.write_str("digest length is not 32 bytes"),
66        }
67    }
68}
69
70impl std::error::Error for CidError {}
71
72// -- Base32lower (RFC 4648, no padding) ---------------------------------------
73
74const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
75
76/// Inverse lookup: ASCII byte -> 5-bit value. `0xFF` = invalid.
77const BASE32_DECODE_TABLE: [u8; 128] = {
78    let mut t = [0xFF_u8; 128];
79    let mut i = 0_u8;
80    while i < 26 {
81        t[(b'a' + i) as usize] = i;
82        i += 1;
83    }
84    i = 0;
85    while i < 6 {
86        t[(b'2' + i) as usize] = 26 + i;
87        i += 1;
88    }
89    t
90};
91
92/// Remaining-byte count -> base32 character count (no padding).
93const REMAINDER_CHARS: [usize; 5] = [0, 2, 4, 5, 7];
94
95fn base32_encode(input: &[u8]) -> String {
96    let mut out = String::with_capacity((input.len() * 8).div_ceil(5));
97    let full = input.chunks_exact(5);
98    let tail = full.remainder();
99
100    for chunk in full {
101        let n = (chunk[0] as u64) << 32
102            | (chunk[1] as u64) << 24
103            | (chunk[2] as u64) << 16
104            | (chunk[3] as u64) << 8
105            | chunk[4] as u64;
106        for &shift in &[35, 30, 25, 20, 15, 10, 5, 0_u8] {
107            out.push(BASE32_ALPHABET[((n >> shift) & 0x1f) as usize] as char);
108        }
109    }
110
111    if !tail.is_empty() {
112        let mut buf = [0_u8; 5];
113        buf[..tail.len()].copy_from_slice(tail);
114        let n = (buf[0] as u64) << 32
115            | (buf[1] as u64) << 24
116            | (buf[2] as u64) << 16
117            | (buf[3] as u64) << 8
118            | buf[4] as u64;
119        let count = REMAINDER_CHARS[tail.len()];
120        let shifts: [u8; 7] = [35, 30, 25, 20, 15, 10, 5];
121        for &shift in &shifts[..count] {
122            out.push(BASE32_ALPHABET[((n >> shift) & 0x1f) as usize] as char);
123        }
124    }
125    out
126}
127
128fn base32_decode(input: &str) -> Result<Vec<u8>, CidError> {
129    let src = input.as_bytes();
130    if src.is_empty() {
131        return Ok(Vec::new());
132    }
133    for &b in src {
134        if b >= 128 || BASE32_DECODE_TABLE[b as usize] == 0xFF {
135            return Err(CidError::InvalidMultibase(format!(
136                "invalid base32 char: '{}'",
137                b as char
138            )));
139        }
140    }
141
142    let mut out = Vec::with_capacity(src.len() * 5 / 8);
143    let full = src.chunks_exact(8);
144    let tail = full.remainder();
145
146    for chunk in full {
147        let n = (BASE32_DECODE_TABLE[chunk[0] as usize] as u64) << 35
148            | (BASE32_DECODE_TABLE[chunk[1] as usize] as u64) << 30
149            | (BASE32_DECODE_TABLE[chunk[2] as usize] as u64) << 25
150            | (BASE32_DECODE_TABLE[chunk[3] as usize] as u64) << 20
151            | (BASE32_DECODE_TABLE[chunk[4] as usize] as u64) << 15
152            | (BASE32_DECODE_TABLE[chunk[5] as usize] as u64) << 10
153            | (BASE32_DECODE_TABLE[chunk[6] as usize] as u64) << 5
154            | BASE32_DECODE_TABLE[chunk[7] as usize] as u64;
155        out.extend_from_slice(&[
156            (n >> 32) as u8,
157            (n >> 24) as u8,
158            (n >> 16) as u8,
159            (n >> 8) as u8,
160            n as u8,
161        ]);
162    }
163
164    if !tail.is_empty() {
165        let byte_count = match tail.len() {
166            2 => 1,
167            4 => 2,
168            5 => 3,
169            7 => 4,
170            other => {
171                return Err(CidError::InvalidMultibase(format!(
172                    "invalid base32 trailing length: {other}"
173                )));
174            }
175        };
176        let mut buf = [0_u8; 8];
177        for (i, &b) in tail.iter().enumerate() {
178            buf[i] = BASE32_DECODE_TABLE[b as usize];
179        }
180        let n = (buf[0] as u64) << 35
181            | (buf[1] as u64) << 30
182            | (buf[2] as u64) << 25
183            | (buf[3] as u64) << 20
184            | (buf[4] as u64) << 15
185            | (buf[5] as u64) << 10
186            | (buf[6] as u64) << 5
187            | buf[7] as u64;
188        for i in 0..byte_count {
189            out.push((n >> (32 - i * 8)) as u8);
190        }
191    }
192    Ok(out)
193}
194
195// -- Encoding: digest -> CID bytes --------------------------------------------
196
197/// Encode a BLAKE3 digest as raw CID v1 bytes (36 bytes).
198#[must_use]
199pub fn blake3_to_cid_bytes(digest: &[u8; 32]) -> [u8; CID_RAW_BLAKE3_LEN] {
200    let mut cid = [0_u8; CID_RAW_BLAKE3_LEN];
201    cid[..4].copy_from_slice(&CID_V1_RAW_BLAKE3_PREFIX);
202    cid[4..].copy_from_slice(digest);
203    cid
204}
205
206/// Encode a BLAKE3 digest as hashseq CID v1 bytes (37 bytes).
207#[must_use]
208pub fn blake3_to_hashseq_cid_bytes(digest: &[u8; 32]) -> [u8; CID_HASHSEQ_BLAKE3_LEN] {
209    let mut cid = [0_u8; CID_HASHSEQ_BLAKE3_LEN];
210    cid[..5].copy_from_slice(&CID_V1_HASHSEQ_BLAKE3_PREFIX);
211    cid[5..].copy_from_slice(digest);
212    cid
213}
214
215// -- Encoding: digest -> CID string -------------------------------------------
216
217/// Encode a BLAKE3 digest as a base32lower CID string (`b` multibase prefix).
218#[must_use]
219pub fn blake3_to_cid_string(digest: &[u8; 32]) -> String {
220    let cid = blake3_to_cid_bytes(digest);
221    format!("b{}", base32_encode(&cid))
222}
223
224/// Encode a BLAKE3 digest as a hex CID string (`f` multibase prefix).
225#[must_use]
226pub fn blake3_to_cid_hex(digest: &[u8; 32]) -> String {
227    let cid = blake3_to_cid_bytes(digest);
228    format!("f{}", hex::encode(cid))
229}
230
231/// Encode a BLAKE3 digest as a base32lower hashseq CID string.
232#[must_use]
233pub fn blake3_to_hashseq_cid_string(digest: &[u8; 32]) -> String {
234    let cid = blake3_to_hashseq_cid_bytes(digest);
235    format!("b{}", base32_encode(&cid))
236}
237
238// -- Bridge: hex digest -> CID string -----------------------------------------
239
240/// Convert a BLAKE3 hex digest (64 chars) to a base32lower CID string.
241pub fn blake3_hex_to_cid_string(hex_str: &str) -> Result<String, CidError> {
242    let bytes = hex::decode(hex_str).map_err(|e| CidError::InvalidHex(e.to_string()))?;
243    let digest: [u8; 32] = bytes
244        .try_into()
245        .map_err(|_| CidError::WrongDigestLength)?;
246    Ok(blake3_to_cid_string(&digest))
247}
248
249/// Normalize a BLAKE3 hash string to CID base32lower format.
250///
251/// If `s` is a 64-char hex digest, converts to CID. Otherwise returns `s` unchanged.
252/// Used at storage boundaries (pgvector, embedding index) to ensure consistent
253/// content-addressable identifiers.
254#[must_use]
255pub fn normalize_blake3_to_cid(s: &str) -> String {
256    blake3_hex_to_cid_string(s).unwrap_or_else(|_| s.to_string())
257}
258
259// -- Decoding: CID bytes -> digest --------------------------------------------
260
261/// Extract BLAKE3 digest from CID bytes. Returns `None` if prefix/length mismatch.
262#[must_use]
263pub fn cid_bytes_to_blake3(cid: &[u8]) -> Option<[u8; 32]> {
264    let (prefix_len, prefix) = if cid.len() == CID_RAW_BLAKE3_LEN {
265        (4, &CID_V1_RAW_BLAKE3_PREFIX[..])
266    } else if cid.len() == CID_HASHSEQ_BLAKE3_LEN {
267        (5, &CID_V1_HASHSEQ_BLAKE3_PREFIX[..])
268    } else {
269        return None;
270    };
271    if cid[..prefix_len] != *prefix {
272        return None;
273    }
274    let mut digest = [0_u8; 32];
275    digest.copy_from_slice(&cid[prefix_len..]);
276    Some(digest)
277}
278
279/// Decode a multibase CID string to its BLAKE3 digest.
280pub fn cid_string_to_blake3(cid_str: &str) -> Result<[u8; 32], CidError> {
281    if cid_str.is_empty() {
282        return Err(CidError::InvalidCid("empty string".into()));
283    }
284    let prefix = &cid_str[..1];
285    let payload = &cid_str[1..];
286    let cid_bytes = match prefix {
287        "b" => base32_decode(payload)?,
288        "f" => hex::decode(payload).map_err(|e| CidError::InvalidHex(e.to_string()))?,
289        _ => {
290            return Err(CidError::InvalidMultibase(format!(
291                "unsupported prefix: '{prefix}'"
292            )));
293        }
294    };
295    cid_bytes_to_blake3(&cid_bytes).ok_or(CidError::WrongPrefix)
296}
297
298// -- Bridge: CID string -> hex digest -----------------------------------------
299
300/// Decode a CID string to its BLAKE3 hex digest.
301pub fn cid_string_to_hex(cid_str: &str) -> Result<String, CidError> {
302    cid_string_to_blake3(cid_str).map(hex::encode)
303}
304
305// -- Detection ----------------------------------------------------------------
306
307/// Check if raw bytes are a valid BLAKE3 CID (raw or hashseq prefix).
308#[must_use]
309pub fn is_blake3_cid_bytes(bytes: &[u8]) -> bool {
310    cid_bytes_to_blake3(bytes).is_some()
311}
312
313/// Check if a string looks like a 64-character hex BLAKE3 digest.
314#[must_use]
315pub fn is_blake3_hex(s: &str) -> bool {
316    s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
317}
318
319/// Check if a string is a valid base32lower or hex-encoded BLAKE3 CID.
320#[must_use]
321pub fn is_blake3_cid_string(s: &str) -> bool {
322    matches!(s.as_bytes().first(), Some(b'b' | b'f')) && cid_string_to_blake3(s).is_ok()
323}
324
325// -- Tests --------------------------------------------------------------------
326
327#[cfg(test)]
328mod tests {
329    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
330    use super::*;
331
332    // BLAKE3 of empty string b""
333    const EMPTY_DIGEST_HEX: &str =
334        "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
335    const EMPTY_CID_B32: &str =
336        "bafkr4ifpcne3t5pzugtkaqcn5i3nzskjtpfslsnnyejlpte2spfoihzsmi";
337    const EMPTY_CID_HEX: &str =
338        "f01551e20af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
339
340    fn empty_digest() -> [u8; 32] {
341        let bytes = hex::decode(EMPTY_DIGEST_HEX).unwrap();
342        bytes.try_into().unwrap()
343    }
344
345    #[test]
346    fn test_cid_bytes_roundtrip() {
347        let digest = empty_digest();
348        let cid = blake3_to_cid_bytes(&digest);
349        let recovered = cid_bytes_to_blake3(&cid).expect("roundtrip decode");
350        assert_eq!(recovered, digest);
351    }
352
353    #[test]
354    fn test_known_vector_base32() {
355        let digest = empty_digest();
356        let cid_str = blake3_to_cid_string(&digest);
357        assert_eq!(cid_str, EMPTY_CID_B32);
358    }
359
360    #[test]
361    fn test_known_vector_hex() {
362        let digest = empty_digest();
363        let cid_hex = blake3_to_cid_hex(&digest);
364        assert_eq!(cid_hex, EMPTY_CID_HEX);
365    }
366
367    #[test]
368    fn test_cid_string_roundtrip() {
369        let digest = empty_digest();
370        let cid_str = blake3_to_cid_string(&digest);
371        let recovered = cid_string_to_blake3(&cid_str).expect("string roundtrip");
372        assert_eq!(recovered, digest);
373    }
374
375    #[test]
376    fn test_hex_to_cid_bridge() {
377        let cid = blake3_hex_to_cid_string(EMPTY_DIGEST_HEX).expect("hex->cid");
378        assert_eq!(cid, EMPTY_CID_B32);
379    }
380
381    #[test]
382    fn test_cid_to_hex_bridge() {
383        let hex_out = cid_string_to_hex(EMPTY_CID_B32).expect("cid->hex");
384        assert_eq!(hex_out, EMPTY_DIGEST_HEX);
385    }
386
387    #[test]
388    fn test_is_blake3_hex_valid() {
389        assert!(is_blake3_hex(EMPTY_DIGEST_HEX));
390    }
391
392    #[test]
393    fn test_is_blake3_hex_invalid() {
394        assert!(!is_blake3_hex(EMPTY_CID_B32));
395    }
396
397    #[test]
398    fn test_is_blake3_cid_string_valid() {
399        assert!(is_blake3_cid_string(EMPTY_CID_B32));
400        assert!(is_blake3_cid_string(EMPTY_CID_HEX));
401    }
402
403    #[test]
404    fn test_is_blake3_cid_string_invalid() {
405        assert!(!is_blake3_cid_string(EMPTY_DIGEST_HEX));
406        assert!(!is_blake3_cid_string("not-a-cid"));
407        assert!(!is_blake3_cid_string(""));
408    }
409
410    #[test]
411    fn test_hashseq_cid_roundtrip() {
412        let digest = empty_digest();
413        let cid = blake3_to_hashseq_cid_bytes(&digest);
414        assert_eq!(cid.len(), CID_HASHSEQ_BLAKE3_LEN);
415        assert_eq!(&cid[..5], &CID_V1_HASHSEQ_BLAKE3_PREFIX);
416        let recovered = cid_bytes_to_blake3(&cid).expect("hashseq roundtrip");
417        assert_eq!(recovered, digest);
418
419        // String roundtrip
420        let cid_str = blake3_to_hashseq_cid_string(&digest);
421        assert!(cid_str.starts_with('b'));
422        let recovered2 = cid_string_to_blake3(&cid_str).expect("hashseq string roundtrip");
423        assert_eq!(recovered2, digest);
424    }
425
426    #[test]
427    fn test_cid_prefix_constant() {
428        assert_eq!(CID_V1_RAW_BLAKE3_PREFIX, [0x01, 0x55, 0x1e, 0x20]);
429        assert_eq!(
430            CID_V1_HASHSEQ_BLAKE3_PREFIX,
431            [0x01, 0x80, 0x01, 0x1e, 0x20]
432        );
433    }
434
435    #[test]
436    fn test_wrong_prefix_rejected() {
437        // CID-shaped bytes with wrong content-type codec (dag-pb = 0x70 instead of raw = 0x55)
438        let digest = empty_digest();
439        let mut bad_cid = [0_u8; CID_RAW_BLAKE3_LEN];
440        bad_cid[0] = 0x01;
441        bad_cid[1] = 0x70; // dag-pb, not raw
442        bad_cid[2] = 0x1e;
443        bad_cid[3] = 0x20;
444        bad_cid[4..].copy_from_slice(&digest);
445        assert!(cid_bytes_to_blake3(&bad_cid).is_none());
446
447        // Wrong length
448        assert!(cid_bytes_to_blake3(&[0x01, 0x55, 0x1e]).is_none());
449    }
450
451    #[test]
452    fn test_base32_encode_decode_roundtrip() {
453        // Empty
454        assert_eq!(base32_decode(&base32_encode(&[])).unwrap(), &[] as &[u8]);
455
456        // All remainder lengths: 1, 2, 3, 4, 5 bytes
457        for len in 1..=5 {
458            let input: Vec<u8> = (0..len).map(|i| (i * 37 + 13) as u8).collect();
459            let encoded = base32_encode(&input);
460            let decoded = base32_decode(&encoded).unwrap();
461            assert_eq!(decoded, input, "roundtrip failed for {len}-byte input");
462        }
463
464        // Larger: 36 bytes (CID-sized), 37 bytes (hashseq CID-sized)
465        for len in [36, 37, 64, 100, 255] {
466            let input: Vec<u8> = (0..len).map(|i| i as u8).collect();
467            let decoded = base32_decode(&base32_encode(&input)).unwrap();
468            assert_eq!(decoded, input, "roundtrip failed for {len}-byte input");
469        }
470
471        // All-zeros and all-ones
472        let zeros = vec![0_u8; 32];
473        assert_eq!(base32_decode(&base32_encode(&zeros)).unwrap(), zeros);
474        let ones = vec![0xFF_u8; 32];
475        assert_eq!(base32_decode(&base32_encode(&ones)).unwrap(), ones);
476    }
477
478    #[test]
479    fn test_live_blake3_hash() {
480        let hash = blake3::hash(b"foxing");
481        let digest: [u8; 32] = *hash.as_bytes();
482        let cid_str = blake3_to_cid_string(&digest);
483        let recovered = cid_string_to_blake3(&cid_str).expect("live hash roundtrip");
484        assert_eq!(recovered, digest);
485
486        // Also verify hex bridge
487        let hex_digest = hex::encode(digest);
488        let cid_via_hex = blake3_hex_to_cid_string(&hex_digest).expect("live hex->cid");
489        assert_eq!(cid_via_hex, cid_str);
490    }
491
492    #[test]
493    fn test_all_zeros_digest_roundtrip() {
494        let zeros = [0u8; 32];
495        let cid = blake3_to_cid_string(&zeros);
496        assert!(cid.starts_with("b"));
497        let decoded = cid_string_to_blake3(&cid).unwrap();
498        assert_eq!(decoded, zeros);
499    }
500
501    #[test]
502    fn test_truncated_cid_string_rejected() {
503        assert!(cid_string_to_blake3("baf").is_err());
504    }
505
506    #[test]
507    fn test_wrong_multibase_prefix_rejected() {
508        // z = base58btc, not supported by our decoder
509        assert!(cid_string_to_blake3("zSomeBase58String").is_err());
510    }
511
512    #[test]
513    fn test_oversized_cid_bytes_rejected() {
514        let mut oversized = [0u8; 38];
515        oversized[..4].copy_from_slice(&CID_V1_RAW_BLAKE3_PREFIX);
516        assert!(cid_bytes_to_blake3(&oversized).is_none());
517    }
518
519    #[test]
520    fn test_invalid_base32_chars_rejected() {
521        assert!(cid_string_to_blake3("b!!!invalid!!!").is_err());
522    }
523
524    #[test]
525    fn test_odd_length_hex_rejected() {
526        let odd = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f326"; // 63 chars
527        assert!(blake3_hex_to_cid_string(odd).is_err());
528    }
529
530    #[test]
531    fn test_f_prefixed_hex_cid_roundtrip() {
532        let digest = *blake3::hash(b"test-f-prefix").as_bytes();
533        let hex_cid = blake3_to_cid_hex(&digest);
534        assert!(hex_cid.starts_with("f01551e20"));
535        let decoded = cid_string_to_blake3(&hex_cid).unwrap();
536        assert_eq!(decoded, digest);
537    }
538
539    #[test]
540    fn test_bare_hex_not_detected_as_cid() {
541        let hex = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
542        assert!(!is_blake3_cid_string(hex));
543        assert!(is_blake3_hex(hex));
544    }
545
546    // -- normalize_blake3_to_cid tests (moved from pgvector.rs + embedding_index.rs) --
547
548    #[test]
549    fn test_normalize_blake3_to_cid_hex_input() {
550        let hex = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
551        let cid = normalize_blake3_to_cid(hex);
552        assert!(cid.starts_with('b'), "CID must start with 'b' multibase prefix");
553        assert_eq!(cid, "bafkr4ifpcne3t5pzugtkaqcn5i3nzskjtpfslsnnyejlpte2spfoihzsmi");
554    }
555
556    #[test]
557    fn test_normalize_blake3_to_cid_passthrough() {
558        let cid = "bafkr4ifpcne3t5pzugtkaqcn5i3nzskjtpfslsnnyejlpte2spfoihzsmi";
559        assert_eq!(normalize_blake3_to_cid(cid), cid);
560
561        let short = "not_a_hex_hash";
562        assert_eq!(normalize_blake3_to_cid(short), short);
563    }
564
565    #[test]
566    fn test_normalize_blake3_to_cid_empty() {
567        assert_eq!(normalize_blake3_to_cid(""), "");
568    }
569
570    #[test]
571    fn test_normalize_blake3_to_cid_identity_for_cid() {
572        let cid = "bafkr4ifpcne3t5pzugtkaqcn5i3nzskjtpfslsnnyejlpte2spfoihzsmi";
573        let result = normalize_blake3_to_cid(cid);
574        assert_eq!(result, cid, "CID input must pass through unchanged");
575
576        let short = normalize_blake3_to_cid("short_hash");
577        assert_eq!(short, "short_hash", "non-hex non-CID must pass through");
578    }
579
580    #[test]
581    fn test_normalize_blake3_to_cid_converts_hex() {
582        let hex = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
583        let expected = "bafkr4ifpcne3t5pzugtkaqcn5i3nzskjtpfslsnnyejlpte2spfoihzsmi";
584        let result = normalize_blake3_to_cid(hex);
585        assert_eq!(result, expected, "64-char hex must convert to CID base32lower");
586    }
587}