Skip to main content

aleph_cid/
car.rs

1//! CARv1 framing for IPFS directory uploads.
2//!
3//! Reference: <https://ipld.io/specs/transport/car/carv1/>
4//!
5//! Provides hand-rolled writers (`write_carv1_header`, `write_block_frame`)
6//! and a strict reader (`read_carv1_root`) that heph re-uses to validate
7//! uploaded CARs in its stub `add_car` handler.
8
9use std::io::{self, Read, Write};
10
11/// Upper bound on the declared CARv1 header size. A single-root header is
12/// ~40 bytes; this cap exists to bound allocations from a malicious varint.
13pub(crate) const MAX_CAR_HEADER_BYTES: usize = 8 * 1024;
14
15/// Write an unsigned LEB128 varint.
16pub(crate) fn write_uvarint<W: Write>(w: &mut W, mut n: u64) -> io::Result<()> {
17    while n >= 0x80 {
18        w.write_all(&[(n as u8) | 0x80])?;
19        n >>= 7;
20    }
21    w.write_all(&[n as u8])
22}
23
24/// Build the DAG-CBOR header bytes for a single-root CARv1.
25///
26/// The header is a 2-entry map `{"roots": [<root>], "version": 1}`. DAG-CBOR
27/// requires deterministic key order (length-first then lexicographic on the
28/// CBOR-encoded keys); "roots" (6 bytes encoded) sorts before "version" (8).
29///
30/// `root_cid_bytes` is the canonical binary CID: for CIDv1, `[varint(1),
31/// varint(codec), multihash]`; for CIDv0, the bare multihash. Either is
32/// accepted; DAG-CBOR wraps it with the 0x00 multibase identity prefix.
33pub(crate) fn build_dagcbor_header(root_cid_bytes: &[u8]) -> Vec<u8> {
34    let mut out = Vec::with_capacity(48 + root_cid_bytes.len());
35    out.push(0xA2); // map, 2 entries
36    // key "roots" (text string, length 5)
37    out.extend_from_slice(&[0x65, b'r', b'o', b'o', b't', b's']);
38    // value: array of 1 CID
39    out.push(0x81); // array, 1 entry
40    out.push(0xD8); // tag, 1-byte tag value follows
41    out.push(0x2A); // tag 42 (CID)
42    // byte string: 0x00 multibase prefix + cid_bytes
43    let bytestring_len = 1 + root_cid_bytes.len();
44    write_cbor_bytestring_header(&mut out, bytestring_len);
45    out.push(0x00);
46    out.extend_from_slice(root_cid_bytes);
47    // key "version" (text string, length 7)
48    out.extend_from_slice(&[0x67, b'v', b'e', b'r', b's', b'i', b'o', b'n']);
49    // value: uint 1 (CBOR short form)
50    out.push(0x01);
51    out
52}
53
54fn write_cbor_bytestring_header(out: &mut Vec<u8>, len: usize) {
55    // Major type 2 (byte string). Short form 0x40..=0x57 for len 0..=23.
56    if len <= 23 {
57        out.push(0x40 | (len as u8));
58    } else if len <= u8::MAX as usize {
59        out.push(0x58);
60        out.push(len as u8);
61    } else if len <= u16::MAX as usize {
62        out.push(0x59);
63        out.extend_from_slice(&(len as u16).to_be_bytes());
64    } else if len <= u32::MAX as usize {
65        out.push(0x5A);
66        out.extend_from_slice(&(len as u32).to_be_bytes());
67    } else {
68        out.push(0x5B);
69        out.extend_from_slice(&(len as u64).to_be_bytes());
70    }
71}
72
73/// Write a complete CARv1 header (leading varint length + DAG-CBOR header)
74/// for a single-root file.
75pub fn write_carv1_header<W: Write>(w: &mut W, root_cid_bytes: &[u8]) -> io::Result<()> {
76    let header = build_dagcbor_header(root_cid_bytes);
77    write_uvarint(w, header.len() as u64)?;
78    w.write_all(&header)
79}
80
81/// Write one CARv1 block frame: `varint(cid_len + data_len) || cid || data`.
82pub fn write_block_frame<W: Write>(
83    w: &mut W,
84    cid_bytes: &[u8],
85    block_bytes: &[u8],
86) -> io::Result<()> {
87    let total = cid_bytes.len() + block_bytes.len();
88    write_uvarint(w, total as u64)?;
89    w.write_all(cid_bytes)?;
90    w.write_all(block_bytes)
91}
92
93/// Errors produced by [`read_carv1_root`].
94#[derive(Debug, thiserror::Error)]
95pub enum InvalidCarFile {
96    #[error("malformed varint")]
97    MalformedVarint,
98    #[error("declared header size exceeds maximum ({MAX_CAR_HEADER_BYTES} bytes)")]
99    HeaderTooLarge,
100    #[error("truncated CAR header")]
101    TruncatedHeader,
102    #[error("malformed DAG-CBOR header")]
103    MalformedHeader,
104    #[error("unsupported CAR version (got {got}, expected 1)")]
105    UnsupportedVersion { got: u64 },
106    #[error("expected exactly 1 root, got {got}")]
107    BadRootCount { got: usize },
108    #[error("malformed root CID")]
109    MalformedRootCid,
110    #[error("I/O error reading CAR file: {0}")]
111    Io(#[from] std::io::Error),
112}
113
114/// Read the CARv1 header from `path` and return its single root CID as a
115/// canonical string (base32 CIDv1 or base58btc CIDv0). Does not read or
116/// validate any block past the header.
117pub fn read_carv1_root(path: &std::path::Path) -> Result<String, InvalidCarFile> {
118    let mut f = std::fs::File::open(path)?;
119    read_carv1_root_from(&mut f)
120}
121
122/// `Read`-based variant of [`read_carv1_root`]; the file entry point is a
123/// thin wrapper around this.
124pub(crate) fn read_carv1_root_from<R: Read>(r: &mut R) -> Result<String, InvalidCarFile> {
125    let header_len = read_uvarint_checked(r)?;
126    if header_len > MAX_CAR_HEADER_BYTES as u64 {
127        return Err(InvalidCarFile::HeaderTooLarge);
128    }
129    let mut header = vec![0u8; header_len as usize];
130    r.read_exact(&mut header)
131        .map_err(|_| InvalidCarFile::TruncatedHeader)?;
132    parse_dagcbor_header(&header)
133}
134
135fn read_uvarint_checked<R: Read>(r: &mut R) -> Result<u64, InvalidCarFile> {
136    let mut result: u64 = 0;
137    let mut shift = 0u32;
138    for _ in 0..10 {
139        let mut buf = [0u8; 1];
140        if r.read_exact(&mut buf).is_err() {
141            return Err(InvalidCarFile::MalformedVarint);
142        }
143        let byte = buf[0];
144        let payload = (byte & 0x7F) as u64;
145        // Reject over-long encodings: at shift 63, only payload 0 or 1 is valid,
146        // and the continuation bit must be clear.
147        if shift == 63 && (payload > 1 || byte & 0x80 != 0) {
148            return Err(InvalidCarFile::MalformedVarint);
149        }
150        result |= payload << shift;
151        if byte & 0x80 == 0 {
152            return Ok(result);
153        }
154        shift += 7;
155    }
156    Err(InvalidCarFile::MalformedVarint)
157}
158
159/// Parse the fixed `{"roots":[<cid>],"version":1}` shape this module emits.
160/// Accept entries in either order (defensive parity with the pyaleph parser).
161fn parse_dagcbor_header(bytes: &[u8]) -> Result<String, InvalidCarFile> {
162    let mut cur = HeaderCursor::new(bytes);
163    let head = cur.next_byte()?;
164    if head != 0xA2 {
165        return Err(InvalidCarFile::MalformedHeader);
166    }
167    let mut roots_cid: Option<Vec<u8>> = None;
168    let mut roots_count: Option<usize> = None;
169    let mut version: Option<u64> = None;
170    for _ in 0..2 {
171        let key = cur.read_text()?;
172        match key.as_str() {
173            "roots" => {
174                let n = cur.read_array_header()?;
175                roots_count = Some(n);
176                if n != 1 {
177                    // Report early - we cannot safely skip unknown array entries.
178                    return Err(InvalidCarFile::BadRootCount { got: n });
179                }
180                let tag1 = cur.next_byte()?;
181                let tag2 = cur.next_byte()?;
182                if tag1 != 0xD8 || tag2 != 0x2A {
183                    return Err(InvalidCarFile::MalformedRootCid);
184                }
185                let bs = cur.read_bytestring()?;
186                if bs.is_empty() || bs[0] != 0x00 {
187                    return Err(InvalidCarFile::MalformedRootCid);
188                }
189                roots_cid = Some(bs[1..].to_vec());
190            }
191            "version" => {
192                version = Some(cur.read_uint()?);
193            }
194            _ => return Err(InvalidCarFile::MalformedHeader),
195        }
196    }
197    if roots_count.is_none() {
198        return Err(InvalidCarFile::MalformedHeader);
199    }
200    let cid_bytes = roots_cid.ok_or(InvalidCarFile::MalformedRootCid)?;
201    let version = version.ok_or(InvalidCarFile::MalformedHeader)?;
202    if version != 1 {
203        return Err(InvalidCarFile::UnsupportedVersion { got: version });
204    }
205    let cid = ::cid::Cid::try_from(&cid_bytes[..]).map_err(|_| InvalidCarFile::MalformedRootCid)?;
206    // Trailing bytes inside the declared header length are silently accepted.
207    // Producers we control never pad; aligns with pyaleph's parser behaviour.
208    Ok(cid.to_string())
209}
210
211struct HeaderCursor<'a> {
212    bytes: &'a [u8],
213    pos: usize,
214}
215
216impl<'a> HeaderCursor<'a> {
217    fn new(bytes: &'a [u8]) -> Self {
218        Self { bytes, pos: 0 }
219    }
220    fn next_byte(&mut self) -> Result<u8, InvalidCarFile> {
221        let b = *self
222            .bytes
223            .get(self.pos)
224            .ok_or(InvalidCarFile::MalformedHeader)?;
225        self.pos += 1;
226        Ok(b)
227    }
228    fn take(&mut self, n: usize) -> Result<&'a [u8], InvalidCarFile> {
229        if self.pos + n > self.bytes.len() {
230            return Err(InvalidCarFile::MalformedHeader);
231        }
232        let out = &self.bytes[self.pos..self.pos + n];
233        self.pos += n;
234        Ok(out)
235    }
236    fn read_text(&mut self) -> Result<String, InvalidCarFile> {
237        let head = self.next_byte()?;
238        let len = match head {
239            0x60..=0x77 => (head - 0x60) as usize,
240            0x78 => self.next_byte()? as usize,
241            0x79 => {
242                let hi = self.next_byte()? as u16;
243                let lo = self.next_byte()? as u16;
244                ((hi << 8) | lo) as usize
245            }
246            _ => return Err(InvalidCarFile::MalformedHeader),
247        };
248        let bytes = self.take(len)?;
249        std::str::from_utf8(bytes)
250            .map(|s| s.to_string())
251            .map_err(|_| InvalidCarFile::MalformedHeader)
252    }
253    fn read_array_header(&mut self) -> Result<usize, InvalidCarFile> {
254        let head = self.next_byte()?;
255        match head {
256            0x80..=0x97 => Ok((head - 0x80) as usize),
257            0x98 => Ok(self.next_byte()? as usize),
258            0x99 => {
259                let hi = self.next_byte()? as u16;
260                let lo = self.next_byte()? as u16;
261                Ok(((hi << 8) | lo) as usize)
262            }
263            _ => Err(InvalidCarFile::MalformedHeader),
264        }
265    }
266    fn read_bytestring(&mut self) -> Result<&'a [u8], InvalidCarFile> {
267        let head = self.next_byte()?;
268        let len = match head {
269            0x40..=0x57 => (head - 0x40) as usize,
270            0x58 => self.next_byte()? as usize,
271            0x59 => {
272                let hi = self.next_byte()? as u16;
273                let lo = self.next_byte()? as u16;
274                ((hi << 8) | lo) as usize
275            }
276            _ => return Err(InvalidCarFile::MalformedHeader),
277        };
278        self.take(len)
279    }
280    fn read_uint(&mut self) -> Result<u64, InvalidCarFile> {
281        let head = self.next_byte()?;
282        match head {
283            0x00..=0x17 => Ok(head as u64),
284            0x18 => Ok(self.next_byte()? as u64),
285            0x19 => Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap()) as u64),
286            0x1A => Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap()) as u64),
287            0x1B => Ok(u64::from_be_bytes(self.take(8)?.try_into().unwrap())),
288            _ => Err(InvalidCarFile::MalformedHeader),
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use cid::Cid as RawCid;
297    use multihash::Multihash;
298    use std::io::Read;
299
300    /// Read an unsigned LEB128 varint. Returns (value, bytes_consumed).
301    // NOTE: This helper is only safe for round-tripping bytes from write_uvarint.
302    // Task 3's production reader must additionally reject payload > 1 at shift == 63.
303    fn read_uvarint(r: &mut impl Read) -> io::Result<(u64, usize)> {
304        let mut result: u64 = 0;
305        let mut shift = 0u32;
306        let mut bytes_read = 0;
307        loop {
308            let mut buf = [0u8; 1];
309            r.read_exact(&mut buf)?;
310            bytes_read += 1;
311            let byte = buf[0];
312            let payload = (byte & 0x7F) as u64;
313            if shift >= 64 {
314                return Err(io::Error::new(
315                    io::ErrorKind::InvalidData,
316                    "varint too long",
317                ));
318            }
319            result |= payload << shift;
320            if byte & 0x80 == 0 {
321                return Ok((result, bytes_read));
322            }
323            shift += 7;
324        }
325    }
326
327    #[test]
328    fn varint_round_trip_small_values() {
329        for n in [0u64, 1, 0x7F, 0x80, 0xFF, 0x3FFF, 0x4000, 0xFFFFFFFF] {
330            let mut buf = Vec::new();
331            write_uvarint(&mut buf, n).unwrap();
332            let (got, consumed) = read_uvarint(&mut &buf[..]).unwrap();
333            assert_eq!(got, n, "round trip failed for {n}");
334            assert_eq!(consumed, buf.len(), "byte count off for {n}");
335        }
336    }
337
338    #[test]
339    fn varint_round_trip_u64_max() {
340        let mut buf = Vec::new();
341        write_uvarint(&mut buf, u64::MAX).unwrap();
342        let (got, _) = read_uvarint(&mut &buf[..]).unwrap();
343        assert_eq!(got, u64::MAX);
344        assert_eq!(buf.len(), 10, "u64::MAX should use 10 bytes");
345    }
346
347    #[test]
348    fn varint_encodes_127_as_one_byte() {
349        let mut buf = Vec::new();
350        write_uvarint(&mut buf, 127).unwrap();
351        assert_eq!(buf, vec![0x7F]);
352    }
353
354    #[test]
355    fn varint_encodes_128_as_two_bytes() {
356        let mut buf = Vec::new();
357        write_uvarint(&mut buf, 128).unwrap();
358        assert_eq!(buf, vec![0x80, 0x01]);
359    }
360
361    /// Build a CIDv1 dag-pb CID whose sha2-256 digest is `digest`. Used by
362    /// header-byte tests to construct an arbitrary deterministic CID.
363    fn make_cidv1_dagpb(digest: [u8; 32]) -> Vec<u8> {
364        let mh = Multihash::<64>::wrap(0x12, &digest).unwrap();
365        let cid = RawCid::new_v1(0x70, mh);
366        cid.to_bytes()
367    }
368
369    #[test]
370    fn dagcbor_header_v1_root_canonical_bytes() {
371        // CIDv1 dag-pb, sha256(zeros).
372        let cid_bytes = make_cidv1_dagpb([0u8; 32]);
373        let hdr = super::build_dagcbor_header(&cid_bytes);
374
375        // Expected byte layout:
376        // A2                     map 2
377        //   65 r o o t s         text "roots" (len 5)
378        //   81                   array 1
379        //     D8 2A              tag 42
380        //     58 <len> 00 ..cid  byte-string(len) of (0x00 || cid)
381        //   67 v e r s i o n     text "version" (len 7)
382        //   01                   uint 1
383
384        let mut expected = vec![0xA2];
385        expected.extend_from_slice(&[0x65, b'r', b'o', b'o', b't', b's']);
386        expected.push(0x81);
387        expected.push(0xD8);
388        expected.push(0x2A);
389
390        let bytestring_len = 1 + cid_bytes.len();
391        assert!(bytestring_len <= u8::MAX as usize);
392        expected.push(0x58);
393        expected.push(bytestring_len as u8);
394        expected.push(0x00);
395        expected.extend_from_slice(&cid_bytes);
396
397        expected.extend_from_slice(&[0x67, b'v', b'e', b'r', b's', b'i', b'o', b'n']);
398        expected.push(0x01);
399
400        assert_eq!(hdr, expected);
401    }
402
403    #[test]
404    fn dagcbor_header_small_cid_uses_short_bytestring() {
405        // 34-byte payload (CIDv0-sized). bytestring_len = 35, uses 0x58 branch.
406        let cid_bytes = vec![0u8; 34];
407        let hdr = super::build_dagcbor_header(&cid_bytes);
408        let tag_idx = hdr.iter().position(|&b| b == 0x2A).unwrap();
409        assert_eq!(
410            hdr[tag_idx + 1],
411            0x58,
412            "bytestring should use 0x58 form for len 35"
413        );
414        assert_eq!(hdr[tag_idx + 2], 35);
415    }
416
417    #[test]
418    fn read_carv1_root_round_trip() {
419        let cid_bytes = make_cidv1_dagpb([7u8; 32]);
420        let header = super::build_dagcbor_header(&cid_bytes);
421        let mut framed = Vec::new();
422        super::write_uvarint(&mut framed, header.len() as u64).unwrap();
423        framed.extend_from_slice(&header);
424
425        let got = super::read_carv1_root_from(&mut &framed[..]).unwrap();
426        let expected_cid_str = cid::Cid::try_from(&cid_bytes[..]).unwrap().to_string();
427        assert_eq!(got, expected_cid_str);
428    }
429
430    #[test]
431    fn read_carv1_root_truncated_varint() {
432        let bytes = [0x80u8]; // continuation bit set, no follow-up
433        let err = super::read_carv1_root_from(&mut &bytes[..]).unwrap_err();
434        assert!(matches!(err, super::InvalidCarFile::MalformedVarint));
435    }
436
437    #[test]
438    fn read_carv1_root_oversized_header_declared() {
439        let mut bytes = Vec::new();
440        super::write_uvarint(&mut bytes, (super::MAX_CAR_HEADER_BYTES + 1) as u64).unwrap();
441        let err = super::read_carv1_root_from(&mut &bytes[..]).unwrap_err();
442        assert!(matches!(err, super::InvalidCarFile::HeaderTooLarge));
443    }
444
445    #[test]
446    fn read_carv1_root_truncated_body() {
447        let mut bytes = Vec::new();
448        super::write_uvarint(&mut bytes, 40).unwrap();
449        bytes.extend_from_slice(&[0u8; 20]); // claim 40, give 20
450        let err = super::read_carv1_root_from(&mut &bytes[..]).unwrap_err();
451        assert!(matches!(err, super::InvalidCarFile::TruncatedHeader));
452    }
453
454    #[test]
455    fn read_carv1_root_version_2_rejected() {
456        let cid_bytes = make_cidv1_dagpb([1u8; 32]);
457        let mut hdr = vec![0xA2];
458        hdr.extend_from_slice(&[0x65, b'r', b'o', b'o', b't', b's']);
459        hdr.push(0x81);
460        hdr.extend_from_slice(&[0xD8, 0x2A]);
461        let bs_len = 1 + cid_bytes.len();
462        hdr.push(0x58);
463        hdr.push(bs_len as u8);
464        hdr.push(0x00);
465        hdr.extend_from_slice(&cid_bytes);
466        hdr.extend_from_slice(&[0x67, b'v', b'e', b'r', b's', b'i', b'o', b'n']);
467        hdr.push(0x02); // version 2
468
469        let mut framed = Vec::new();
470        super::write_uvarint(&mut framed, hdr.len() as u64).unwrap();
471        framed.extend_from_slice(&hdr);
472
473        let err = super::read_carv1_root_from(&mut &framed[..]).unwrap_err();
474        assert!(matches!(
475            err,
476            super::InvalidCarFile::UnsupportedVersion { got: 2 }
477        ));
478    }
479
480    #[test]
481    fn read_carv1_root_zero_roots_rejected() {
482        let mut hdr = vec![0xA2];
483        hdr.extend_from_slice(&[0x65, b'r', b'o', b'o', b't', b's']);
484        hdr.push(0x80); // empty array
485        hdr.extend_from_slice(&[0x67, b'v', b'e', b'r', b's', b'i', b'o', b'n']);
486        hdr.push(0x01);
487
488        let mut framed = Vec::new();
489        super::write_uvarint(&mut framed, hdr.len() as u64).unwrap();
490        framed.extend_from_slice(&hdr);
491
492        let err = super::read_carv1_root_from(&mut &framed[..]).unwrap_err();
493        assert!(matches!(
494            err,
495            super::InvalidCarFile::BadRootCount { got: 0 }
496        ));
497    }
498
499    #[test]
500    fn read_carv1_root_two_roots_rejected() {
501        let cid_bytes = make_cidv1_dagpb([2u8; 32]);
502        let mut hdr = vec![0xA2];
503        hdr.extend_from_slice(&[0x65, b'r', b'o', b'o', b't', b's']);
504        hdr.push(0x82); // array of 2
505        for _ in 0..2 {
506            hdr.extend_from_slice(&[0xD8, 0x2A]);
507            let bs_len = 1 + cid_bytes.len();
508            hdr.push(0x58);
509            hdr.push(bs_len as u8);
510            hdr.push(0x00);
511            hdr.extend_from_slice(&cid_bytes);
512        }
513        hdr.extend_from_slice(&[0x67, b'v', b'e', b'r', b's', b'i', b'o', b'n']);
514        hdr.push(0x01);
515
516        let mut framed = Vec::new();
517        super::write_uvarint(&mut framed, hdr.len() as u64).unwrap();
518        framed.extend_from_slice(&hdr);
519
520        let err = super::read_carv1_root_from(&mut &framed[..]).unwrap_err();
521        assert!(matches!(
522            err,
523            super::InvalidCarFile::BadRootCount { got: 2 }
524        ));
525    }
526
527    #[test]
528    fn read_carv1_root_malformed_cbor_rejected() {
529        let mut framed = Vec::new();
530        super::write_uvarint(&mut framed, 4).unwrap();
531        framed.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
532        let err = super::read_carv1_root_from(&mut &framed[..]).unwrap_err();
533        assert!(matches!(err, super::InvalidCarFile::MalformedHeader));
534    }
535
536    #[test]
537    fn read_carv1_root_overlong_varint_rejected() {
538        // 9 continuation bytes + a 10th byte with payload=2 at shift=63 -> overflow.
539        // Without the shift==63 guard, this would silently truncate (release) or
540        // panic (debug); the guard maps it to MalformedVarint.
541        let bytes = [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x02];
542        let err = super::read_carv1_root_from(&mut &bytes[..]).unwrap_err();
543        assert!(matches!(err, super::InvalidCarFile::MalformedVarint));
544    }
545
546    #[test]
547    fn read_carv1_root_version_first_ordering_accepted() {
548        // Craft a header with "version" first, then "roots". DAG-CBOR canonical
549        // ordering puts "roots" first (shorter), but our parser is defensively
550        // order-insensitive; this test pins that.
551        let cid_bytes = make_cidv1_dagpb([9u8; 32]);
552        let mut hdr = vec![0xA2];
553        // version: 1
554        hdr.extend_from_slice(&[0x67, b'v', b'e', b'r', b's', b'i', b'o', b'n']);
555        hdr.push(0x01);
556        // roots: [<cid>]
557        hdr.extend_from_slice(&[0x65, b'r', b'o', b'o', b't', b's']);
558        hdr.push(0x81);
559        hdr.extend_from_slice(&[0xD8, 0x2A]);
560        let bs_len = 1 + cid_bytes.len();
561        hdr.push(0x58);
562        hdr.push(bs_len as u8);
563        hdr.push(0x00);
564        hdr.extend_from_slice(&cid_bytes);
565
566        let mut framed = Vec::new();
567        super::write_uvarint(&mut framed, hdr.len() as u64).unwrap();
568        framed.extend_from_slice(&hdr);
569
570        let got = super::read_carv1_root_from(&mut &framed[..]).unwrap();
571        let expected = cid::Cid::try_from(&cid_bytes[..]).unwrap().to_string();
572        assert_eq!(got, expected);
573    }
574
575    #[test]
576    fn write_carv1_header_emits_varint_prefix_and_canonical_header() {
577        let cid_bytes = make_cidv1_dagpb([3u8; 32]);
578        let mut out = Vec::new();
579        super::write_carv1_header(&mut out, &cid_bytes).unwrap();
580
581        // Round-trip: a reader should recover the same CID.
582        let cid_str = super::read_carv1_root_from(&mut &out[..]).unwrap();
583        let expected = cid::Cid::try_from(&cid_bytes[..]).unwrap().to_string();
584        assert_eq!(cid_str, expected);
585    }
586
587    #[test]
588    fn write_block_frame_layout() {
589        let mut out = Vec::new();
590        super::write_block_frame(&mut out, b"abc", b"hello").unwrap();
591        // len = 3 + 5 = 8 -> varint 0x08
592        assert_eq!(out, b"\x08abchello");
593    }
594
595    #[test]
596    fn write_block_frame_large_payload_uses_multi_byte_varint() {
597        let cid = vec![0xAAu8; 36];
598        let block = vec![0xBBu8; 130]; // total 166 -> varint 0xA6 0x01
599        let mut out = Vec::new();
600        super::write_block_frame(&mut out, &cid, &block).unwrap();
601        assert_eq!(&out[..2], &[0xA6, 0x01]);
602        assert_eq!(&out[2..2 + cid.len()], &cid[..]);
603        assert_eq!(&out[2 + cid.len()..], &block[..]);
604    }
605}