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!(§ions[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}