diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f10cf6f8..f9db2d28 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1065,6 +1065,7 @@ dependencies = [ "db_entity", "error", "matchmaking", + "rand 0.8.6", "regex", "sea-orm", "serde", diff --git a/backend/modules/chess/Cargo.toml b/backend/modules/chess/Cargo.toml index 9301cda9..13b24cb1 100644 --- a/backend/modules/chess/Cargo.toml +++ b/backend/modules/chess/Cargo.toml @@ -13,3 +13,6 @@ db_entity = { path = "../db/entity" } error = { path = "../error" } matchmaking = { path = "../matchmaking" } serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +rand = "0.8" diff --git a/backend/modules/chess/src/fen_validator.rs b/backend/modules/chess/src/fen_validator.rs new file mode 100644 index 00000000..4cbded25 --- /dev/null +++ b/backend/modules/chess/src/fen_validator.rs @@ -0,0 +1,461 @@ +//! Strict FEN legality validation. +//! +//! Endpoints that accept caller-supplied FEN strings (puzzle setup, game +//! restoration, custom challenges) must never hand a malformed or illegal +//! position to the engine — that's how you get crashes / panics deep in +//! move generation. This module rejects those positions up front with a +//! specific, descriptive error instead. +//! +//! Rather than re-implement position legality (king counts, checks, +//! castling/en-passant geometry, ...) by hand, this wraps [`shakmaty`]'s +//! FEN parser and [`Position::from_setup`] legality checks, which are +//! already exercised against Stockfish/Lichess-compatible rules elsewhere +//! in this crate (see `pgn.rs`). That keeps this validator small while +//! still covering every rule in the ticket. + +use shakmaty::fen::{Fen, ParseFenError}; +use shakmaty::{CastlingMode, Chess, FromSetup, PositionError, PositionErrorKinds}; + +/// Reasons a FEN string may be rejected by [`validate_fen_legality`]. +/// +/// Every variant carries a stable [`FenValidationError::code`] suitable for +/// API error responses, in addition to a human-readable [`Display`] message. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum FenValidationError { + /// The FEN could not be parsed at all: wrong number of fields, a + /// malformed board section, bad rank counts, non-digit/non-piece + /// characters, etc. + #[error("malformed FEN: {0}")] + MalformedFen(String), + + /// The active color field was present but was neither `w` nor `b`. + #[error("active color must be 'w' or 'b'")] + InvalidActiveColor, + + /// A side has no king. + #[error("each side must have exactly one king")] + MissingKing, + + /// A side has more than one king. + #[error("each side must have exactly one king")] + TooManyKings, + + /// A pawn is on rank 1 or rank 8. + #[error("pawns cannot be placed on rank 1 or rank 8")] + PawnsOnBackRank, + + /// A side has more pieces than are reachable through any sequence of + /// legal moves (more than 16 total, or a promotion count inconsistent + /// with the number of missing pawns). + #[error("a side has more pieces than are reachable through legal play")] + TooMuchMaterial, + + /// Castling rights don't match the actual king/rook placement. + #[error("castling rights do not match king/rook placement")] + InvalidCastlingRights, + + /// The en-passant square isn't geometrically valid for this position + /// (wrong rank, occupied, or no pawn present to have made the double + /// push that would allow it). + #[error("en passant square is not valid for this position")] + InvalidEnPassantSquare, + + /// The side NOT to move is in check — only the side to move may be. + #[error("the side not to move may not be in check")] + OppositeCheck, + + /// The position implies an impossible check configuration (too many + /// simultaneous checkers, misaligned sliding checkers, or a checker + /// that contradicts the en-passant square). + #[error("position implies an impossible check configuration")] + ImpossibleCheck, + + /// A variant-specific rule was violated. + #[error("position violates a variant-specific rule")] + VariantRuleViolated, +} + +impl FenValidationError { + /// A stable, machine-readable error code — safe to expose over an API. + pub fn code(&self) -> &'static str { + match self { + Self::MalformedFen(_) => "MALFORMED_FEN", + Self::InvalidActiveColor => "INVALID_ACTIVE_COLOR", + Self::MissingKing => "MISSING_KING", + Self::TooManyKings => "TOO_MANY_KINGS", + Self::PawnsOnBackRank => "PAWNS_ON_BACKRANK", + Self::TooMuchMaterial => "TOO_MUCH_MATERIAL", + Self::InvalidCastlingRights => "INVALID_CASTLING_RIGHTS", + Self::InvalidEnPassantSquare => "INVALID_EN_PASSANT_SQUARE", + Self::OppositeCheck => "OPPOSITE_CHECK", + Self::ImpossibleCheck => "IMPOSSIBLE_CHECK", + Self::VariantRuleViolated => "VARIANT_RULE_VIOLATED", + } + } +} + +impl From for FenValidationError { + fn from(err: ParseFenError) -> Self { + match err { + ParseFenError::InvalidTurn => FenValidationError::InvalidActiveColor, + other => FenValidationError::MalformedFen(other.to_string()), + } + } +} + +impl From> for FenValidationError { + fn from(err: PositionError) -> Self { + let kinds = err.kinds(); + + // A single illegal position can trip several of shakmaty's checks + // at once (e.g. a missing king also reads as "empty-ish" material). + // Walk them in a fixed priority so the caller always gets one + // specific, stable code rather than a nondeterministic pick. + if kinds.contains(PositionErrorKinds::MISSING_KING) + || kinds.contains(PositionErrorKinds::EMPTY_BOARD) + { + FenValidationError::MissingKing + } else if kinds.contains(PositionErrorKinds::TOO_MANY_KINGS) { + FenValidationError::TooManyKings + } else if kinds.contains(PositionErrorKinds::PAWNS_ON_BACKRANK) { + FenValidationError::PawnsOnBackRank + } else if kinds.contains(PositionErrorKinds::TOO_MUCH_MATERIAL) { + FenValidationError::TooMuchMaterial + } else if kinds.contains(PositionErrorKinds::OPPOSITE_CHECK) { + FenValidationError::OppositeCheck + } else if kinds.contains(PositionErrorKinds::INVALID_CASTLING_RIGHTS) { + FenValidationError::InvalidCastlingRights + } else if kinds.contains(PositionErrorKinds::INVALID_EP_SQUARE) { + FenValidationError::InvalidEnPassantSquare + } else if kinds.contains(PositionErrorKinds::IMPOSSIBLE_CHECK) { + FenValidationError::ImpossibleCheck + } else { + FenValidationError::VariantRuleViolated + } + } +} + +/// Validate that `fen` is both syntactically well-formed and describes a +/// legally reachable chess position. +/// +/// Checks performed (see [`FenValidationError`] for the specific codes): +/// - exactly one king per side +/// - no pawns on rank 1 or rank 8 +/// - no more than 16 pieces per side, with promotions consistent with +/// missing pawns +/// - the side *not* to move is not in check +/// - castling rights match actual king/rook placement +/// - the en-passant square (if any) is geometrically valid +/// - the active color field is exactly `w` or `b` +/// +/// On success, returns the parsed [`Chess`] position so callers don't need +/// to re-parse the FEN. This function never panics, regardless of how +/// garbled the input is — every failure mode is reported as a +/// [`FenValidationError`]. +pub fn validate_fen_legality(fen: &str) -> Result { + let parsed: Fen = fen.parse().map_err(FenValidationError::from)?; + Chess::from_setup(parsed.into_setup(), CastlingMode::Standard).map_err(FenValidationError::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + const STARTING_POSITION: &str = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + + fn assert_rejected(fen: &str, expected: FenValidationError) { + match validate_fen_legality(fen) { + Ok(_) => panic!("expected {fen:?} to be rejected as {expected:?}, but it was accepted"), + Err(actual) => assert_eq!( + actual, expected, + "fen {fen:?} rejected for the wrong reason" + ), + } + } + + // --------------------------------------------------------------- + // Valid positions parse cleanly. + // --------------------------------------------------------------- + + #[test] + fn accepts_starting_position() { + assert!(validate_fen_legality(STARTING_POSITION).is_ok()); + } + + #[test] + fn accepts_position_with_black_to_move() { + assert!(validate_fen_legality( + "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1" + ) + .is_ok()); + } + + #[test] + fn accepts_valid_en_passant_square() { + // White just pushed e2-e4, so e3 is a legal en-passant target. + assert!(validate_fen_legality( + "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" + ) + .is_ok()); + } + + #[test] + fn accepts_position_with_no_castling_rights() { + assert!(validate_fen_legality("4k3/8/8/8/8/8/8/4K3 w - - 0 1").is_ok()); + } + + #[test] + fn accepts_endgame_with_single_pawn() { + assert!(validate_fen_legality("8/8/8/4k3/8/8/4P3/4K3 w - - 0 1").is_ok()); + } + + // --------------------------------------------------------------- + // Invalid positions are rejected with the specific expected code. + // (>= 20 distinct invalid FEN edge cases, per the acceptance criteria.) + // --------------------------------------------------------------- + + #[test] + fn rejects_empty_string() { + assert!(matches!( + validate_fen_legality(""), + Err(FenValidationError::MalformedFen(_)) + )); + } + + #[test] + fn rejects_completely_garbage_input() { + assert!(matches!( + validate_fen_legality("not a fen at all!! \0\0\0"), + Err(FenValidationError::MalformedFen(_)) + )); + } + + #[test] + fn rejects_random_unicode_garbage_without_panicking() { + assert!(validate_fen_legality("♞♞♞♞♞♞♞♞/🎉🎉🎉🎉🎉🎉🎉🎉/8/8/8/8/8/8 w - - 0 1").is_err()); + } + + #[test] + fn rejects_too_few_ranks() { + assert!(matches!( + validate_fen_legality("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP w KQkq - 0 1"), + Err(FenValidationError::MalformedFen(_)) + )); + } + + #[test] + fn rejects_too_many_ranks() { + // 9 slashes (10 rank groups). Note: exactly 8 slashes in the board + // field is a documented shakmaty extension for an appended Crazyhouse + // pocket, so this needs one rank group beyond even that to reliably + // fail as malformed rather than being reinterpreted. + assert!(matches!( + validate_fen_legality("8/8/rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"), + Err(FenValidationError::MalformedFen(_)) + )); + } + + #[test] + fn rejects_rank_with_too_many_squares() { + assert!(matches!( + validate_fen_legality("9/8/8/8/8/8/8/4K2k w - - 0 1"), + Err(FenValidationError::MalformedFen(_)) + )); + } + + #[test] + fn rejects_rank_with_too_few_squares() { + assert!(matches!( + validate_fen_legality("6/8/8/8/8/8/8/4K2k w - - 0 1"), + Err(FenValidationError::MalformedFen(_)) + )); + } + + #[test] + fn rejects_invalid_piece_character() { + assert!(matches!( + validate_fen_legality("xxxxxxxx/8/8/8/8/8/8/4K2k w - - 0 1"), + Err(FenValidationError::MalformedFen(_)) + )); + } + + #[test] + fn rejects_missing_active_color() { + // Missing turn field defaults to white in shakmaty's lenient EPD + // parsing, so exercise outright invalid tokens instead below; this + // case just documents that a totally empty FEN is still malformed. + assert!(matches!( + validate_fen_legality(" "), + Err(FenValidationError::MalformedFen(_)) + )); + } + + #[test] + fn rejects_invalid_active_color_token() { + assert_eq!( + validate_fen_legality("4k3/8/8/8/8/8/8/4K3 x - - 0 1").unwrap_err(), + FenValidationError::InvalidActiveColor + ); + } + + #[test] + fn rejects_numeric_active_color_token() { + assert_eq!( + validate_fen_legality("4k3/8/8/8/8/8/8/4K3 1 - - 0 1").unwrap_err(), + FenValidationError::InvalidActiveColor + ); + } + + #[test] + fn rejects_uppercase_active_color_token() { + assert_eq!( + validate_fen_legality("4k3/8/8/8/8/8/8/4K3 W - - 0 1").unwrap_err(), + FenValidationError::InvalidActiveColor + ); + } + + #[test] + fn rejects_position_with_no_kings() { + assert_rejected("8/8/8/8/8/8/8/8 w - - 0 1", FenValidationError::MissingKing); + } + + #[test] + fn rejects_position_missing_white_king() { + assert_rejected( + "4k3/8/8/8/8/8/8/8 w - - 0 1", + FenValidationError::MissingKing, + ); + } + + #[test] + fn rejects_position_missing_black_king() { + assert_rejected( + "8/8/8/8/8/8/8/4K3 w - - 0 1", + FenValidationError::MissingKing, + ); + } + + #[test] + fn rejects_two_white_kings() { + assert_rejected( + "4k3/8/8/8/8/8/8/3KK3 w - - 0 1", + FenValidationError::TooManyKings, + ); + } + + #[test] + fn rejects_two_black_kings() { + assert_rejected( + "3kk3/8/8/8/8/8/8/4K3 w - - 0 1", + FenValidationError::TooManyKings, + ); + } + + #[test] + fn rejects_pawn_on_rank_eight() { + assert_rejected( + "4P3/8/8/8/8/8/8/4Kk2 w - - 0 1", + FenValidationError::PawnsOnBackRank, + ); + } + + #[test] + fn rejects_pawn_on_rank_one() { + assert_rejected( + "4Kk2/8/8/8/8/8/8/4p3 w - - 0 1", + FenValidationError::PawnsOnBackRank, + ); + } + + #[test] + fn rejects_black_pawn_on_rank_one() { + assert_rejected( + "4Kk2/8/8/8/8/8/8/3ppp2 w - - 0 1", + FenValidationError::PawnsOnBackRank, + ); + } + + #[test] + fn rejects_nine_pawns_for_one_side() { + assert_rejected( + "4k3/pppppppp/p7/8/8/8/8/4K3 w - - 0 1", + FenValidationError::TooMuchMaterial, + ); + } + + #[test] + fn rejects_too_many_queens_for_material_available() { + // 9 queens plus all 8 pawns still on the board is impossible for + // white — every extra queen beyond the original one requires a + // pawn to have been sacrificed via promotion. + assert_rejected( + "4k3/QQQQQQQQ/8/8/8/8/PPPPPPPP/QK6 w - - 0 1", + FenValidationError::TooMuchMaterial, + ); + } + + #[test] + fn rejects_side_not_to_move_in_check() { + // It is white to move, but black's king sits in open check from a + // white rook down the e-file — black (not to move) must not be in + // check. + assert_rejected( + "4k3/8/8/8/4R3/8/8/4K3 w - - 0 1", + FenValidationError::OppositeCheck, + ); + } + + #[test] + fn rejects_castling_rights_without_rook() { + assert_rejected( + "r3k3/8/8/8/8/8/8/4K3 w KQ - 0 1", + FenValidationError::InvalidCastlingRights, + ); + } + + #[test] + fn rejects_castling_rights_with_king_not_on_home_square() { + assert_rejected( + "r3k3/8/8/8/8/8/8/3K4 w Q - 0 1", + FenValidationError::InvalidCastlingRights, + ); + } + + #[test] + fn rejects_en_passant_square_on_wrong_rank() { + assert_rejected( + "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e6 0 1", + FenValidationError::InvalidEnPassantSquare, + ); + } + + #[test] + fn rejects_en_passant_square_with_no_pawn_present() { + assert_rejected( + "4k3/8/8/8/8/8/8/4K3 b - e3 0 1", + FenValidationError::InvalidEnPassantSquare, + ); + } + + #[test] + fn rejects_extra_trailing_field() { + assert!(matches!( + validate_fen_legality("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 extra"), + Err(FenValidationError::MalformedFen(_)) + )); + } + + #[test] + fn error_code_is_stable_and_machine_readable() { + assert_eq!(FenValidationError::MissingKing.code(), "MISSING_KING"); + assert_eq!(FenValidationError::TooManyKings.code(), "TOO_MANY_KINGS"); + assert_eq!( + FenValidationError::InvalidActiveColor.code(), + "INVALID_ACTIVE_COLOR" + ); + assert_eq!( + FenValidationError::PawnsOnBackRank.code(), + "PAWNS_ON_BACKRANK" + ); + } +} diff --git a/backend/modules/chess/src/lib.rs b/backend/modules/chess/src/lib.rs index 82d1c674..3897a942 100644 --- a/backend/modules/chess/src/lib.rs +++ b/backend/modules/chess/src/lib.rs @@ -1,12 +1,13 @@ pub mod bitboard; +pub mod fen_validator; pub mod mandatory_draw; pub mod pgn; pub mod rating; pub mod time_control; +pub use fen_validator::{validate_fen_legality, FenValidationError}; pub use mandatory_draw::{ - check_mandatory_draw_conditions, update_position_tracker, MandatoryDrawResult, - PositionTracker, + check_mandatory_draw_conditions, update_position_tracker, MandatoryDrawResult, PositionTracker, }; pub use pgn::{ parse_pgn, validate_game, GameResult as PgnGameResult, ParsedGame, PgnError, PgnHeaders, diff --git a/backend/modules/chess/src/mandatory_draw.rs b/backend/modules/chess/src/mandatory_draw.rs index 03be66a4..3d812cfb 100644 --- a/backend/modules/chess/src/mandatory_draw.rs +++ b/backend/modules/chess/src/mandatory_draw.rs @@ -37,7 +37,8 @@ impl PositionTracker { /// Record a position and check for repetition /// Returns the count of times this position has occurred pub fn record_position(&mut self, position_hash: &str) -> u32 { - let count = self.position_counts + let count = self + .position_counts .entry(position_hash.to_string()) .and_modify(|c| *c += 1) .or_insert(1); diff --git a/backend/modules/chess/src/pgn.rs b/backend/modules/chess/src/pgn.rs index b2380f27..fe8042e4 100644 --- a/backend/modules/chess/src/pgn.rs +++ b/backend/modules/chess/src/pgn.rs @@ -175,10 +175,16 @@ fn parse_moves(move_text: &str) -> Vec { // `10...Nf6`), not just the spaced form (`1. e4`). let move_number_prefix = Regex::new(r"^\d+\.+").unwrap(); let result_regex = Regex::new(r"^(1-0|0-1|1/2-1/2|\*)$").unwrap(); + // Trailing NAG-style move-quality annotations glued directly onto the + // move (e.g. "Bb5!", "Qh5??", "e4!?") are standard, widely-produced PGN + // syntax, but aren't part of SAN itself — strip them so downstream SAN + // parsing sees a clean move token. + let annotation_suffix = Regex::new(r"[!?]{1,2}$").unwrap(); tokens .into_iter() .map(|token| move_number_prefix.replace(token, "").into_owned()) + .map(|token| annotation_suffix.replace(&token, "").into_owned()) .filter(|token| !token.is_empty() && !result_regex.is_match(token)) .collect() } @@ -797,13 +803,25 @@ mod tests { #[test] fn test_export_pgn_check_suffix_non_mating() { // A mid-game check that is not mate should get "+", never "#". + // 1.e4 c5 2.Nf3 d6 3.Bb5+ — the classic Rossolimo/Moscow check: by + // move 3 both c6 and d7 are vacated, so the bishop's diagonal to e8 + // is open (unlike e.g. 1.e4 e6 2.Bb5, which stays blocked by the + // still-present d7 pawn). let moves = vec![ MoveAnnotation { san: "e4".to_string(), ..Default::default() }, MoveAnnotation { - san: "e6".to_string(), + san: "c5".to_string(), + ..Default::default() + }, + MoveAnnotation { + san: "Nf3".to_string(), + ..Default::default() + }, + MoveAnnotation { + san: "d6".to_string(), ..Default::default() }, MoveAnnotation { diff --git a/backend/modules/chess/src/rating.rs b/backend/modules/chess/src/rating.rs index f8e718a9..9d6d8176 100644 --- a/backend/modules/chess/src/rating.rs +++ b/backend/modules/chess/src/rating.rs @@ -52,7 +52,8 @@ impl RatingService { /// * `Err(ApiError)` - If game not found, players not found, or database error /// /// # Example - /// ```rust + /// ```ignore + /// // Illustrative only — `db` and `game_id` come from the caller's context. /// let config = RatingConfig::default(); /// let (white_rating, black_rating) = RatingService::update_ratings_after_game( /// &db, diff --git a/backend/modules/chess/tests/fen_fuzz.rs b/backend/modules/chess/tests/fen_fuzz.rs new file mode 100644 index 00000000..5cf4ae5d --- /dev/null +++ b/backend/modules/chess/tests/fen_fuzz.rs @@ -0,0 +1,153 @@ +//! Fuzz test for `validate_fen_legality`. +//! +//! Acceptance criteria (issue #1000 / BE-34): zero server panics or +//! unhandled `unwrap` crashes on fuzz-generated invalid FEN strings. This +//! throws a large, deterministic (fixed-seed, so CI is reproducible rather +//! than occasionally flaky) mix of pure-random garbage and mutated +//! near-valid FENs at the validator and asserts only that it never panics — +//! whether a given string is accepted or rejected is exercised separately +//! by the unit tests in `fen_validator.rs`. + +use chess::validate_fen_legality; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::time::Instant; + +const SEED: u64 = 0xFEED_5EED_5EED_5EEDu64; +const RANDOM_ITERATIONS: usize = 5_000; +const MUTATION_ITERATIONS: usize = 5_000; + +const SEED_FENS: &[&str] = &[ + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", + "8/8/8/4k3/8/8/4P3/4K3 w - - 0 1", + "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", +]; + +/// Random printable-ish bytes, including characters common in FEN (digits, +/// piece letters, slash, space, dash) but with no structural guarantees at +/// all — this is the "completely garbage" half of the fuzz corpus. +fn random_fen_like_string(rng: &mut StdRng) -> String { + const ALPHABET: &[u8] = b"pnbrqkPNBRQK12345678/ w b KQkqabcdefgh-\0\t\n\"'\\<>{}[]~"; + let len = rng.gen_range(0..=80); + (0..len) + .map(|_| { + let idx = rng.gen_range(0..ALPHABET.len()); + ALPHABET[idx] as char + }) + .collect() +} + +/// Take a real, valid FEN and randomly mutate it (delete/insert/replace a +/// handful of characters). This targets the "almost legal" edge cases that +/// pure noise is unlikely to hit — off-by-one rank counts, dangling +/// castling flags, corrupted en-passant squares, etc. +fn mutate_fen(rng: &mut StdRng, base: &str) -> String { + let mut chars: Vec = base.chars().collect(); + let mutations = rng.gen_range(1..=4); + + for _ in 0..mutations { + if chars.is_empty() { + break; + } + match rng.gen_range(0..3) { + 0 => { + // Replace a random character with a random byte from a + // small "plausible but wrong" alphabet. + let idx = rng.gen_range(0..chars.len()); + const REPLACEMENTS: &[char] = &[ + 'p', 'n', 'b', 'r', 'q', 'k', 'P', 'N', 'B', 'R', 'Q', 'K', '/', ' ', '-', '9', + '0', 'x', 'w', 'b', 'W', 'B', + ]; + chars[idx] = REPLACEMENTS[rng.gen_range(0..REPLACEMENTS.len())]; + } + 1 => { + // Delete a random character. + let idx = rng.gen_range(0..chars.len()); + chars.remove(idx); + } + _ => { + // Insert a random character. + let idx = rng.gen_range(0..=chars.len()); + const INSERTS: &[char] = &['p', 'K', '/', ' ', '8', '-', 'z']; + chars.insert(idx, INSERTS[rng.gen_range(0..INSERTS.len())]); + } + } + } + + chars.into_iter().collect() +} + +#[test] +fn validate_fen_legality_never_panics_on_fuzzed_input() { + let mut rng = StdRng::seed_from_u64(SEED); + let mut failures: Vec = Vec::new(); + + let start = Instant::now(); + + for _ in 0..RANDOM_ITERATIONS { + let candidate = random_fen_like_string(&mut rng); + let result = catch_unwind(AssertUnwindSafe(|| validate_fen_legality(&candidate))); + if result.is_err() { + failures.push(candidate); + } + } + + for _ in 0..MUTATION_ITERATIONS { + let base = SEED_FENS[rng.gen_range(0..SEED_FENS.len())]; + let candidate = mutate_fen(&mut rng, base); + let result = catch_unwind(AssertUnwindSafe(|| validate_fen_legality(&candidate))); + if result.is_err() { + failures.push(candidate); + } + } + + let elapsed = start.elapsed(); + + assert!( + failures.is_empty(), + "validate_fen_legality panicked on {} of {} fuzzed inputs, e.g. {:?}", + failures.len(), + RANDOM_ITERATIONS + MUTATION_ITERATIONS, + &failures[..failures.len().min(5)], + ); + + // Generous ceiling (not a tight benchmark) just to catch an accidental + // quadratic blowup; CI hardware varies a lot, so this is not a tight + // performance assertion. + assert!( + elapsed.as_secs() < 10, + "fuzzing {} inputs took {:?}, which is suspiciously slow", + RANDOM_ITERATIONS + MUTATION_ITERATIONS, + elapsed + ); +} + +#[test] +fn validate_fen_legality_never_panics_on_pathological_strings() { + let pathological: &[&str] = &[ + "", + " ", + "/", + "////////", + "8/8/8/8/8/8/8/8", + "8/8/8/8/8/8/8/8 w - - 0 1", + &"p".repeat(10_000), + &"/".repeat(10_000), + &"8/".repeat(1_000), + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq e9 0 1", + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w -------- 0 1", + "\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}", + "🎉🎉🎉🎉🎉🎉🎉🎉/8/8/8/8/8/8/8 w - - 0 1", + "RNBQKBNR/PPPPPPPP/8/8/8/8/pppppppp/rnbqkbnr w KQkq - 0 1", + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - -1 1", + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 99999999999999999999 1", + ]; + + for fen in pathological { + let result = catch_unwind(AssertUnwindSafe(|| validate_fen_legality(fen))); + assert!(result.is_ok(), "validate_fen_legality panicked on {fen:?}"); + } +}