Skip to main content

rustynes_core/
save_state.rs

1//! Save-state container format for `RustyNES` v2.
2//!
3//! Per `CLAUDE.md` "Open questions worth knowing": tagged-section per chip
4//! with version byte; cross-version compatibility is best-effort, not
5//! guaranteed.
6//!
7//! # On-wire layout
8//!
9//! ```text
10//! HEADER (16 bytes):
11//!     magic       : "RUSTYNES"  (8 bytes)
12//!     format ver  : u16 little-endian  (currently 3 -- see [`FORMAT_VERSION`])
13//!     rom sha-256 : truncated to 6 bytes (sanity tag, not authoritative)
14//!
15//! BODY (sections in any order, each):
16//!     tag         : [u8; 4]  e.g. b"CPU ", b"PPU ", b"APU ", b"MAP "
17//!     version     : u8       per-section schema version
18//!     length      : u32 little-endian (body bytes after this length field)
19//!     body        : `length` bytes
20//! ```
21//!
22//! Determinism: every `snapshot()` for a given `(seed, ROM, input sequence)`
23//! produces bit-identical bytes. Loading is order-independent.
24
25use alloc::{format, string::String, vec::Vec};
26use thiserror::Error;
27
28/// Magic header bytes — first 8 bytes of every `.rns` file.
29pub const MAGIC: &[u8; 8] = b"RUSTYNES";
30
31/// Current container-format version.
32///
33/// - v1 (v0.9.0 ..): the tagged-section container as documented above.
34/// - **v2 (v2.0.0 "Timebase" rc.1, ADR 0028)**: marks the MAJOR-boundary
35///   line. The container's own on-wire layout is unchanged (this field
36///   only guards forward-compat -- a reader rejects any blob whose
37///   `format_version` exceeds its own [`FORMAT_VERSION`]); the real
38///   v2.0.0-line rejection happens per-section, via the strict version
39///   equality checks each chip's snapshot module already enforces (see
40///   `rustynes_cpu::CPU_SNAPSHOT_VERSION`'s v3 bump for the concrete
41///   example). This bump exists so a `.rns` file's header alone signals
42///   which release line produced it, without needing to inspect every
43///   section.
44/// - **v3 (v2.9.8, ADR 0042)**: the second epoch. The layout is unchanged
45///   again; what changes is that a reader now also refuses any blob OLDER
46///   than [`MIN_FORMAT_VERSION`], with [`SnapshotError::FormatTooOld`]. v2.9.8
47///   removed every legacy-format reader in the section decoders (BUS section
48///   2, and the PPU, APU and mapper upconversions), and several sections kept
49///   their version numbers because their layouts did not move, so without a
50///   container check a v2.9.7 file would be refused only at whichever section
51///   happened to come first. One header check gives one clear message.
52pub const FORMAT_VERSION: u16 = 3;
53
54/// Oldest container-format version this build reads.
55///
56/// Since v2.9.8 that is the current one only (ADR 0042, following ADR 0028's
57/// v2.0.0 epoch). A `.rns` file from v2.9.7 or earlier carries 2 (or 1) and
58/// is refused at the header.
59pub const MIN_FORMAT_VERSION: u16 = 3;
60
61/// Length of the truncated ROM SHA-256 we embed in the header (sanity tag).
62pub const ROM_HASH_TAG_LEN: usize = 6;
63
64/// Header byte length.
65pub const HEADER_LEN: usize = 8 + 2 + ROM_HASH_TAG_LEN;
66
67/// Section tags. The fixed-width 4-byte format keeps parsing trivial and
68/// avoids string allocation on the hot path.
69pub mod tag {
70    /// CPU (2A03 / 6502) state.
71    pub const CPU: [u8; 4] = *b"CPU ";
72    /// PPU (2C02) state.
73    pub const PPU: [u8; 4] = *b"PPU ";
74    /// APU (2A03 audio) state.
75    pub const APU: [u8; 4] = *b"APU ";
76    /// Mapper state (delegates to `Mapper::save_state`).
77    pub const MAP: [u8; 4] = *b"MAP ";
78    /// Bus / scheduler state (RAM, DMA, NMI edge latches, cycle counter).
79    pub const BUS: [u8; 4] = *b"BUS ";
80    /// Optional UI thumbnail (128x120 RGBA8 nearest-neighbor of the current
81    /// framebuffer). NOT part of the deterministic save-state contract --
82    /// frontends use it for slot pickers. See ADR 0003.
83    pub const THM: [u8; 4] = *b"THM ";
84}
85
86/// Thumbnail width in pixels (1/2 native NES width).
87pub const THUMBNAIL_WIDTH: usize = 128;
88/// Thumbnail height in pixels (1/2 native NES height).
89pub const THUMBNAIL_HEIGHT: usize = 120;
90/// Thumbnail byte length (`THUMBNAIL_WIDTH * THUMBNAIL_HEIGHT * 4`, RGBA8).
91pub const THUMBNAIL_LEN: usize = THUMBNAIL_WIDTH * THUMBNAIL_HEIGHT * 4;
92/// Body version byte for the `THM ` section.
93pub const THUMBNAIL_VERSION: u8 = 1;
94
95/// v3.0.0 — which way a section version differs, in words a player can act
96/// on ([`SnapshotError::VersionMismatch`]).
97const fn section_version_hint(file_version: u8, this_build: u8) -> &'static str {
98    if file_version < this_build {
99        "it was saved by an older release of RustyNES, whose states this version \
100         cannot load; re-create it from the game or an in-game save"
101    } else {
102        "it was saved by a newer release of RustyNES; load it with that version"
103    }
104}
105
106/// Errors produced by save-state encode / decode.
107#[derive(Debug, Error)]
108#[non_exhaustive]
109pub enum SnapshotError {
110    /// The blob is shorter than the header.
111    #[error("save state truncated: header needs {expected} bytes, got {got}")]
112    HeaderTruncated {
113        /// Expected byte count.
114        expected: usize,
115        /// Actual byte count.
116        got: usize,
117    },
118
119    /// The magic prefix is wrong.
120    #[error("save state magic mismatch: expected {:?}, got {got:?}", MAGIC)]
121    BadMagic {
122        /// Bytes observed at the magic offset.
123        got: [u8; 8],
124    },
125
126    /// The container format version is outside the range we understand.
127    #[error("save state container format version {got} not supported (max {max})")]
128    UnsupportedFormat {
129        /// Version we read.
130        got: u16,
131        /// Highest version we accept.
132        max: u16,
133    },
134
135    /// The container is from an older release whose save states this build no
136    /// longer reads (v2.9.8, ADR 0042: v2.9.7 and earlier).
137    #[error(
138        "save state container format version {got} is from an older release; this build reads version {min} and later"
139    )]
140    FormatTooOld {
141        /// Version we read.
142        got: u16,
143        /// Oldest version we accept ([`MIN_FORMAT_VERSION`]).
144        min: u16,
145    },
146
147    /// A section body is shorter than its declared length.
148    #[error("save state section {tag} body truncated: declared {declared} bytes, got {got}")]
149    SectionTruncated {
150        /// 4-byte tag (printable ASCII).
151        tag: String,
152        /// Declared length.
153        declared: usize,
154        /// Bytes actually available.
155        got: usize,
156    },
157
158    /// A section had a version this build does not handle.
159    ///
160    /// Every section reader accepts only its current layout (v2.9.8, ADR
161    /// 0042), so this is what a state saved by another release of `RustyNES`
162    /// fails with, when its container format is still current: v2.9.8's and
163    /// v2.9.9's states, at v3.0.0. The message therefore says which way the
164    /// versions differ (v3.0.0; until then it gave only the two numbers, and
165    /// players read it as a damaged file).
166    #[error(
167        "save state section {tag} version {file_version} not supported (this build reads \
168         {chip_supports}): {}",
169        section_version_hint(*file_version, *chip_supports)
170    )]
171    VersionMismatch {
172        /// 4-byte tag (printable ASCII).
173        tag: String,
174        /// Version recorded in the file.
175        file_version: u8,
176        /// Highest version the running chip accepts.
177        chip_supports: u8,
178    },
179
180    /// A section's body failed internal consistency checks.
181    #[error("save state section {tag}: {reason}")]
182    SectionInvalid {
183        /// 4-byte tag (printable ASCII).
184        tag: String,
185        /// Free-form reason.
186        reason: String,
187    },
188
189    /// A required section was missing.
190    #[error("save state missing required section {0}")]
191    MissingSection(String),
192
193    /// A section blob ran past EOF.
194    #[error("save state truncated mid-section at offset {0}")]
195    Eof(usize),
196}
197
198impl SnapshotError {
199    /// Construct a [`Self::SectionInvalid`] with a borrowed tag.
200    pub fn invalid(tag: [u8; 4], reason: impl Into<String>) -> Self {
201        Self::SectionInvalid {
202            tag: tag_string(tag),
203            reason: reason.into(),
204        }
205    }
206}
207
208/// Render a 4-byte tag back into a `String` (lossy — non-ASCII becomes `?`).
209#[must_use]
210pub fn tag_string(t: [u8; 4]) -> String {
211    let mut s = String::with_capacity(4);
212    for b in t {
213        s.push(if (0x20..=0x7E).contains(&b) {
214            char::from(b)
215        } else {
216            '?'
217        });
218    }
219    s
220}
221
222/// Cursor-style binary writer used by chip snapshot encoders.
223///
224/// Little-endian for all multi-byte integers; bools are 1 byte (`0` / `1`);
225/// optional values are tagged with a presence byte.
226#[derive(Debug, Default)]
227pub struct BinWriter {
228    buf: Vec<u8>,
229}
230
231impl BinWriter {
232    /// Empty writer.
233    #[must_use]
234    pub const fn new() -> Self {
235        Self { buf: Vec::new() }
236    }
237
238    /// Pre-sized writer.
239    #[must_use]
240    pub fn with_capacity(cap: usize) -> Self {
241        Self {
242            buf: Vec::with_capacity(cap),
243        }
244    }
245
246    /// Take the inner buffer.
247    #[must_use]
248    pub fn into_vec(self) -> Vec<u8> {
249        self.buf
250    }
251
252    /// Currently-accumulated byte count.
253    #[must_use]
254    pub const fn len(&self) -> usize {
255        self.buf.len()
256    }
257
258    /// `true` if no bytes have been written.
259    #[must_use]
260    pub const fn is_empty(&self) -> bool {
261        self.buf.is_empty()
262    }
263
264    /// Append one byte.
265    pub fn u8(&mut self, v: u8) {
266        self.buf.push(v);
267    }
268
269    /// Append a u16 little-endian.
270    pub fn u16(&mut self, v: u16) {
271        self.buf.extend_from_slice(&v.to_le_bytes());
272    }
273
274    /// Append a u32 little-endian.
275    pub fn u32(&mut self, v: u32) {
276        self.buf.extend_from_slice(&v.to_le_bytes());
277    }
278
279    /// Append a u64 little-endian.
280    pub fn u64(&mut self, v: u64) {
281        self.buf.extend_from_slice(&v.to_le_bytes());
282    }
283
284    /// Append an i16 little-endian.
285    pub fn i16(&mut self, v: i16) {
286        self.buf.extend_from_slice(&v.to_le_bytes());
287    }
288
289    /// Append a bool as 1 byte.
290    pub fn bool(&mut self, v: bool) {
291        self.buf.push(u8::from(v));
292    }
293
294    /// Append a raw byte slice.
295    pub fn bytes(&mut self, v: &[u8]) {
296        self.buf.extend_from_slice(v);
297    }
298
299    /// Append a length-prefixed byte slice (u32 le length).
300    pub fn lp_bytes(&mut self, v: &[u8]) {
301        self.u32(u32::try_from(v.len()).expect("slice too large for save state"));
302        self.bytes(v);
303    }
304}
305
306/// Cursor-style binary reader, the inverse of [`BinWriter`].
307#[derive(Debug)]
308pub struct BinReader<'a> {
309    src: &'a [u8],
310    pos: usize,
311}
312
313impl<'a> BinReader<'a> {
314    /// New reader.
315    #[must_use]
316    pub const fn new(src: &'a [u8]) -> Self {
317        Self { src, pos: 0 }
318    }
319
320    /// Bytes remaining.
321    #[must_use]
322    pub const fn remaining(&self) -> usize {
323        self.src.len() - self.pos
324    }
325
326    /// `true` if the cursor is at end-of-input.
327    #[must_use]
328    pub const fn is_empty(&self) -> bool {
329        self.remaining() == 0
330    }
331
332    /// Current byte offset.
333    #[must_use]
334    pub const fn pos(&self) -> usize {
335        self.pos
336    }
337
338    const fn need(&self, n: usize) -> Result<(), SnapshotError> {
339        if self.remaining() < n {
340            return Err(SnapshotError::Eof(self.pos));
341        }
342        Ok(())
343    }
344
345    /// Read one byte.
346    ///
347    /// # Errors
348    ///
349    /// Returns [`SnapshotError::Eof`] on EOF.
350    pub fn u8(&mut self) -> Result<u8, SnapshotError> {
351        self.need(1)?;
352        let v = self.src[self.pos];
353        self.pos += 1;
354        Ok(v)
355    }
356
357    /// Read a u16 little-endian.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`SnapshotError::Eof`] on EOF.
362    pub fn u16(&mut self) -> Result<u16, SnapshotError> {
363        self.need(2)?;
364        let v = u16::from_le_bytes([self.src[self.pos], self.src[self.pos + 1]]);
365        self.pos += 2;
366        Ok(v)
367    }
368
369    /// Read a u32 little-endian.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`SnapshotError::Eof`] on EOF.
374    pub fn u32(&mut self) -> Result<u32, SnapshotError> {
375        self.need(4)?;
376        let mut a = [0u8; 4];
377        a.copy_from_slice(&self.src[self.pos..self.pos + 4]);
378        self.pos += 4;
379        Ok(u32::from_le_bytes(a))
380    }
381
382    /// Read a u64 little-endian.
383    ///
384    /// # Errors
385    ///
386    /// Returns [`SnapshotError::Eof`] on EOF.
387    pub fn u64(&mut self) -> Result<u64, SnapshotError> {
388        self.need(8)?;
389        let mut a = [0u8; 8];
390        a.copy_from_slice(&self.src[self.pos..self.pos + 8]);
391        self.pos += 8;
392        Ok(u64::from_le_bytes(a))
393    }
394
395    /// Read an i16 little-endian.
396    ///
397    /// # Errors
398    ///
399    /// Returns [`SnapshotError::Eof`] on EOF.
400    pub fn i16(&mut self) -> Result<i16, SnapshotError> {
401        self.need(2)?;
402        let v = i16::from_le_bytes([self.src[self.pos], self.src[self.pos + 1]]);
403        self.pos += 2;
404        Ok(v)
405    }
406
407    /// Read a bool (any non-zero byte counts as true).
408    ///
409    /// # Errors
410    ///
411    /// Returns [`SnapshotError::Eof`] on EOF.
412    pub fn bool(&mut self) -> Result<bool, SnapshotError> {
413        Ok(self.u8()? != 0)
414    }
415
416    /// Read `n` bytes (returns a borrowed slice).
417    ///
418    /// # Errors
419    ///
420    /// Returns [`SnapshotError::Eof`] on EOF.
421    pub fn take(&mut self, n: usize) -> Result<&'a [u8], SnapshotError> {
422        self.need(n)?;
423        let s = &self.src[self.pos..self.pos + n];
424        self.pos += n;
425        Ok(s)
426    }
427
428    /// Read into a fixed-length destination.
429    ///
430    /// # Errors
431    ///
432    /// Returns [`SnapshotError::Eof`] on EOF.
433    pub fn read_into(&mut self, dst: &mut [u8]) -> Result<(), SnapshotError> {
434        let s = self.take(dst.len())?;
435        dst.copy_from_slice(s);
436        Ok(())
437    }
438
439    /// Read a length-prefixed byte slice (u32 le length followed by the bytes).
440    ///
441    /// # Errors
442    ///
443    /// Returns [`SnapshotError::Eof`] on EOF.
444    pub fn lp_bytes(&mut self) -> Result<&'a [u8], SnapshotError> {
445        let n = self.u32()? as usize;
446        self.take(n)
447    }
448}
449
450/// Encode a section header (`tag` + `version` + `length`) into `out` and
451/// then append the body bytes.
452pub fn write_section(out: &mut Vec<u8>, tag: [u8; 4], version: u8, body: &[u8]) {
453    out.extend_from_slice(&tag);
454    out.push(version);
455    let len = u32::try_from(body.len()).expect("section body too large");
456    out.extend_from_slice(&len.to_le_bytes());
457    out.extend_from_slice(body);
458}
459
460/// Decoded view of one section's metadata + body slice.
461#[derive(Debug, Clone, Copy)]
462pub struct Section<'a> {
463    /// Tag bytes (e.g. `b"CPU "`).
464    pub tag: [u8; 4],
465    /// Per-section schema version.
466    pub version: u8,
467    /// Body slice (does NOT include the header itself).
468    pub body: &'a [u8],
469}
470
471/// Header decoded from the start of the blob.
472#[derive(Debug, Clone)]
473pub struct Header {
474    /// Container format version (matches [`FORMAT_VERSION`] when written by
475    /// this build).
476    pub format_version: u16,
477    /// Truncated ROM SHA-256 sanity tag.
478    pub rom_hash_tag: [u8; ROM_HASH_TAG_LEN],
479}
480
481/// Parse the 16-byte header.
482///
483/// # Errors
484///
485/// - [`SnapshotError::HeaderTruncated`] if `bytes` is shorter than 16 bytes.
486/// - [`SnapshotError::BadMagic`] on a bad prefix.
487/// - [`SnapshotError::UnsupportedFormat`] if the format version is past
488///   [`FORMAT_VERSION`].
489pub fn parse_header(bytes: &[u8]) -> Result<(Header, usize), SnapshotError> {
490    if bytes.len() < HEADER_LEN {
491        return Err(SnapshotError::HeaderTruncated {
492            expected: HEADER_LEN,
493            got: bytes.len(),
494        });
495    }
496    let mut magic = [0u8; 8];
497    magic.copy_from_slice(&bytes[..8]);
498    if &magic != MAGIC {
499        return Err(SnapshotError::BadMagic { got: magic });
500    }
501    let format_version = u16::from_le_bytes([bytes[8], bytes[9]]);
502    if format_version > FORMAT_VERSION {
503        return Err(SnapshotError::UnsupportedFormat {
504            got: format_version,
505            max: FORMAT_VERSION,
506        });
507    }
508    if format_version < MIN_FORMAT_VERSION {
509        return Err(SnapshotError::FormatTooOld {
510            got: format_version,
511            min: MIN_FORMAT_VERSION,
512        });
513    }
514    let mut rom_hash_tag = [0u8; ROM_HASH_TAG_LEN];
515    rom_hash_tag.copy_from_slice(&bytes[10..16]);
516    Ok((
517        Header {
518            format_version,
519            rom_hash_tag,
520        },
521        HEADER_LEN,
522    ))
523}
524
525/// Iterate sections starting at `bytes` (which should begin immediately
526/// after the header).
527pub struct SectionIter<'a> {
528    src: &'a [u8],
529    pos: usize,
530}
531
532impl<'a> SectionIter<'a> {
533    /// New section iterator at the start of the body.
534    #[must_use]
535    pub const fn new(body: &'a [u8]) -> Self {
536        Self { src: body, pos: 0 }
537    }
538}
539
540impl<'a> Iterator for SectionIter<'a> {
541    type Item = Result<Section<'a>, SnapshotError>;
542
543    fn next(&mut self) -> Option<Self::Item> {
544        if self.pos >= self.src.len() {
545            return None;
546        }
547        // v2.8.0 (libretro audit §2.2) -- an all-zero tail is padding, not a
548        // section. No tag this format writes contains a zero byte (every tag is
549        // four printable ASCII characters, `write_section`'s only callers use
550        // `tag::*` literals), so a run of zeros that reaches the end of the blob
551        // cannot begin a section. The libretro core pads to its advertised
552        // `retro_serialize_size`, which reserves room for expansion devices;
553        // without this, whether its own state loaded back depended on the
554        // padding length mod 9. Any non-zero byte in the tail still reaches the
555        // header parse below and errors as before. Cost: the scan below runs
556        // only when a tag would start with a zero byte, which no written tag
557        // does, and at most once, because either outcome ends the iteration.
558        //
559        // And a zero byte where a tag starts is never a section, padding or
560        // not (review on #556, CodeRabbit): without this, nine zero bytes
561        // framed as an empty zero-tagged section, which restore skips as an
562        // unknown tag, so a crafted blob of such headers followed by one
563        // non-zero byte re-ran the scan above once per header -- quadratic.
564        // Now the scan runs at most once: the tail is padding, or the blob is
565        // rejected here.
566        if self.src[self.pos] == 0 {
567            let at = self.pos;
568            self.pos = self.src.len();
569            if self.src[at..].iter().all(|&b| b == 0) {
570                return None;
571            }
572            return Some(Err(SnapshotError::SectionInvalid {
573                tag: String::from("(zero)"),
574                reason: format!(
575                    "a section tag cannot start with a zero byte (offset {at}), and the \
576                     bytes after it are not all padding"
577                ),
578            }));
579        }
580        // tag(4) + version(1) + len(4) = 9-byte section header.
581        if self.src.len() - self.pos < 9 {
582            let at = self.pos;
583            self.pos = self.src.len(); // fuse, as for a bad length below
584            return Some(Err(SnapshotError::Eof(at)));
585        }
586        let mut tag = [0u8; 4];
587        tag.copy_from_slice(&self.src[self.pos..self.pos + 4]);
588        let version = self.src[self.pos + 4];
589        let mut len_bytes = [0u8; 4];
590        len_bytes.copy_from_slice(&self.src[self.pos + 5..self.pos + 9]);
591        let len = u32::from_le_bytes(len_bytes) as usize;
592        let body_start = self.pos + 9;
593        // `checked_add`, not `+` (core audit IMP-03). `len` is an untrusted
594        // `u32` from the file; where `usize` is 32 bits (`wasm32`, the
595        // `thumbv7em` no_std target) `body_start + len` can wrap to a small
596        // value that passes the bounds test below and slices the wrong bytes,
597        // or panics in a debug build. On 64-bit hosts it cannot overflow, which
598        // is why no native test ever saw it.
599        let body_end = match body_start.checked_add(len) {
600            Some(end) if end <= self.src.len() => end,
601            _ => {
602                // Fuse: a malformed length leaves `pos` unable to advance, so
603                // without this every later `next()` would return the same
604                // error forever -- an infinite iterator for any caller that
605                // skips errors rather than stopping on the first.
606                let got = self.src.len() - body_start;
607                self.pos = self.src.len();
608                return Some(Err(SnapshotError::SectionTruncated {
609                    tag: tag_string(tag),
610                    declared: len,
611                    got,
612                }));
613            }
614        };
615        let body = &self.src[body_start..body_end];
616        self.pos = body_end;
617        Some(Ok(Section { tag, version, body }))
618    }
619}
620
621/// Build the 16-byte header into `out`.
622pub fn write_header(out: &mut Vec<u8>, rom_hash_tag: [u8; ROM_HASH_TAG_LEN]) {
623    out.extend_from_slice(MAGIC);
624    out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
625    out.extend_from_slice(&rom_hash_tag);
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use alloc::vec;
632
633    #[test]
634    fn header_round_trip() {
635        let mut out = Vec::new();
636        write_header(&mut out, [1, 2, 3, 4, 5, 6]);
637        assert_eq!(out.len(), HEADER_LEN);
638        let (h, off) = parse_header(&out).unwrap();
639        assert_eq!(h.format_version, FORMAT_VERSION);
640        assert_eq!(h.rom_hash_tag, [1, 2, 3, 4, 5, 6]);
641        assert_eq!(off, HEADER_LEN);
642    }
643
644    #[test]
645    fn header_rejects_bad_magic() {
646        let mut out = Vec::new();
647        out.extend_from_slice(b"NOTRUSTY");
648        out.extend_from_slice(&[0u8; HEADER_LEN - 8]);
649        assert!(matches!(
650            parse_header(&out),
651            Err(SnapshotError::BadMagic { .. })
652        ));
653    }
654
655    #[test]
656    fn header_rejects_too_new_format() {
657        let mut out = Vec::new();
658        out.extend_from_slice(MAGIC);
659        out.extend_from_slice(&u16::MAX.to_le_bytes());
660        out.extend_from_slice(&[0u8; ROM_HASH_TAG_LEN]);
661        assert!(matches!(
662            parse_header(&out),
663            Err(SnapshotError::UnsupportedFormat { .. })
664        ));
665    }
666
667    /// v2.9.8 (ADR 0042): a container from v2.9.7 or earlier (format 2, or
668    /// the pre-v2.0.0 format 1) is refused at the header, with the typed
669    /// "older release" error rather than a section-level one.
670    #[test]
671    fn header_rejects_formats_older_than_the_minimum() {
672        for old in 0..MIN_FORMAT_VERSION {
673            let mut out = Vec::new();
674            out.extend_from_slice(MAGIC);
675            out.extend_from_slice(&old.to_le_bytes());
676            out.extend_from_slice(&[0u8; ROM_HASH_TAG_LEN]);
677            assert!(matches!(
678                parse_header(&out),
679                Err(SnapshotError::FormatTooOld { got, min })
680                    if got == old && min == MIN_FORMAT_VERSION
681            ));
682        }
683        assert_eq!(MIN_FORMAT_VERSION, FORMAT_VERSION);
684    }
685
686    #[test]
687    fn section_iter_round_trip() {
688        let mut out = Vec::new();
689        write_section(&mut out, *b"AAAA", 1, &[1, 2, 3]);
690        write_section(&mut out, *b"BBBB", 7, &[4, 5, 6, 7, 8]);
691        let mut it = SectionIter::new(&out);
692        let s1 = it.next().unwrap().unwrap();
693        assert_eq!(&s1.tag, b"AAAA");
694        assert_eq!(s1.version, 1);
695        assert_eq!(s1.body, &[1, 2, 3]);
696        let s2 = it.next().unwrap().unwrap();
697        assert_eq!(&s2.tag, b"BBBB");
698        assert_eq!(s2.version, 7);
699        assert_eq!(s2.body, &[4, 5, 6, 7, 8]);
700        assert!(it.next().is_none());
701    }
702
703    #[test]
704    fn section_iter_stops_after_a_malformed_section() {
705        // Core audit IMP-03 follow-on. A malformed section cannot advance
706        // `pos`, so an unfused iterator returns the same error on every call
707        // and a caller that skips errors (`.filter_map(Result::ok)`, `.flatten()`)
708        // never terminates. Each case must yield exactly one error, then end.
709        let mut good = Vec::new();
710        write_section(&mut good, *b"AAAA", 1, &[1, 2, 3]);
711
712        // A header declaring a body of u32::MAX bytes -- the IMP-03 shape.
713        let mut huge = good.clone();
714        huge.extend_from_slice(b"BBBB");
715        huge.push(1);
716        huge.extend_from_slice(&u32::MAX.to_le_bytes());
717        // A header cut short, under the 9-byte minimum.
718        let mut short = good.clone();
719        short.extend_from_slice(b"CCCC");
720
721        for (name, blob) in [("huge length", &huge), ("short header", &short)] {
722            let mut it = SectionIter::new(blob);
723            assert!(it.next().unwrap().is_ok(), "{name}: the good section reads");
724            assert!(it.next().unwrap().is_err(), "{name}: the bad one errors");
725            assert!(it.next().is_none(), "{name}: and the iterator then ENDS");
726            // The skip-errors caller terminates and sees only the good section.
727            assert_eq!(SectionIter::new(blob).flatten().count(), 1, "{name}");
728        }
729    }
730
731    #[test]
732    fn section_iter_ends_at_an_all_zero_tail() {
733        // v2.8.0 (libretro audit §2.2). A libretro frontend hands
734        // `retro_unserialize` a buffer of `retro_serialize_size` bytes, and the
735        // core reserves headroom there for expansion devices, so a state the
736        // core wrote comes back followed by zeros. No section tag is ever
737        // zero (every tag is four printable ASCII bytes), so a tail that is
738        // zero from here to the end cannot be the start of a section: it is
739        // padding, and the blob has ended.
740        //
741        // Before this, 1-8 zero bytes read as a truncated header (`Eof`), a
742        // multiple of 9 read as empty sections named "\0\0\0\0", and any other
743        // count errored after those -- so whether a padded state loaded
744        // depended on the padding length mod 9.
745        let mut good = Vec::new();
746        write_section(&mut good, *b"AAAA", 1, &[1, 2, 3]);
747        write_section(&mut good, *b"BBBB", 7, &[4, 5, 6, 7, 8]);
748        for pad in 1..=64 {
749            let mut padded = good.clone();
750            padded.resize(good.len() + pad, 0);
751            let sections: Vec<_> = SectionIter::new(&padded)
752                .collect::<Result<_, _>>()
753                .unwrap_or_else(|e| panic!("{pad} zero bytes of padding: {e:?}"));
754            assert_eq!(
755                sections.len(),
756                2,
757                "{pad} zero bytes: exactly the real sections"
758            );
759            assert_eq!(&sections[1].tag, b"BBBB");
760        }
761    }
762
763    #[test]
764    fn no_section_tag_contains_a_zero_byte() {
765        // The invariant the all-zero-tail rule rests on. A tag with a zero byte
766        // in it could, at the end of a blob, be mistaken for padding; pinned
767        // here so a new tag cannot quietly break it.
768        for t in [tag::CPU, tag::PPU, tag::APU, tag::MAP, tag::BUS, tag::THM] {
769            assert!(
770                t.iter().all(|b| b.is_ascii_graphic() || *b == b' '),
771                "section tag {t:?} must be four printable ASCII bytes"
772            );
773        }
774    }
775
776    #[test]
777    fn section_iter_rejects_zero_tagged_headers_in_one_step() {
778        // Review on #556 (CodeRabbit). Nine zero bytes used to frame an empty
779        // section with tag 00 00 00 00, which restore skips as unknown; with
780        // the padding rule, a blob of many such headers ending in one non-zero
781        // byte re-scanned the zero run once per header. A zero byte where a
782        // tag starts is now rejected on the spot, so the iterator yields one
783        // error and stops, however many headers follow.
784        let mut blob = Vec::new();
785        write_section(&mut blob, *b"AAAA", 1, &[1, 2, 3]);
786        let real = blob.len();
787        blob.resize(real + 9 * 10_000, 0);
788        blob.push(0x5A);
789        let items: Vec<_> = SectionIter::new(&blob).collect();
790        assert_eq!(items.len(), 2, "the real section, then exactly one error");
791        assert!(items[0].is_ok());
792        match &items[1] {
793            Err(SnapshotError::SectionInvalid { reason, .. }) => assert!(
794                reason.contains(&format!("offset {real}")),
795                "the error names where the zero tag starts: {reason}"
796            ),
797            other => panic!("expected SectionInvalid, got {other:?}"),
798        }
799        // A single zero-tagged header followed by a real section, which the
800        // old iterator skipped past, is rejected the same way.
801        let mut blob = vec![0_u8; 9];
802        write_section(&mut blob, *b"AAAA", 1, &[1]);
803        assert!(SectionIter::new(&blob).next().is_some_and(|r| r.is_err()));
804    }
805
806    #[test]
807    fn section_iter_still_rejects_a_tail_that_is_not_all_zero() {
808        // The other half: padding is recognised by being ENTIRELY zero, so a
809        // tail with one non-zero byte, wherever it falls (first byte, last
810        // byte, past a section-sized run of zeros), is still parsed as section
811        // data. The iterator yields at least one item past the real section for
812        // it -- an error, or, where the bytes happen to form a well-framed
813        // header, an unknown-tag section that callers ignore, exactly as
814        // before this change. What it must never do is end silently.
815        let mut good = Vec::new();
816        write_section(&mut good, *b"AAAA", 1, &[1, 2, 3]);
817        for (len, at) in [(1, 0), (8, 7), (20, 0), (20, 19), (40, 25)] {
818            let mut tail = good.clone();
819            tail.resize(good.len() + len, 0);
820            tail[good.len() + at] = 0x5A;
821            assert!(
822                SectionIter::new(&tail).count() > 1,
823                "a {len}-byte tail with a non-zero byte at {at} must not read as padding"
824            );
825        }
826        // And the short cases, which cannot frame a header, are errors.
827        for (len, at) in [(1, 0), (8, 7), (8, 0)] {
828            let mut tail = good.clone();
829            tail.resize(good.len() + len, 0);
830            tail[good.len() + at] = 0x5A;
831            assert!(
832                SectionIter::new(&tail).any(|s| s.is_err()),
833                "a {len}-byte non-zero tail is a truncated header"
834            );
835        }
836    }
837
838    #[test]
839    fn binwriter_round_trip() {
840        let mut w = BinWriter::new();
841        w.u8(0x12);
842        w.u16(0x3456);
843        w.u32(0x789A_BCDE);
844        w.u64(0xFEED_FACE_CAFE_BEEF);
845        w.bool(true);
846        w.bool(false);
847        w.bytes(&[0xAA, 0xBB]);
848        let buf = w.into_vec();
849        let mut r = BinReader::new(&buf);
850        assert_eq!(r.u8().unwrap(), 0x12);
851        assert_eq!(r.u16().unwrap(), 0x3456);
852        assert_eq!(r.u32().unwrap(), 0x789A_BCDE);
853        assert_eq!(r.u64().unwrap(), 0xFEED_FACE_CAFE_BEEF);
854        assert!(r.bool().unwrap());
855        assert!(!r.bool().unwrap());
856        assert_eq!(r.take(2).unwrap(), &[0xAA, 0xBB]);
857        assert!(r.is_empty());
858    }
859
860    #[test]
861    fn binreader_eof_errors() {
862        let buf = [0x12u8];
863        let mut r = BinReader::new(&buf);
864        assert!(r.u8().is_ok());
865        assert!(matches!(r.u8(), Err(SnapshotError::Eof(_))));
866    }
867}