1pub const CID_V1_RAW_BLAKE3_PREFIX: [u8; 4] = [0x01, 0x55, 0x1e, 0x20];
21
22pub const CID_V1_HASHSEQ_BLAKE3_PREFIX: [u8; 5] = [0x01, 0x80, 0x01, 0x1e, 0x20];
25
26pub const CID_RAW_BLAKE3_LEN: usize = 36;
28
29pub const CID_HASHSEQ_BLAKE3_LEN: usize = 37;
31
32pub const MULTICODEC_BLAKE3: u64 = 0x1e;
34
35pub const MULTICODEC_RAW: u64 = 0x55;
37
38pub const MULTICODEC_BLAKE3_HASHSEQ: u64 = 0x80;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum CidError {
46 InvalidHex(String),
48 InvalidCid(String),
50 InvalidMultibase(String),
52 WrongPrefix,
54 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
72const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
75
76const 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
92const 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#[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#[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#[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#[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#[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
238pub 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#[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#[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
279pub 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
298pub fn cid_string_to_hex(cid_str: &str) -> Result<String, CidError> {
302 cid_string_to_blake3(cid_str).map(hex::encode)
303}
304
305#[must_use]
309pub fn is_blake3_cid_bytes(bytes: &[u8]) -> bool {
310 cid_bytes_to_blake3(bytes).is_some()
311}
312
313#[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#[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#[cfg(test)]
328mod tests {
329 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
330 use super::*;
331
332 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 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 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; 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 assert!(cid_bytes_to_blake3(&[0x01, 0x55, 0x1e]).is_none());
449 }
450
451 #[test]
452 fn test_base32_encode_decode_roundtrip() {
453 assert_eq!(base32_decode(&base32_encode(&[])).unwrap(), &[] as &[u8]);
455
456 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 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 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 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 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"; 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 #[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}