From 10306384ee2b20fbf9d9a036d338d480fb95d0e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:57:47 +0000 Subject: [PATCH 1/2] test(pksave): kill the full-crate mutation-run survivors cargo mutants -p pksave over the whole crate (issue #33): 1555 mutants, 89 missed against the previous suite. Every killable survivor now has a dedicated test: - box/party layout arithmetic pinned against the raw byte image at slot indexes >= 2, so symmetric read/write paths cannot self-cancel (box edits, remove-shift, swap, withdraw-append, swap_party_box); - is_empty polarity through every view AND mut wrapper (boxes, HoF, item lists), ItemListMut::get, DaycareMut name getters; - boundary semantics: counts exactly at capacity are legal, level 100 still gets the exp-coherence check, set_bytes at the buffer end and the first tail byte, dex bit 151 filtered from counts; - exact diagnostic spans (box sentinel, unknown item id), item-list region extents as literals, changed_ranges tail merging; - the SaveGame trait impl driven through a trait object; - MonView::moves slot order, flag getters both polarities, map block coords and last_map. The 16 provably-equivalent survivors are recorded in .cargo/mutants.toml: eleven |->^ mutants on disjoint-bit packs are excluded per function with rationale; five +0-constant mutants in validate.rs stay visible on purpose (a function-scoped exclude would mask real span regressions). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BSxh62iGqh4bffn8hRcTME --- .cargo/mutants.toml | 23 ++++ .gitignore | 2 + crates/pksave/tests/boxes.rs | 194 +++++++++++++++++++++++++++++ crates/pksave/tests/daycare.rs | 14 +++ crates/pksave/tests/diagnostics.rs | 96 ++++++++++++++ crates/pksave/tests/events_map.rs | 36 ++++++ crates/pksave/tests/hof.rs | 13 ++ crates/pksave/tests/items.rs | 57 +++++++++ crates/pksave/tests/pokedex.rs | 11 ++ crates/pksave/tests/pokemon.rs | 9 ++ crates/pksave/tests/raw_edit.rs | 42 +++++++ 11 files changed, 497 insertions(+) create mode 100644 .cargo/mutants.toml diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..9886bf2 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,23 @@ +# cargo-mutants configuration (issue #33, full-crate pass 2026-07-17: +# 1555 mutants, 89 missed -> tests added for all killable ones). +# +# Excluded below: provably-equivalent `|`->`^` mutants. In every listed +# function the `|` operands have disjoint set bits (nibble/bit-field +# packs or shifted whole bytes), where OR and XOR are the same function +# — the #5-precedent equivalence class. Reviewed per function, not +# blanket: every `|` in each listed function is a disjoint-operand pack. +# +# Known-equivalent but NOT excluded (kept visible on purpose, expect +# them as "missed"): five `+ with -` mutants in validate.rs (party, +# boxes, level_exp_coherence x2, text_terminators) that add the layout +# constant COUNT == 0, where +0 == -0. A function-scoped exclude would +# also hide future real span-arithmetic regressions in those functions. +exclude_re = [ + 'replace \| with \^ in encode', # bcd: (hi<<4) | lo, lo < 16 + 'replace \| with \^ in SaveFile::set_current_box_number', # (byte & 0x80) | n, n < 12 + 'replace \| with \^ in MonView::exp', # b0<<16 | b1<<8 | b2 + 'replace \| with \^ in Dvs::pack', # (x&0xF)<<4 | (y&0xF) + 'replace \| with \^ in Dvs::hp_dv', # four distinct single bits + 'replace \| with \^ in compose_pp', # (ups&3)<<6 | (pp&0x3F) + 'replace \| with \^ in SaveFile::set_text_speed', # (byte & 0xF0) | speed, speed < 8 +] diff --git a/.gitignore b/.gitignore index 6054358..5759a8d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ e2e/fixtures/ e2e/.venv/ __pycache__/ .DS_Store +# cargo-mutants output (issue #33 runs) +mutants*.out/ diff --git a/crates/pksave/tests/boxes.rs b/crates/pksave/tests/boxes.rs index 49f4b3e..0d61e4a 100644 --- a/crates/pksave/tests/boxes.rs +++ b/crates/pksave/tests/boxes.rs @@ -711,3 +711,197 @@ impl TestPoke for SaveFile { self.mark_edited(); } } + +// ---- mutation hardening (issue #33): pin the layout arithmetic against +// the raw byte image, so offset math cannot silently self-cancel through +// symmetric read/write paths. + +#[test] +fn box_edits_at_high_slots_land_at_the_documented_offsets() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); // box 0 routes to its bank slot + { + let mut b = save.box_mut(0); + for dex in [1, 4, 7] { + b.add(&make_box_mon(dex, 12), "RED", "NICK").expect("room"); + } + b.set_ot_name(2, "BLUE").expect("encodes"); + b.set_nickname(2, "SQUIRT").expect("encodes"); + b.set_species(1, 0x99); + } + let bytes = save.to_bytes(); + let base = offsets::box_offset(0); + assert_eq!(bytes[base + SPECIES_LIST + 1], 0x99, "species list entry 1"); + assert_eq!( + &bytes[base + OT_NAMES + 2 * offsets::NAME_LEN..][..offsets::NAME_LEN], + &pksave::gen1::text::encode("BLUE", offsets::NAME_LEN).expect("encodes")[..], + "slot 2 OT name bytes" + ); + assert_eq!( + &bytes[base + NICKNAMES + 2 * offsets::NAME_LEN..][..offsets::NAME_LEN], + &pksave::gen1::text::encode("SQUIRT", offsets::NAME_LEN).expect("encodes")[..], + "slot 2 nickname bytes" + ); + assert_eq!( + bytes[base + MONS + 2 * offsets::BOX_MON_SIZE], + DEX_TO_INDEX[7], + "slot 2 mon record species byte" + ); +} + +#[test] +fn box_remove_middle_slot_shifts_every_array() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + { + let mut b = save.box_mut(0); + for (dex, nick) in [(1, "N1"), (4, "N2"), (7, "N3"), (25, "N4")] { + b.add(&make_box_mon(dex, 9), &format!("O{nick}"), nick) + .expect("room"); + } + b.remove(1); + } + let view = save.box_(0); + assert_eq!(view.len(), 3); + assert_eq!( + view.species_list(), + &[DEX_TO_INDEX[1], DEX_TO_INDEX[7], DEX_TO_INDEX[25]] + ); + assert_eq!( + [view.nickname(0), view.nickname(1), view.nickname(2)], + ["N1".to_owned(), "N3".to_owned(), "N4".to_owned()] + ); + assert_eq!(view.ot_name(1), "ON3"); + assert_eq!(view.mon(1).species(), DEX_TO_INDEX[7]); + // The vacated trailing slot is zero-filled in all three arrays. + let bytes = save.to_bytes(); + let base = offsets::box_offset(0); + assert!( + bytes[base + MONS + 3 * offsets::BOX_MON_SIZE..][..offsets::BOX_MON_SIZE] + .iter() + .all(|&b| b == 0) + ); + assert!( + bytes[base + OT_NAMES + 3 * offsets::NAME_LEN..][..offsets::NAME_LEN] + .iter() + .all(|&b| b == 0) + ); + assert!( + bytes[base + NICKNAMES + 3 * offsets::NAME_LEN..][..offsets::NAME_LEN] + .iter() + .all(|&b| b == 0) + ); +} + +#[test] +fn box_swap_moves_all_four_arrays_together() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + { + let mut b = save.box_mut(0); + for (dex, nick) in [(1, "A"), (4, "B"), (7, "C")] { + b.add(&make_box_mon(dex, 9), &format!("O{nick}"), nick) + .expect("room"); + } + b.swap(0, 2); + } + let view = save.box_(0); + assert_eq!( + view.species_list(), + &[DEX_TO_INDEX[7], DEX_TO_INDEX[4], DEX_TO_INDEX[1]] + ); + assert_eq!(view.nickname(0), "C"); + assert_eq!(view.nickname(2), "A"); + assert_eq!(view.ot_name(0), "OC"); + assert_eq!(view.mon(0).species(), DEX_TO_INDEX[7]); + assert_eq!(view.mon(2).species(), DEX_TO_INDEX[1]); +} + +#[test] +fn withdraw_appends_at_the_documented_party_offsets() { + // Party-block layout (docs/FORMAT.md): count 0x000, species list + // 0x001, mons 0x008, OT names 0x110, nicknames 0x152. + const P_MONS: usize = 0x008; + const P_OT_NAMES: usize = 0x110; + const P_NICKNAMES: usize = 0x152; + + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + for (dex, nick) in [(1, "P1"), (4, "P2")] { + save.party_mut() + .add(&make_party_mon(dex, 8), "ASH", nick) + .expect("room"); + } + save.box_mut(0) + .add(&make_box_mon(7, 31), "OTIS", "BOXY") + .expect("room"); + save.withdraw(0, 0).expect("party has room"); + + let party = save.party(); + assert_eq!(party.len(), 3); + assert_eq!(party.nickname(2), "BOXY"); + assert_eq!(party.ot_name(2), "OTIS"); + assert_eq!(party.mon(2).species(), DEX_TO_INDEX[7]); + assert_eq!(party.mon(2).level(), 31, "level derives from exp"); + + let bytes = save.to_bytes(); + let base = offsets::PARTY; + assert_eq!(bytes[base], 3, "party count byte"); + assert_eq!(bytes[base + 1 + 2], DEX_TO_INDEX[7], "species list entry 2"); + assert_eq!(bytes[base + 1 + 3], 0xFF, "species list sentinel"); + assert_eq!( + bytes[base + P_MONS + 2 * offsets::PARTY_MON_SIZE], + DEX_TO_INDEX[7], + "mon record 2 species byte" + ); + assert_eq!( + &bytes[base + P_OT_NAMES + 2 * offsets::NAME_LEN..][..offsets::NAME_LEN], + &pksave::gen1::text::encode("OTIS", offsets::NAME_LEN).expect("encodes")[..], + "OT name 2 bytes" + ); + assert_eq!( + &bytes[base + P_NICKNAMES + 2 * offsets::NAME_LEN..][..offsets::NAME_LEN], + &pksave::gen1::text::encode("BOXY", offsets::NAME_LEN).expect("encodes")[..], + "nickname 2 bytes" + ); +} + +#[test] +fn swap_party_box_exchanges_records_names_and_nicknames() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + for (dex, nick) in [(1, "P1"), (4, "P2"), (7, "P3")] { + save.party_mut() + .add(&make_party_mon(dex, 8), "ASH", nick) + .expect("room"); + } + for (dex, nick) in [(25, "B1"), (39, "B2"), (52, "B3")] { + save.box_mut(0) + .add(&make_box_mon(dex, 21), "GARY", nick) + .expect("room"); + } + save.swap_party_box(2, 0, 2).expect("both occupied"); + + let party = save.party(); + assert_eq!(party.mon(2).species(), DEX_TO_INDEX[52]); + assert_eq!(party.nickname(2), "B3"); + assert_eq!(party.ot_name(2), "GARY"); + assert_eq!(party.mon(2).level(), 21, "withdrawn level derives from exp"); + let boxv = save.box_(0); + assert_eq!(boxv.mon(2).species(), DEX_TO_INDEX[7]); + assert_eq!(boxv.nickname(2), "P3"); + assert_eq!(boxv.ot_name(2), "ASH"); +} + +#[test] +fn box_is_empty_reports_both_polarities_via_view_and_mut() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + assert!(save.box_(0).is_empty()); + assert!(save.box_mut(0).is_empty()); + save.box_mut(0) + .add(&make_box_mon(1, 5), "RED", "BULBA") + .expect("room"); + assert!(!save.box_(0).is_empty()); + assert!(!save.box_mut(0).is_empty()); +} diff --git a/crates/pksave/tests/daycare.rs b/crates/pksave/tests/daycare.rs index ecf0a78..936d339 100644 --- a/crates/pksave/tests/daycare.rs +++ b/crates/pksave/tests/daycare.rs @@ -119,3 +119,17 @@ fn nonzero_in_use_byte_counts_as_occupied() { let save = SaveFile::from_bytes(bytes).expect("length is valid"); assert!(save.daycare().is_some()); } + +#[test] +fn daycare_mut_reads_names_back() { + // Mutation hardening (issue #33): the DaycareMut name getters are a + // separate delegation from the view's. + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + let mut mon = [0u8; offsets::BOX_MON_SIZE]; + BoxMonMut::new(&mut mon).set_species(DEX_TO_INDEX[113]); + save.set_daycare(Some((&mon, "OTIS", "EGGSY"))) + .expect("names encode"); + let daycare = save.daycare_mut().expect("occupied"); + assert_eq!(daycare.ot_name(), "OTIS"); + assert_eq!(daycare.nickname(), "EGGSY"); +} diff --git a/crates/pksave/tests/diagnostics.rs b/crates/pksave/tests/diagnostics.rs index 18705b6..991c511 100644 --- a/crates/pksave/tests/diagnostics.rs +++ b/crates/pksave/tests/diagnostics.rs @@ -439,3 +439,99 @@ fn multi_broken_save_snapshot() { .collect(); insta::assert_snapshot!(rendered.join("\n")); } + +#[test] +fn savegame_trait_object_delegates_to_the_inherent_impls() { + // Mutation hardening (issue #33): the `SaveGame` impl is a thin + // delegation layer no other test calls through. + use pksave::SaveGame; + let save = broken(false, |bytes| bytes[offsets::MAIN_DATA] ^= 0xFF); + let dyn_save: &dyn SaveGame = &save; + assert_eq!(dyn_save.game_label(), save.game_label()); + assert!(!dyn_save.game_label().is_empty()); + assert_eq!(dyn_save.to_bytes(), save.to_bytes()); + let diags = dyn_save.diagnostics(); + assert_eq!(diags, save.diagnostics()); + assert!(!diags.is_empty(), "the broken checksum must be reported"); +} + +// ---- mutation hardening (issue #33): exact boundaries of the +// count/sentinel/level checks ---- + +#[test] +fn full_party_and_full_box_counts_are_legal() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + let mut party_mon = [0u8; offsets::PARTY_MON_SIZE]; + { + let mut mon = PartyMonMut::new(&mut party_mon); + mon.set_species(DEX_TO_INDEX[1]); + mon.set_level_coherent(5); + } + for _ in 0..offsets::PARTY_CAPACITY { + save.party_mut() + .add(&party_mon, "RED", "BULBA") + .expect("room"); + } + let mut box_mon = [0u8; offsets::BOX_MON_SIZE]; + { + let mut mon = BoxMonMut::new(&mut box_mon); + mon.set_species(DEX_TO_INDEX[1]); + mon.set_level_coherent(5); + } + save.set_current_box_number(1); + for _ in 0..offsets::MONS_PER_BOX { + save.box_mut(0).add(&box_mon, "RED", "BULBA").expect("room"); + } + let diags = save.diagnostics(); + assert!( + diags + .iter() + .all(|d| d.code != "W-PARTY-COUNT" && d.code != "W-BOX-COUNT"), + "counts at exactly capacity are legal: {diags:?}" + ); +} + +#[test] +fn box_sentinel_span_names_the_terminator_byte() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + let mut box_mon = [0u8; offsets::BOX_MON_SIZE]; + { + let mut mon = BoxMonMut::new(&mut box_mon); + mon.set_species(DEX_TO_INDEX[1]); + mon.set_level_coherent(5); + } + for _ in 0..3 { + save.box_mut(0).add(&box_mon, "RED", "BULBA").expect("room"); + } + let sentinel_at = offsets::box_offset(0) + 1 + 3; + save.set_byte(sentinel_at, 0x00).expect("in range"); + let diags = save.diagnostics(); + let diag = diags + .iter() + .find(|d| d.code == "W-BOX-SENTINEL") + .expect("sentinel mismatch flagged"); + assert_eq!(diag.span, Some(sentinel_at..sentinel_at + 1)); +} + +#[test] +fn level_100_still_gets_the_exp_mismatch_check() { + // Exactly 100 is a legal level; only >100 is exempt from the + // level/exp coherence check. + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + let mut box_mon = [0u8; offsets::BOX_MON_SIZE]; + { + let mut mon = BoxMonMut::new(&mut box_mon); + mon.set_species(DEX_TO_INDEX[1]); + mon.set_level_coherent(50); + mon.set_box_level(100); + } + save.box_mut(0).add(&box_mon, "RED", "BULBA").expect("room"); + assert!( + save.diagnostics() + .iter() + .any(|d| d.code == "W-LEVEL-EXP-MISMATCH"), + "a level-100 byte disagreeing with exp must be flagged" + ); +} diff --git a/crates/pksave/tests/events_map.rs b/crates/pksave/tests/events_map.rs index ec25454..17d2c4f 100644 --- a/crates/pksave/tests/events_map.rs +++ b/crates/pksave/tests/events_map.rs @@ -239,3 +239,39 @@ fn warp_to_touches_only_map_and_coord_bytes() { "pointer left untouched" ); } + +// ---- mutation hardening (issue #33) ---- + +#[test] +fn missable_and_hidden_flags_read_both_polarities() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + assert!(!save.missable_flag(3)); + save.set_missable_flag(3, true); + assert!(save.missable_flag(3)); + assert!(!save.hidden_item_flag(5)); + save.set_hidden_item_flag(5, true); + assert!(save.hidden_item_flag(5)); +} + +#[test] +fn bit_slice_mut_get_reads_live_bits() { + use pksave::gen1::flags::BitSliceMut; + let mut bytes = [0u8; 2]; + let mut bits = BitSliceMut::new(&mut bytes); + assert!(!bits.get(3)); + bits.set(3, true); + assert!(bits.get(3)); + assert!(!bits.get(4)); +} + +#[test] +fn block_coords_and_last_map_round_trip() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + assert_eq!(save.last_map(), 0, "new save has no last map"); + save.set_last_map(5); + assert_eq!(save.last_map(), 5); + save.set_x_block_coord(7); + save.set_y_block_coord(9); + assert_eq!(save.x_block_coord(), 7); + assert_eq!(save.y_block_coord(), 9); +} diff --git a/crates/pksave/tests/hof.rs b/crates/pksave/tests/hof.rs index 37cfce2..1663e93 100644 --- a/crates/pksave/tests/hof.rs +++ b/crates/pksave/tests/hof.rs @@ -152,3 +152,16 @@ fn six_slots_per_team() { } assert_eq!(team.len(), 6); } + +#[test] +fn team_is_empty_reports_both_polarities_via_view_and_mut() { + // Mutation hardening (issue #33). + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + assert!(save.hof_team(0).is_empty()); + assert!(save.hof_team_mut(0).is_empty()); + save.hof_team_mut(0) + .set_mon(0, DEX_TO_INDEX[151], 70, "MEW") + .expect("fits"); + assert!(!save.hof_team(0).is_empty()); + assert!(!save.hof_team_mut(0).is_empty()); +} diff --git a/crates/pksave/tests/items.rs b/crates/pksave/tests/items.rs index 6bdfb59..9c00c68 100644 --- a/crates/pksave/tests/items.rs +++ b/crates/pksave/tests/items.rs @@ -286,3 +286,60 @@ fn diagnostics_spans_point_into_the_list_region() { Some(offsets::BAG_ITEMS + 2..offsets::BAG_ITEMS + 3) ); } + +// ---- mutation hardening (issue #33) ---- + +#[test] +fn is_empty_and_get_report_through_the_mut_view_too() { + let mut save = blank(); + assert!(save.bag_items().is_empty()); + assert!(save.bag_items_mut().is_empty()); + save.bag_items_mut().add(POTION, 2).expect("room"); + save.bag_items_mut().add(0x0A, 95).expect("room"); + assert!(!save.bag_items().is_empty()); + let list = save.bag_items_mut(); + assert!(!list.is_empty()); + assert_eq!(list.get(0), Some((POTION, 2))); + assert_eq!(list.get(1), Some((0x0A, 95))); + assert_eq!(list.get(2), None, "past the end"); +} + +#[test] +fn spec_regions_span_count_pairs_and_terminator() { + // count byte + 2*capacity pair bytes + terminator, as literal + // offsets so the arithmetic itself is pinned. + assert_eq!(BAG_LIST.region(), 0x25C9..0x25C9 + 1 + 2 * 20 + 1); + assert_eq!(PC_LIST.region(), 0x27E6..0x27E6 + 1 + 2 * 50 + 1); +} + +#[test] +fn count_equal_to_capacity_is_not_flagged() { + let mut save = blank(); + for _ in 0..offsets::BAG_CAPACITY { + save.bag_items_mut().add(POTION, 1).expect("room"); + } + assert!( + save.diagnostics().iter().all(|d| d.code != "W-ITEMS-COUNT"), + "a full list is legal" + ); +} + +#[test] +fn unknown_item_id_span_points_at_that_entry() { + let mut save = blank(); + save.bag_items_mut().add(POTION, 1).expect("room"); + save.bag_items_mut().add(POTION, 1).expect("room"); + // Corrupt entry 1's id to 0x00 (no item name) through the raw path. + save.set_byte(offsets::BAG_ITEMS + 2, 0x00) + .expect("in range"); + let diags = save.diagnostics(); + let diag = diags + .iter() + .find(|d| d.code == "W-ITEMS-UNKNOWN-ID") + .expect("unknown id flagged"); + assert_eq!( + diag.span, + Some(offsets::BAG_ITEMS + 2..offsets::BAG_ITEMS + 3), + "span names entry 1's id byte" + ); +} diff --git a/crates/pksave/tests/pokedex.rs b/crates/pksave/tests/pokedex.rs index 756bcc8..b8eba08 100644 --- a/crates/pksave/tests/pokedex.rs +++ b/crates/pksave/tests/pokedex.rs @@ -109,3 +109,14 @@ fn complete_dex_sets_all_151_owned_and_seen() { assert_eq!(b[offsets::POKEDEX_SEEN + offsets::POKEDEX_LEN - 1], 0x7F); assert!(save.is_edited()); } + +#[test] +fn stray_152nd_bit_never_counts() { + // Mutation hardening (issue #33): bit 151 (byte 18, mask 0x80) is + // outside the 151-species dex and must be filtered from the counts. + let mut save = blank(); + save.set_dex_owned(1, true); + save.set_byte(offsets::POKEDEX_OWNED + 18, 0x80) + .expect("in range"); + assert_eq!(save.owned_count(), 1); +} diff --git a/crates/pksave/tests/pokemon.rs b/crates/pksave/tests/pokemon.rs index f30066d..b3e8745 100644 --- a/crates/pksave/tests/pokemon.rs +++ b/crates/pksave/tests/pokemon.rs @@ -690,3 +690,12 @@ proptest! { prop_assert_eq!(restored, original); } } + +#[test] +fn moves_read_back_in_slot_order() { + // Mutation hardening (issue #33): distinct move ids pin the per-slot + // offset arithmetic of the reader. + let mut bytes = [0u8; PARTY_MON_SIZE]; + PartyMonMut::new(&mut bytes).set_moves([10, 20, 30, 40]); + assert_eq!(PartyMonView::new(&bytes).moves(), [10, 20, 30, 40]); +} diff --git a/crates/pksave/tests/raw_edit.rs b/crates/pksave/tests/raw_edit.rs index 2ebb40b..f09c706 100644 --- a/crates/pksave/tests/raw_edit.rs +++ b/crates/pksave/tests/raw_edit.rs @@ -274,3 +274,45 @@ proptest! { } } } + +// ---- mutation hardening (issue #33): boundary behavior of set_bytes +// and the tail/SRAM edge ---- + +#[test] +fn set_bytes_accepts_the_exact_boundaries() { + let mut save = SaveFile::from_bytes(vec![0u8; 0x8009]).expect("valid length"); + let len = 0x8009; + save.set_bytes(len, &[]).expect("empty write at the end"); + assert!(!save.is_edited(), "nothing was written"); + save.set_bytes(len - 2, &[9, 9]) + .expect("exact fit at the end"); + assert_eq!(&save.as_bytes()[len - 2..], &[9, 9]); +} + +#[test] +fn first_tail_byte_writes_verbatim_without_checksum_repair() { + // Byte 0x8000 is the first byte past the SRAM image: writing it must + // take the tail path (verbatim, no edited flag, checksums kept). + let mut save = SaveFile::from_bytes(vec![0u8; 0x8009]).expect("valid length"); + save.set_bytes(offsets::SRAM_SIZE, &[7]).expect("in range"); + assert!(!save.is_edited(), "tail writes bypass the edited flag"); + let mut expected = vec![0u8; 0x8009]; + expected[offsets::SRAM_SIZE] = 7; + assert_eq!(save.to_bytes(), expected, "only the tail byte changed"); +} + +#[test] +fn changed_ranges_merges_only_adjacent_tail_growth() { + // A change run that ends exactly at the common length merges with + // the appended tail; a gap keeps them separate. + assert_eq!( + changed_ranges(&[1, 2, 3], &[1, 2, 9, 8]), + vec![2..4], + "adjacent run merges with the tail" + ); + assert_eq!( + changed_ranges(&[1, 2, 3], &[9, 2, 3, 4]), + vec![0..1, 3..4], + "non-adjacent run stays separate from the tail" + ); +} From a8ba367ddb4d12717a89a4ed1721a3f0d182b257 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:57:59 +0000 Subject: [PATCH 2/2] fuzz: coverage-guided libFuzzer target for the no-panic guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cargo-fuzz scaffolding for issue #34: a save_walk target that parses arbitrary bytes and drives pksave::fuzz_support::exercise — a walk of every public view, accessor and diagnostic, the untouched byte-identity round-trip, fix_checksums + reload, and the mutating paths that consume file-derived values (deposit/withdraw per box, recalculate_stats). Keeping the walk inside the crate as a #[doc(hidden)] module means it compiles against every API change even though the fuzz crate itself is nightly-only and excluded from the stable workspace. Seeds: the e2e fixture saves plus all-0x00/all-0xFF hostile bases. Run with: cargo +nightly fuzz run save_walk -- -max_len=33000 (inputs shorter than 32 KiB are rejected by from_bytes, so max_len must exceed SRAM_SIZE for the fuzzer to reach the interesting space). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BSxh62iGqh4bffn8hRcTME --- Cargo.toml | 2 + crates/pksave/fuzz/.gitignore | 4 + crates/pksave/fuzz/Cargo.lock | 152 +++++++++++++++++ crates/pksave/fuzz/Cargo.toml | 29 ++++ crates/pksave/fuzz/fuzz_targets/save_walk.rs | 11 ++ crates/pksave/src/fuzz_support.rs | 165 +++++++++++++++++++ crates/pksave/src/lib.rs | 3 + 7 files changed, 366 insertions(+) create mode 100644 crates/pksave/fuzz/.gitignore create mode 100644 crates/pksave/fuzz/Cargo.lock create mode 100644 crates/pksave/fuzz/Cargo.toml create mode 100644 crates/pksave/fuzz/fuzz_targets/save_walk.rs create mode 100644 crates/pksave/src/fuzz_support.rs diff --git a/Cargo.toml b/Cargo.toml index 0c9cdc2..5844391 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] resolver = "2" members = ["crates/pksave", "crates/pksave-app", "crates/xtask"] +# The fuzz crate (nightly + libFuzzer only) builds as its own workspace. +exclude = ["crates/pksave/fuzz"] [workspace.package] edition = "2021" diff --git a/crates/pksave/fuzz/.gitignore b/crates/pksave/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/crates/pksave/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/crates/pksave/fuzz/Cargo.lock b/crates/pksave/fuzz/Cargo.lock new file mode 100644 index 0000000..d553591 --- /dev/null +++ b/crates/pksave/fuzz/Cargo.lock @@ -0,0 +1,152 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "pksave" +version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "pksave-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "pksave", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/crates/pksave/fuzz/Cargo.toml b/crates/pksave/fuzz/Cargo.toml new file mode 100644 index 0000000..4a133ee --- /dev/null +++ b/crates/pksave/fuzz/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "pksave-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.pksave] +path = ".." + +[[bin]] +name = "save_walk" +path = "fuzz_targets/save_walk.rs" +test = false +doc = false +bench = false + +# Its own workspace: the fuzz crate needs nightly + libFuzzer and must +# stay out of the stable-toolchain root workspace builds. +[workspace] +members = ["."] + +[profile.release] +debug = 1 diff --git a/crates/pksave/fuzz/fuzz_targets/save_walk.rs b/crates/pksave/fuzz/fuzz_targets/save_walk.rs new file mode 100644 index 0000000..5b7c379 --- /dev/null +++ b/crates/pksave/fuzz/fuzz_targets/save_walk.rs @@ -0,0 +1,11 @@ +//! Coverage-guided sweep of the no-panic-from-file-contents guarantee: +//! parse arbitrary bytes and walk every view, accessor, diagnostic and +//! file-value-driven mutating path. The walk itself lives in +//! `pksave::fuzz_support` so it always compiles against the current API. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + pksave::fuzz_support::exercise(data); +}); diff --git a/crates/pksave/src/fuzz_support.rs b/crates/pksave/src/fuzz_support.rs new file mode 100644 index 0000000..f1886fa --- /dev/null +++ b/crates/pksave/src/fuzz_support.rs @@ -0,0 +1,165 @@ +//! Shared harness for coverage-guided fuzzing (issue #34): parse an +//! arbitrary buffer and walk every public view, accessor and diagnostic, +//! then drive the mutating paths that consume file-derived values. +//! +//! Nothing here may panic for any input: this is the module-shaped form +//! of the crate's core guarantee that **no panic is reachable from file +//! contents** (a panic aborts the whole app on wasm — DoS via a crafted +//! save). The fuzz target (`fuzz/fuzz_targets/save_walk.rs`) is a thin +//! wrapper around [`exercise`]; keeping the walk inside the crate keeps +//! it compiling against every API change. + +use crate::gen1::offsets; +use crate::gen1::pokemon::{MonView, PartyMon}; +use crate::gen1::save::SaveFile; + +/// Read every getter of one mon record. +fn walk_mon(mon: &dyn MonView) { + let _ = mon.species(); + let _ = mon.current_hp(); + let _ = mon.box_level(); + let _ = mon.status(); + let _ = mon.sleep_turns(); + let _ = mon.is_poisoned(); + let _ = mon.is_burned(); + let _ = mon.is_frozen(); + let _ = mon.is_paralyzed(); + let _ = mon.types(); + let _ = mon.catch_rate(); + let _ = mon.moves(); + let _ = mon.ot_id(); + let _ = mon.exp(); + let _ = mon.stat_exps(); + let _ = mon.dvs(); + let _ = mon.pp(); + let _ = mon.level_from_exp(); +} + +/// Read every view/accessor/diagnostic of the save. +fn walk(save: &SaveFile) { + let _ = save.diagnostics(); + let _ = save.game_label(); + + let party = save.party(); + for i in 0..party.len() { + let mon = party.mon(i); + walk_mon(&mon); + let _ = mon.level(); + let _ = mon.max_hp(); + let _ = mon.attack(); + let _ = mon.defense(); + let _ = mon.speed(); + let _ = mon.special(); + let _ = party.nickname(i); + let _ = party.ot_name(i); + } + + let _ = save.current_box_number(); + let _ = save.boxes_initialized(); + for n in 0..offsets::NUM_BOXES { + let _ = save.box_is_live(n); + let view = save.box_(n); + for i in 0..view.len() { + walk_mon(&view.mon(i)); + let _ = view.nickname(i); + let _ = view.ot_name(i); + } + } + + if let Some(daycare) = save.daycare() { + walk_mon(&daycare.mon()); + let _ = daycare.nickname(); + let _ = daycare.ot_name(); + } + + let _ = save.hof_team_count(); + for t in 0..offsets::HOF_TEAM_CAPACITY { + let team = save.hof_team(t); + for slot in 0..team.len() { + if let Some(mon) = team.mon(slot) { + let _ = mon.species(); + let _ = mon.level(); + let _ = mon.nickname(); + } + } + } + + for (bag, list) in [(true, save.bag_items()), (false, save.pc_items())] { + let _ = bag; + let _ = list.is_empty(); + for (id, qty) in list.iter() { + let _ = (id, qty); + } + } + + let _ = save.player_name(); + let _ = save.rival_name(); + let _ = save.money(); + let _ = save.money_lossy(); + let _ = save.coins(); + let _ = save.coins_lossy(); + let _ = save.player_id(); + let _ = save.badges(); + let _ = save.options(); + let _ = save.text_speed(); + let _ = save.battle_animations_off(); + let _ = save.battle_style_set(); + + for dex in 1..=151u8 { + let _ = save.dex_owned(dex); + let _ = save.dex_seen(dex); + } + let _ = save.owned_count(); + let _ = save.seen_count(); + + let _ = save.cur_map(); + let _ = save.cur_map_name(); + let _ = save.x_coord(); + let _ = save.y_coord(); + let _ = save.x_block_coord(); + let _ = save.y_block_coord(); + let _ = save.last_map(); + let _ = save.tileset(); + let _ = save.map_view_pointer(); + + for (_, _, value) in save.named_event_flags() { + let _ = value; + } + let _ = save.game_progress_flags(); +} + +/// The fuzz entry point: parse `data`, walk everything, then exercise +/// the mutating paths driven by file-derived values on clones. +pub fn exercise(data: &[u8]) { + let Ok(save) = SaveFile::from_bytes(data.to_vec()) else { + // Too short: the only rejection; nothing further to check. + return; + }; + + walk(&save); + + // Untouched files round-trip byte-identically — even hostile ones. + assert_eq!(save.to_bytes(), data, "untouched round-trip"); + + // fix_checksums must produce a buffer that reloads warning-free at + // the checksum level and never panics on re-walk. + let mut fixed = SaveFile::from_bytes(data.to_vec()).expect("length already accepted"); + fixed.fix_checksums(); + let reloaded = SaveFile::from_bytes(fixed.to_bytes()).expect("serialized length is valid"); + walk(&reloaded); + + // Transfers consume file-derived counts and species; drive one + // deposit and one withdraw per box on a clone (failures are fine, + // panics are not). + let mut edited = SaveFile::from_bytes(data.to_vec()).expect("length already accepted"); + for n in 0..offsets::NUM_BOXES { + let _ = edited.deposit(0, n); + let _ = edited.withdraw(n, 0); + } + let mut party = edited.party_mut(); + for i in 0..party.as_view().len() { + party.mon_mut(i).recalculate_stats(); + } + walk(&edited); + let _ = edited.to_bytes(); +} diff --git a/crates/pksave/src/lib.rs b/crates/pksave/src/lib.rs index fcc4e61..e87a7d0 100644 --- a/crates/pksave/src/lib.rs +++ b/crates/pksave/src/lib.rs @@ -18,6 +18,9 @@ pub mod gen1; +#[doc(hidden)] +pub mod fuzz_support; + /// Severity of a [`Diagnostic`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Severity {