//! Persistence fault-injection gates (TRAFFIC-11). //! //! Serialize/write/flush/sync/rename faults must preserve the previous //! profile and surface failure. Corrupt/future data enters recovery or //! read-only state, never silent empty defaults. Learner data is anonymous //! by default and bounded (8 MiB, 10k attempts). use nigig_traffic::traffic::persistence::{ save_atomic, AttemptRecord, LearnerProfile, LoadOutcome, MAX_PROFILE_BYTES, }; use std::path::PathBuf; fn dir(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("nigig-traffic-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); d } fn attempt(id: &str, score: i32, digest: u64) -> AttemptRecord { AttemptRecord { scenario_id: id.into(), content_version: 0, ticks: 600, mistakes: 1, score, max_points: 100, passed: true, replay_digest: digest, } } #[test] fn save_then_faulty_overwrite_preserves_previous_profile() { let d = dir("faults"); let mut p = LearnerProfile::new("anon-f".into()); p.record(attempt("lane_straight", 90, 7)); save_atomic(&d, &p).expect("initial save"); // Fault: clobber the file with garbage (simulates a torn write that // fsync/rename did not protect). Load must report Corrupt with bytes // preserved — never an empty profile that looks like a fresh learner. let profile_path = d.join("learner_profile_v1.json"); std::fs::write(&profile_path, b"\xff\xfe torn write").expect("clobber"); assert_eq!( nigig_traffic::traffic::persistence::load(&d), LoadOutcome::Corrupt { bytes_preserved: true } ); assert!(profile_path.exists(), "corrupt bytes must be preserved"); let _ = std::fs::remove_dir_all(&d); } #[test] fn future_schema_is_read_only_not_default() { let d = dir("future"); std::fs::create_dir_all(&d).expect("mkdir"); std::fs::write( d.join("learner_profile_v1.json"), b"{\"schema\":99,\"learner_id\":\"a\",\"attempts\":[]}", ) .expect("write"); assert_eq!( nigig_traffic::traffic::persistence::load(&d), LoadOutcome::FutureVersion { found: 99 } ); let _ = std::fs::remove_dir_all(&d); } #[test] fn oversize_profile_is_rejected_before_alloc() { let d = dir("oversize"); std::fs::create_dir_all(&d).expect("mkdir"); // One byte over the bound, otherwise well-formed. let mut bytes = vec![b' '; (MAX_PROFILE_BYTES + 1) as usize]; bytes[0] = b'{'; std::fs::write(d.join("learner_profile_v1.json"), &bytes).expect("write"); assert_eq!( nigig_traffic::traffic::persistence::load(&d), LoadOutcome::Corrupt { bytes_preserved: true } ); let _ = std::fs::remove_dir_all(&d); } #[test] fn malformed_attempts_fail_closed() { let d = dir("malformed"); std::fs::create_dir_all(&d).expect("mkdir"); std::fs::write( d.join("learner_profile_v1.json"), b"{\"schema\":1,\"learner_id\":\"a\",\"attempts\":[{\"id\":\"x\"}]}", ) .expect("write"); assert_eq!( nigig_traffic::traffic::persistence::load(&d), LoadOutcome::Corrupt { bytes_preserved: true } ); let _ = std::fs::remove_dir_all(&d); } #[test] fn traversal_ids_rejected() { let d = dir("traversal"); std::fs::create_dir_all(&d).expect("mkdir"); std::fs::write( d.join("learner_profile_v1.json"), b"{\"schema\":1,\"learner_id\":\"../evil\",\"attempts\":[]}", ) .expect("write"); assert_eq!( nigig_traffic::traffic::persistence::load(&d), LoadOutcome::Corrupt { bytes_preserved: true } ); let _ = std::fs::remove_dir_all(&d); } #[test] fn compaction_bounds_attempts_at_10k() { let mut p = LearnerProfile::new("anon-c".into()); for i in 0..10_005 { p.record(attempt("s", 10, i)); } assert!(p.attempts.len() <= 10_000, "len {}", p.attempts.len()); } #[test] fn learners_are_isolated_by_profile() { let da = dir("iso-a"); let db = dir("iso-b"); let mut pa = LearnerProfile::new("anon-a".into()); pa.record(attempt("lane_straight", 100, 1)); let pb = LearnerProfile::new("anon-b".into()); save_atomic(&da, &pa).expect("save a"); save_atomic(&db, &pb).expect("save b"); match nigig_traffic::traffic::persistence::load(&db) { LoadOutcome::Loaded(got) => assert_eq!(got.best("lane_straight"), None), other => panic!("unexpected {other:?}"), } let _ = std::fs::remove_dir_all(&da); let _ = std::fs::remove_dir_all(&db); }