146 lines
4.6 KiB
Rust
146 lines
4.6 KiB
Rust
//! 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);
|
|
}
|