Skip to main content

nord_format/
cbin.rs

1//! The `CBIN` container: one type owning what every Nord file format shares — the
2//! header (both generations), the checksum policy, and the length bookkeeping.
3//!
4//! A format module contributes a [`Body`]: the bytes after the header, decoded from
5//! a [`BodyReader`] scoped so that position 0 is the first body byte. Bodies never
6//! see the header layout, the checksum, or the generation — which is what makes a
7//! type-0 format the same amount of work as a type-1 format.
8//!
9//! Type 1's tag, location, version, and body length match the device's object-info
10//! reply. Combining them with the downloaded body reproduces the file byte for byte.
11//!
12//! Confirmed on hardware.
13//!
14//! A type-0 header never crosses the wire, and its checksum covers the header.
15//!
16//! Inferred from specimens; not confirmed on hardware.
17//!
18//! | offset | type 0 | type 1 |
19//! |---|---|---|
20//! | `0x00` | `"CBIN"` | `"CBIN"` |
21//! | `0x04` | 0, LE u32 | 1 |
22//! | `0x08` | tag | tag |
23//! | `0x0c` | location | location |
24//! | `0x10` | aux | aux |
25//! | `0x14` | version | version |
26//! | `0x18` | body… | crc32 over the body, 16 zero bytes, body at `0x2c` |
27//! | EOF−2 | crc16 over every byte before it, LE | — |
28//!
29//! The container reads and writes in one forward pass with O(1) state; body allocation
30//! belongs to the body type.
31
32use crate::crc::{Crc16Stream, Crc32Stream};
33use crate::error::{try_vec, Error, ParseError};
34use std::io::{self, Read, Seek, SeekFrom, Write};
35
36pub const MAGIC: &[u8; 4] = b"CBIN";
37
38/// Bytes of the fields both generations share: magic through version.
39const HEAD_LEN: usize = 0x18;
40
41/// The two header layouts, named by the u32 at `0x04`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Generation {
44    V0,
45    V1,
46}
47
48impl Generation {
49    /// Offset of the first body byte.
50    pub fn body_start(self) -> u64 {
51        match self {
52            Generation::V0 => 0x18,
53            Generation::V1 => 0x2c,
54        }
55    }
56
57    /// Bytes of checksum after the body: the type-0 crc16 trails the file.
58    pub(crate) fn trailer_len(self) -> u64 {
59        match self {
60            Generation::V0 => 2,
61            Generation::V1 => 0,
62        }
63    }
64}
65
66/// The tag at `0x08`, kept as raw bytes.
67///
68/// ⚠️ Three tags are three characters plus a NUL (`nsp\0`, `nss\0`, `nwp\0`), so a
69/// format spells all four bytes and nothing pads implicitly.
70pub type Tag = [u8; 4];
71
72/// `format` as its 4-byte tag. Every format module's constant is four bytes by a
73/// compile-time assertion; a caller-supplied string of any other length is a bug in
74/// the caller, not a file condition, hence the panic.
75#[track_caller]
76fn tag(format: &str) -> Tag {
77    format
78        .as_bytes()
79        .try_into()
80        .unwrap_or_else(|_| panic!("format tag {format:?} is not 4 bytes — bug in format module"))
81}
82
83fn tag_str(tag: &Tag) -> String {
84    String::from_utf8_lossy(tag).into_owned()
85}
86
87/// The five fields both generations carry, verbatim.
88///
89/// No asserts live here: what `location` and `aux` mean is per format — a
90/// bank/slot pair on programs, a library location on samples, `0xFFFFFFFF` where
91/// unset — so the container preserves them and the format modules interpret them.
92#[derive(Clone, PartialEq, Eq)]
93pub struct Header {
94    pub generation: Generation,
95    pub tag: Tag,
96    /// u32 at `0x0c`. On slot-addressed formats the low u16 is the bank and the
97    /// high u16 the slot; see [`Header::slot`].
98    pub location: u32,
99    /// u32 at `0x10`: unset, a program category in the low u16, or a format-specific
100    /// two-word value. Preserved verbatim; see [`Header::category`].
101    pub aux: u32,
102    /// u32 at `0x14`: a format's schema version (`ne5p` holds 4) or a library's
103    /// content version (`nsmp` holds format×100 + revision).
104    pub version: u32,
105}
106
107impl Header {
108    /// A fresh type-1 header — the generation every current device writes — with
109    /// `aux` at the `0xFFFFFFFF` the slot-addressed formats hold.
110    pub fn new(format: &str, location: (u16, u16), version: u32) -> Header {
111        Header {
112            generation: Generation::V1,
113            tag: tag(format),
114            location: (location.0 as u32) | ((location.1 as u32) << 16),
115            aux: 0xFFFF_FFFF,
116            version,
117        }
118    }
119
120    /// The location as the (bank, slot) pair slot-addressed formats store there.
121    pub fn slot(&self) -> (u16, u16) {
122        (self.location as u16, (self.location >> 16) as u16)
123    }
124
125    /// The program category id most program formats keep in `aux`: the low u16
126    /// when the high u16 is zero, `None` for `0xFFFFFFFF` (no category — the
127    /// formats whose panel has no category picker) and for the both-halves
128    /// shape the preset/library tags hold. What an id names is per model; the
129    /// Stage 2/3 names are [`ProgramCategory`](crate::components::ProgramCategory).
130    pub fn category(&self) -> Option<u16> {
131        match (self.aux >> 16, self.aux as u16) {
132            (0, id) => Some(id),
133            _ => None,
134        }
135    }
136
137    pub fn set_slot(&mut self, (bank, slot): (u16, u16)) {
138        self.location = (bank as u32) | ((slot as u32) << 16);
139    }
140
141    /// The shared `0x18` header bytes, as they appear on disk.
142    fn head_bytes(&self) -> [u8; HEAD_LEN] {
143        let generation: u32 = match self.generation {
144            Generation::V0 => 0,
145            Generation::V1 => 1,
146        };
147        let mut out = [0u8; HEAD_LEN];
148        out[0..4].copy_from_slice(MAGIC);
149        out[4..8].copy_from_slice(&generation.to_le_bytes());
150        out[8..12].copy_from_slice(&self.tag);
151        out[12..16].copy_from_slice(&self.location.to_le_bytes());
152        out[16..20].copy_from_slice(&self.aux.to_le_bytes());
153        out[20..24].copy_from_slice(&self.version.to_le_bytes());
154        out
155    }
156}
157
158impl std::fmt::Debug for Header {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.debug_struct("Header")
161            .field("generation", &self.generation)
162            .field("tag", &tag_str(&self.tag))
163            .field("location", &format_args!("{:#010x}", self.location))
164            .field("aux", &format_args!("{:#010x}", self.aux))
165            .field("version", &self.version)
166            .finish()
167    }
168}
169
170/// The bytes after the header, as one format decodes them.
171pub trait Body: Sized {
172    /// Fixed body length, when the format has one; checked on read and write.
173    const LEN: Option<u64> = None;
174
175    /// Decode from `r`, which is scoped to the body: position 0 is the first body
176    /// byte and [`BodyReader::len`] is known. `header` is for version gating and
177    /// `location`/`aux` interpretation, not for layout — the container already
178    /// consumed the header bytes.
179    fn read<R: Read + Seek>(r: &mut BodyReader<'_, R>, header: &Header) -> Result<Self, Error>;
180
181    /// Encode to `w` in one forward pass.
182    fn write<W: Write + Seek>(&self, w: &mut BodyWriter<'_, W>) -> Result<(), Error>;
183}
184
185/// One decoded file: its header and its body.
186///
187/// Derefs to the body: the body *is* the entity, and the container is its file
188/// identity, so `file.center_panel` reads as the entity access it is.
189#[derive(Debug)]
190pub struct Cbin<B> {
191    pub header: Header,
192    pub body: B,
193}
194
195impl<B> std::ops::Deref for Cbin<B> {
196    type Target = B;
197
198    fn deref(&self) -> &B {
199        &self.body
200    }
201}
202
203impl<B> std::ops::DerefMut for Cbin<B> {
204    fn deref_mut(&mut self) -> &mut B {
205        &mut self.body
206    }
207}
208
209/// One checksum accumulator, whichever the generation uses.
210enum Hash {
211    V0(Crc16Stream<'static>),
212    V1(Crc32Stream<'static>),
213}
214
215impl Hash {
216    fn new(generation: Generation) -> Hash {
217        match generation {
218            Generation::V0 => Hash::V0(Crc16Stream::new()),
219            Generation::V1 => Hash::V1(Crc32Stream::new()),
220        }
221    }
222
223    fn update(&mut self, bytes: &[u8]) {
224        match self {
225            Hash::V0(h) => h.update(bytes),
226            Hash::V1(h) => h.update(bytes),
227        }
228    }
229}
230
231fn le_u32(bytes: &[u8], at: usize) -> u32 {
232    u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]])
233}
234
235fn tag_from_slice(bytes: &[u8]) -> Tag {
236    [bytes[0], bytes[1], bytes[2], bytes[3]]
237}
238
239/// Parse the header; for a type-1 file also consume the crc32 word and the pad,
240/// leaving the stream at the first body byte.
241pub(crate) fn read_header(r: &mut impl Read) -> Result<(Header, u32), Error> {
242    let mut head = [0u8; HEAD_LEN];
243    r.read_exact(&mut head)?;
244    if &head[0..4] != MAGIC {
245        return Err(ParseError::UnknownFileType(tag_str(&tag_from_slice(&head[0..4]))).into());
246    }
247    let le = |at: usize| le_u32(&head, at);
248    let generation = match le(4) {
249        0 => Generation::V0,
250        1 => Generation::V1,
251        other => {
252            return Err(ParseError::UnknownFormat(format!("CBIN header type {other}")).into());
253        }
254    };
255    let header = Header {
256        generation,
257        tag: tag_from_slice(&head[8..12]),
258        location: le(12),
259        aux: le(16),
260        version: le(20),
261    };
262
263    let mut stored_crc32 = 0;
264    if generation == Generation::V1 {
265        let mut rest = [0u8; 20];
266        r.read_exact(&mut rest)?;
267        stored_crc32 = le_u32(&rest, 0);
268        // Zero on every specimen. Inferred from specimens; not confirmed on
269        // hardware. A file that used these bytes would round-trip wrong silently,
270        // so refuse it loudly instead.
271        if rest[4..] != [0u8; 16] {
272            return Err(ParseError::AssertFail(
273                "nonzero bytes in the 0x1c..0x2c header pad".into(),
274            )
275            .into());
276        }
277    }
278    Ok((header, stored_crc32))
279}
280
281/// The end of the stream, with the position restored.
282fn stream_end(r: &mut impl Seek) -> io::Result<u64> {
283    let pos = r.stream_position()?;
284    let end = r.seek(SeekFrom::End(0))?;
285    r.seek(SeekFrom::Start(pos))?;
286    Ok(end)
287}
288
289/// Read one container from the whole rest of the stream, expecting `format`'s tag.
290pub fn read<B: Body>(r: &mut (impl Read + Seek), format: &'static str) -> Result<Cbin<B>, Error> {
291    read_inner(r, Some(format))
292}
293
294/// Read one container of any tag, its body kept verbatim.
295pub fn read_raw(r: &mut (impl Read + Seek)) -> Result<Cbin<RawBody>, Error> {
296    read_inner(r, None)
297}
298
299fn read_inner<B: Body>(
300    r: &mut (impl Read + Seek),
301    format: Option<&'static str>,
302) -> Result<Cbin<B>, Error> {
303    let start = r.stream_position()?;
304    let (header, stored_crc32) = read_header(r)?;
305    if let Some(expected) = format {
306        if header.tag != tag(expected) {
307            return Err(ParseError::WrongFormat {
308                expected,
309                got: tag_str(&header.tag),
310            }
311            .into());
312        }
313    }
314    // In errors below, name the format by its expected tag when one was asked
315    // for, and by the file's own tag on a raw read.
316    let format = format.map_or_else(|| tag_str(&header.tag), str::to_string);
317
318    let end = stream_end(r)?;
319    let overhead = header.generation.body_start() + header.generation.trailer_len();
320    if end < start + overhead {
321        return Err(ParseError::AssertFail(format!(
322            "{format}: {} bytes is shorter than the {overhead}-byte container",
323            end - start,
324        ))
325        .into());
326    }
327    let body_start = start + header.generation.body_start();
328    let body_len = end - body_start - header.generation.trailer_len();
329    if let Some(expected) = B::LEN {
330        if body_len != expected {
331            return Err(ParseError::WrongBodyLength {
332                format,
333                got: body_len,
334                expected,
335            }
336            .into());
337        }
338    }
339
340    let mut hash = Hash::new(header.generation);
341    if header.generation == Generation::V0 {
342        // The crc16 covers the header too. Re-encoding is exact: every one of the
343        // 0x18 bytes is either verified (magic) or held verbatim in `header`.
344        hash.update(&header.head_bytes());
345    }
346    let mut reader = BodyReader {
347        inner: r,
348        start: body_start,
349        len: body_len,
350        pos: 0,
351        hashed: 0,
352        hash,
353    };
354    let body = B::read(&mut reader, &header)?;
355    reader.verify(stored_crc32, &format)?;
356    Ok(Cbin { header, body })
357}
358
359impl<B: Body> Cbin<B> {
360    pub fn write_to(&self, w: &mut (impl Write + Seek)) -> Result<(), Error> {
361        let start = w.stream_position()?;
362        let head = self.header.head_bytes();
363        let mut hash = Hash::new(self.header.generation);
364        w.write_all(&head)?;
365        match self.header.generation {
366            // The crc32 is not known yet; a placeholder holds its word until the
367            // body has streamed past, then one seek patches it.
368            Generation::V1 => w.write_all(&[0u8; 20])?,
369            Generation::V0 => hash.update(&head),
370        }
371
372        let body_start = start + self.header.generation.body_start();
373        let mut writer = BodyWriter {
374            inner: w,
375            pos: 0,
376            hash,
377        };
378        self.body.write(&mut writer)?;
379        let BodyWriter {
380            pos: written, hash, ..
381        } = writer;
382
383        if let Some(expected) = B::LEN {
384            if written != expected {
385                return Err(ParseError::WrongBodyLength {
386                    format: tag_str(&self.header.tag),
387                    got: written,
388                    expected,
389                }
390                .into());
391            }
392        }
393
394        match hash {
395            Hash::V1(h) => {
396                w.seek(SeekFrom::Start(start + 0x18))?;
397                w.write_all(&h.value().to_le_bytes())?;
398                w.seek(SeekFrom::Start(body_start + written))?;
399            }
400            Hash::V0(h) => w.write_all(&h.value().to_le_bytes())?,
401        }
402        Ok(())
403    }
404}
405
406/// A body kept verbatim: bytes in, bytes out, checksum verified, nothing decoded.
407///
408/// For formats whose body is not yet mapped, for one parsed as a borrowed view over
409/// these bytes rather than a bit-mapped struct (`npno`), and for wire code that moves
410/// bodies whole. ⚠️ Allocates the body — a library-sized file wants [`inspect`],
411/// which holds O(1), not a `RawBody`.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct RawBody(pub Vec<u8>);
414
415impl Body for RawBody {
416    fn read<R: Read + Seek>(r: &mut BodyReader<'_, R>, _: &Header) -> Result<RawBody, Error> {
417        let len = usize::try_from(r.len()).map_err(|_| ParseError::OutOfBounds {
418            value: format!("{} body bytes", r.len()),
419            bound: "a length that fits this platform's usize".into(),
420        })?;
421        let mut bytes = try_vec(len)?;
422        r.read_exact(&mut bytes)?;
423        Ok(RawBody(bytes))
424    }
425
426    fn write<W: Write + Seek>(&self, w: &mut BodyWriter<'_, W>) -> Result<(), Error> {
427        w.write_all(&self.0)?;
428        Ok(())
429    }
430}
431
432/// Container-level facts about one file: header, length, checksum verdict.
433#[derive(Debug, Clone)]
434pub struct Info {
435    pub header: Header,
436    pub body_len: u64,
437    /// Whether the stored checksum matches the bytes. A mismatch is a fact to
438    /// report, not an error: reporting bad files is this function's job.
439    pub checksum_ok: bool,
440}
441
442/// One streaming pass over any CBIN file, no body knowledge needed. O(1) memory,
443/// so it serves the formats too large or too unmapped to decode.
444pub fn inspect(r: &mut (impl Read + Seek)) -> Result<Info, Error> {
445    let start = r.stream_position()?;
446    let (header, stored_crc32) = read_header(r)?;
447    let end = stream_end(r)?;
448    let overhead = header.generation.body_start() + header.generation.trailer_len();
449    if end < start + overhead {
450        return Err(ParseError::AssertFail(format!(
451            "{}: {} bytes is shorter than the {overhead}-byte container",
452            tag_str(&header.tag),
453            end - start,
454        ))
455        .into());
456    }
457    let body_len = end - start - overhead;
458
459    let mut hash = Hash::new(header.generation);
460    if header.generation == Generation::V0 {
461        hash.update(&header.head_bytes());
462    }
463    let mut remaining = body_len;
464    let mut scratch = [0u8; 8192];
465    while remaining > 0 {
466        let take = remaining.min(scratch.len() as u64) as usize;
467        r.read_exact(&mut scratch[..take])?;
468        hash.update(&scratch[..take]);
469        remaining -= take as u64;
470    }
471    let checksum_ok = match hash {
472        Hash::V1(h) => h.value() == stored_crc32,
473        Hash::V0(h) => {
474            let mut trailer = [0u8; 2];
475            r.read_exact(&mut trailer)?;
476            h.value() == u16::from_le_bytes(trailer)
477        }
478    };
479    Ok(Info {
480        header,
481        body_len,
482        checksum_ok,
483    })
484}
485
486/// A read view scoped to the body: position 0 is the first body byte, [`len`] is
487/// the body length (the type-0 trailer already excluded), and every byte is
488/// checksummed on its way past.
489///
490/// Seeking forward reads through the gap in bounded chunks so skipped bytes still
491/// reach the checksum; seeking backward and re-reading hashes nothing twice.
492///
493/// [`len`]: BodyReader::len
494pub struct BodyReader<'a, R: Read + Seek> {
495    inner: &'a mut R,
496    /// Absolute offset of body byte 0.
497    start: u64,
498    len: u64,
499    /// Body-relative position.
500    pos: u64,
501    /// High-water mark of hashed bytes. Never exceeded by `pos` outside `seek`,
502    /// so the hash covers `[0, hashed)` exactly once.
503    hashed: u64,
504    hash: Hash,
505}
506
507impl<R: Read + Seek> BodyReader<'_, R> {
508    /// Body length in bytes.
509    #[allow(clippy::len_without_is_empty)]
510    pub fn len(&self) -> u64 {
511        self.len
512    }
513
514    /// Body bytes between the current position and the end.
515    pub fn remaining(&self) -> u64 {
516        self.len - self.pos
517    }
518
519    /// Drain to the end, then check the checksum against the stored one.
520    fn verify(mut self, stored_crc32: u32, format: &str) -> Result<(), Error> {
521        self.seek(SeekFrom::Start(self.len))?;
522        match self.hash {
523            Hash::V1(h) => {
524                let computed = h.value();
525                if computed != stored_crc32 {
526                    return Err(ParseError::AssertFail(format!(
527                        "{format}: stored checksum {stored_crc32:#010x} does not match the \
528                         body's {computed:#010x}"
529                    ))
530                    .into());
531                }
532            }
533            Hash::V0(h) => {
534                let mut trailer = [0u8; 2];
535                self.inner.read_exact(&mut trailer)?;
536                let stored = u16::from_le_bytes(trailer);
537                let computed = h.value();
538                if computed != stored {
539                    return Err(ParseError::AssertFail(format!(
540                        "{format}: stored checksum {stored:#06x} does not match the \
541                         file's {computed:#06x}"
542                    ))
543                    .into());
544                }
545            }
546        }
547        Ok(())
548    }
549}
550
551impl<R: Read + Seek> Read for BodyReader<'_, R> {
552    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
553        let remaining = self.len - self.pos;
554        if remaining == 0 || buf.is_empty() {
555            return Ok(0);
556        }
557        let want = (buf.len() as u64).min(remaining) as usize;
558        let n = self.inner.read(&mut buf[..want])?;
559        let end = self.pos + n as u64;
560        if end > self.hashed {
561            // Only the tail past the high-water mark: a re-read after a backward
562            // seek must not reach the accumulator twice.
563            let from = (self.hashed - self.pos) as usize;
564            self.hash.update(&buf[from..n]);
565            self.hashed = end;
566        }
567        self.pos = end;
568        Ok(n)
569    }
570}
571
572impl<R: Read + Seek> Seek for BodyReader<'_, R> {
573    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
574        let target = match pos {
575            SeekFrom::Start(p) => p as i128,
576            SeekFrom::Current(d) => self.pos as i128 + d as i128,
577            SeekFrom::End(d) => self.len as i128 + d as i128,
578        };
579        if target < 0 || target > self.len as i128 {
580            return Err(io::Error::new(
581                io::ErrorKind::InvalidInput,
582                format!("seek to {target} outside the {}-byte body", self.len),
583            ));
584        }
585        let target = target as u64;
586
587        if target <= self.hashed {
588            self.inner.seek(SeekFrom::Start(self.start + target))?;
589        } else {
590            // Read through the gap so the skipped bytes still reach the checksum.
591            self.inner.seek(SeekFrom::Start(self.start + self.hashed))?;
592            let mut scratch = [0u8; 8192];
593            while self.hashed < target {
594                let take = (target - self.hashed).min(scratch.len() as u64) as usize;
595                self.inner.read_exact(&mut scratch[..take])?;
596                self.hash.update(&scratch[..take]);
597                self.hashed += take as u64;
598            }
599        }
600        self.pos = target;
601        Ok(target)
602    }
603}
604
605/// The write half: hashes bytes as they stream out, in one forward pass.
606///
607/// ⚠️ This must never implement `Seek`. The checksum is accumulated as bytes go
608/// past, so it is only correct if every body byte is written exactly once, in
609/// order; a rewind would hash a byte twice and stamp a checksum matching no file.
610pub struct BodyWriter<'a, W: Write + Seek> {
611    inner: &'a mut W,
612    /// Body-relative position.
613    pos: u64,
614    hash: Hash,
615}
616
617impl<W: Write + Seek> Write for BodyWriter<'_, W> {
618    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
619        let n = self.inner.write(buf)?;
620        // Only the bytes accepted — hashing past `n` would checksum bytes the
621        // caller will retry, counting them twice.
622        self.hash.update(&buf[..n]);
623        self.pos += n as u64;
624        Ok(n)
625    }
626
627    fn flush(&mut self) -> io::Result<()> {
628        self.inner.flush()
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::crc::{crc16, crc32};
636    use std::io::Cursor;
637
638    /// The three `aux` shapes: category under a zero high u16, unset, and the
639    /// both-halves preset/library shape — only the first yields a category.
640    #[test]
641    fn category_reads_only_the_program_shape() {
642        let mut h = Header::new("ne5p", (0, 0), 4);
643        assert_eq!(h.category(), None, "0xFFFFFFFF is no category");
644        h.aux = 0x0000_0017;
645        assert_eq!(h.category(), Some(0x17));
646        h.aux = 0x0000_0000;
647        assert_eq!(h.category(), Some(0), "zero is a value, not unset");
648        h.aux = 0x000a_0004; // the `ns3y` both-halves shape
649        assert_eq!(
650            h.category(),
651            None,
652            "a set high u16 is not the category shape"
653        );
654    }
655
656    /// A 5-byte body under a fixed length, to exercise `B::LEN`.
657    #[derive(Debug)]
658    struct Five([u8; 5]);
659
660    impl Body for Five {
661        const LEN: Option<u64> = Some(5);
662
663        fn read<R: Read + Seek>(r: &mut BodyReader<'_, R>, _: &Header) -> Result<Five, Error> {
664            let mut b = [0u8; 5];
665            r.read_exact(&mut b)?;
666            Ok(Five(b))
667        }
668
669        fn write<W: Write + Seek>(&self, w: &mut BodyWriter<'_, W>) -> Result<(), Error> {
670            w.write_all(&self.0)?;
671            Ok(())
672        }
673    }
674
675    fn v1_file(body: &[u8]) -> Vec<u8> {
676        let mut out = Vec::new();
677        out.extend_from_slice(b"CBIN");
678        out.extend_from_slice(&1u32.to_le_bytes());
679        out.extend_from_slice(b"test");
680        out.extend_from_slice(&0x0007_0003u32.to_le_bytes());
681        out.extend_from_slice(&u32::MAX.to_le_bytes());
682        out.extend_from_slice(&4u32.to_le_bytes());
683        out.extend_from_slice(&crc32(body).to_le_bytes());
684        out.extend_from_slice(&[0u8; 16]);
685        out.extend_from_slice(body);
686        out
687    }
688
689    fn v0_file(body: &[u8]) -> Vec<u8> {
690        let mut out = Vec::new();
691        out.extend_from_slice(b"CBIN");
692        out.extend_from_slice(&0u32.to_le_bytes());
693        out.extend_from_slice(b"test");
694        out.extend_from_slice(&0x0007_0003u32.to_le_bytes());
695        out.extend_from_slice(&u32::MAX.to_le_bytes());
696        out.extend_from_slice(&4u32.to_le_bytes());
697        out.extend_from_slice(body);
698        let crc = crc16(&out);
699        out.extend_from_slice(&crc.to_le_bytes());
700        out
701    }
702
703    /// Both generations round-trip and differ only by their 18-byte checksum layout.
704    #[test]
705    fn both_generations_round_trip_and_differ_by_18_bytes() {
706        let body = [0xaa, 0xbb, 0xcc, 0xdd, 0xee];
707        for bytes in [v1_file(&body), v0_file(&body)] {
708            let file: Cbin<Five> = read(&mut Cursor::new(&bytes), "test").unwrap();
709            assert_eq!(file.body.0, body);
710            assert_eq!(file.header.slot(), (3, 7));
711
712            let mut out = Cursor::new(Vec::new());
713            file.write_to(&mut out).unwrap();
714            assert_eq!(out.into_inner(), bytes, "round trip changed the bytes");
715        }
716        assert_eq!(v1_file(&body).len() - v0_file(&body).len(), 18);
717    }
718
719    #[test]
720    fn a_corrupted_byte_fails_either_checksum() {
721        let body = [1, 2, 3, 4, 5];
722        for mut bytes in [v1_file(&body), v0_file(&body)] {
723            let at = bytes.len() - 3;
724            bytes[at] ^= 0xff;
725            assert!(
726                read::<Five>(&mut Cursor::new(&bytes), "test").is_err(),
727                "a corrupted body must not verify"
728            );
729        }
730    }
731
732    /// The type-0 crc16 covers the header: corrupting a header byte must fail even
733    /// though the body is intact.
734    #[test]
735    fn the_v0_checksum_covers_the_header() {
736        let mut bytes = v0_file(&[1, 2, 3, 4, 5]);
737        bytes[0x0c] ^= 0xff;
738        assert!(read::<Five>(&mut Cursor::new(&bytes), "test").is_err());
739
740        // Type-1 checksums only the body, so location edits remain valid.
741        let mut bytes = v1_file(&[1, 2, 3, 4, 5]);
742        bytes[0x0c] ^= 0xff;
743        assert!(read::<Five>(&mut Cursor::new(&bytes), "test").is_ok());
744    }
745
746    #[test]
747    fn the_wrong_tag_is_refused_by_name() {
748        let bytes = v1_file(&[1, 2, 3, 4, 5]);
749        let err = read::<Five>(&mut Cursor::new(&bytes), "ne5p").unwrap_err();
750        assert!(
751            matches!(
752                err,
753                Error::Parse(ParseError::WrongFormat {
754                    expected: "ne5p",
755                    ..
756                })
757            ),
758            "refused for the wrong reason: {err}",
759        );
760    }
761
762    #[test]
763    fn the_wrong_length_is_refused_before_the_body_decodes() {
764        let bytes = v1_file(&[1, 2, 3]);
765        let err = read::<Five>(&mut Cursor::new(&bytes), "test").unwrap_err();
766        assert!(
767            matches!(
768                err,
769                Error::Parse(ParseError::WrongBodyLength {
770                    got: 3,
771                    expected: 5,
772                    ..
773                })
774            ),
775            "refused for the wrong reason: {err}",
776        );
777    }
778
779    /// An unread body tail still reaches the checksum: the container drains what
780    /// the body left behind, so verification never silently narrows.
781    #[test]
782    fn an_unread_tail_is_still_verified() {
783        struct TwoOfFive;
784        impl Body for TwoOfFive {
785            fn read<R: Read + Seek>(
786                r: &mut BodyReader<'_, R>,
787                _: &Header,
788            ) -> Result<TwoOfFive, Error> {
789                let mut b = [0u8; 2];
790                r.read_exact(&mut b)?;
791                Ok(TwoOfFive)
792            }
793            fn write<W: Write + Seek>(&self, _: &mut BodyWriter<'_, W>) -> Result<(), Error> {
794                Ok(())
795            }
796        }
797
798        let mut bytes = v1_file(&[1, 2, 3, 4, 5]);
799        assert!(read::<TwoOfFive>(&mut Cursor::new(&bytes), "test").is_ok());
800        *bytes.last_mut().unwrap() ^= 0xff;
801        assert!(
802            read::<TwoOfFive>(&mut Cursor::new(&bytes), "test").is_err(),
803            "a corrupt byte the body never read must still fail verification",
804        );
805    }
806
807    /// Forward seeks hash the skipped bytes; backward seeks and re-reads hash
808    /// nothing twice.
809    #[test]
810    fn seeking_bodies_keep_the_checksum_exact() {
811        struct Skipper;
812        impl Body for Skipper {
813            fn read<R: Read + Seek>(
814                r: &mut BodyReader<'_, R>,
815                _: &Header,
816            ) -> Result<Skipper, Error> {
817                r.seek(SeekFrom::Start(4))?; // skip forward over unread bytes
818                let mut b = [0u8; 1];
819                r.read_exact(&mut b)?;
820                r.seek(SeekFrom::Start(0))?; // back to the start
821                r.read_exact(&mut b)?; // re-read an already-hashed byte
822                Ok(Skipper)
823            }
824            fn write<W: Write + Seek>(&self, _: &mut BodyWriter<'_, W>) -> Result<(), Error> {
825                Ok(())
826            }
827        }
828
829        let bytes = v1_file(&[9, 8, 7, 6, 5]);
830        assert!(read::<Skipper>(&mut Cursor::new(&bytes), "test").is_ok());
831    }
832
833    #[test]
834    fn inspect_reports_both_generations_without_a_body() {
835        for (bytes, generation) in [
836            (v1_file(&[1, 2, 3]), Generation::V1),
837            (v0_file(&[1, 2, 3]), Generation::V0),
838        ] {
839            let info = inspect(&mut Cursor::new(&bytes)).unwrap();
840            assert_eq!(info.header.generation, generation);
841            assert_eq!(info.body_len, 3);
842            assert!(info.checksum_ok);
843
844            let mut corrupt = bytes.clone();
845            let at = corrupt.len() - 3;
846            corrupt[at] ^= 0xff;
847            let info = inspect(&mut Cursor::new(&corrupt)).unwrap();
848            assert!(!info.checksum_ok, "inspect reports, it does not refuse");
849        }
850    }
851
852    /// The header names its own layout, so a type this build has never laid out is
853    /// refused rather than read with one of the two it knows.
854    #[test]
855    fn a_header_type_that_is_neither_generation_is_refused() {
856        let mut bytes = v1_file(&[1, 2, 3, 4, 5]);
857        bytes[4..8].copy_from_slice(&2u32.to_le_bytes());
858        let err = read::<Five>(&mut Cursor::new(&bytes), "test").unwrap_err();
859        assert!(
860            matches!(err, Error::Parse(ParseError::UnknownFormat(ref what)) if what.contains("type 2")),
861            "refused for the wrong reason: {err}",
862        );
863    }
864
865    /// The sixteen bytes after the type-1 checksum are zero in every specimen. A file
866    /// using them would round-trip wrong silently, so it is refused loudly.
867    #[test]
868    fn a_nonzero_header_pad_is_refused() {
869        for at in 0x1c..0x2c {
870            let mut bytes = v1_file(&[1, 2, 3, 4, 5]);
871            bytes[at] = 0xff;
872            let err = read::<Five>(&mut Cursor::new(&bytes), "test")
873                .unwrap_err()
874                .to_string();
875            assert!(err.contains("header pad"), "byte {at:#x}: {err}");
876        }
877    }
878
879    /// A file shorter than the header and checksum its own generation declares has no
880    /// body to speak of, and the length arithmetic must say so rather than wrap.
881    #[test]
882    fn a_file_shorter_than_its_container_is_refused() {
883        // A type-0 container is the 0x18 header plus its 2-byte trailer.
884        let short = &v0_file(&[1, 2, 3, 4, 5])[..HEAD_LEN + 1];
885        let err = read::<Five>(&mut Cursor::new(short), "test").unwrap_err();
886        assert!(
887            matches!(&err, Error::Parse(ParseError::AssertFail(why))
888                if why.contains("shorter than the 26-byte container")),
889            "refused for the wrong reason: {err}",
890        );
891        assert!(inspect(&mut Cursor::new(short)).is_err());
892
893        // A type-1 header is longer than this whole file, so it cannot even be read.
894        let truncated = &v1_file(&[1, 2, 3, 4, 5])[..0x2b];
895        assert!(read::<Five>(&mut Cursor::new(truncated), "test").is_err());
896    }
897
898    #[test]
899    fn raw_bodies_round_trip_any_tag() {
900        let bytes = v0_file(&[1, 2, 3, 4, 5, 6, 7]);
901        let file = read_raw(&mut Cursor::new(&bytes)).unwrap();
902        assert_eq!(file.body.0, [1, 2, 3, 4, 5, 6, 7]);
903
904        let mut out = Cursor::new(Vec::new());
905        file.write_to(&mut out).unwrap();
906        assert_eq!(out.into_inner(), bytes);
907    }
908}