//! The state file: a first start, surviving a restart, its limits, and refusing a damaged file //! instead of guessing (M4a spec, section 9). Do not edit. #[path = "support/tmp.rs"] mod tmp; use std::os::unix::fs::PermissionsExt; use gatewayd::state::{InFlight, RECENT_KEPT, State, StateError, THREADS_KEPT}; use tmp::TempDir; const CHAN: &str = "c0000000000000000000000000"; const DM: &str = "d0000000000000000000000000"; fn id(n: usize) -> String { format!("p{n:025}") } fn turn(n: usize) -> InFlight { InFlight { session: format!("mm-{}", id(n)), channel: DM.to_string(), root: id(n), } } #[test] fn a_first_start_then_a_restart() { let dir = TempDir::new("state-restart"); let path = dir.path().join("gateway/state.json"); let mut s = State::load(&path).unwrap(); assert!(!path.exists(), "loading writes nothing"); assert_eq!( (s.since(CHAN), s.channels().len(), s.seen(&id(1))), (None, 0, false) ); s.handled(&id(1), CHAN, 2000).unwrap(); s.handled(&id(2), CHAN, 1500).unwrap(); s.mark(DM, 3000).unwrap(); s.mark(DM, 9000).unwrap(); s.join_thread(&id(1)).unwrap(); s.start_turn(turn(1)).unwrap(); s.start_turn(turn(2)).unwrap(); s.end_turn(&format!("mm-{}", id(2))).unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; let dir_mode = std::fs::metadata(path.parent().unwrap()) .unwrap() .permissions() .mode() & 0o777; assert_eq!((mode, dir_mode), (0o600, 0o700)); assert!(!dir.path().join("gateway/state.json.tmp").exists()); let mut again = State::load(&path).unwrap(); assert_eq!(again.since(CHAN), Some(2000), "the mark never moves back"); assert_eq!( again.since(DM), Some(3000), "mark only sets a channel without one" ); assert_eq!(again.channels(), [CHAN, DM]); assert!(again.seen(&id(1)) && again.seen(&id(2))); assert!(again.knows_thread(&id(1)) && !again.knows_thread(&id(2))); assert_eq!(again.take_in_flight().unwrap(), [turn(1)]); assert!( State::load(&path) .unwrap() .take_in_flight() .unwrap() .is_empty(), "taking is saved" ); } #[test] fn only_the_newest_posts_and_threads_are_kept() { let dir = TempDir::new("state-limits"); // A full file to start from: posts 0.. and threads 0.., at their limits. let recent: Vec = (0..RECENT_KEPT).map(id).collect(); let threads: Vec = (0..THREADS_KEPT).map(id).collect(); let full = serde_json::json!({"channels": {}, "recent": recent, "threads": threads, "in_flight": []}); let path = dir.write("state.json", &full.to_string()); let mut s = State::load(&path).unwrap(); s.handled(&id(RECENT_KEPT), CHAN, 1).unwrap(); s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap(); s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap(); s.join_thread(&id(THREADS_KEPT)).unwrap(); s.join_thread(&id(3)).unwrap(); let s = State::load(&path).unwrap(); assert!(!s.seen(&id(0)) && !s.seen(&id(1)) && s.seen(&id(2)) && s.seen(&id(RECENT_KEPT + 1))); assert!(!s.knows_thread(&id(0)) && s.knows_thread(&id(1)) && s.knows_thread(&id(THREADS_KEPT))); let text = std::fs::read_to_string(&path).unwrap(); let v: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!( v["recent"].as_array().unwrap().len(), RECENT_KEPT, "a repeat is not stored twice" ); assert_eq!(v["threads"].as_array().unwrap().len(), THREADS_KEPT); } #[test] fn a_damaged_file_stops_with_its_pointer() { let dir = TempDir::new("state-damaged"); let good_turn = r#"{"session":"mm-p0000000000000000000000001","channel":"d0000000000000000000000000","root":"p0000000000000000000000001"}"#; let cases = [ "".to_string(), "{".to_string(), "[]".to_string(), r#"{"channels":{},"recent":[],"threads":[]}"#.to_string(), r#"{"channels":{},"recent":[],"threads":[],"in_flight":[],"extra":1}"#.to_string(), r#"{"channels":{"../x":1},"recent":[],"threads":[],"in_flight":[]}"#.to_string(), r#"{"channels":{},"recent":["short"],"threads":[],"in_flight":[]}"#.to_string(), r#"{"channels":{},"recent":[],"threads":["P0000000000000000000000000"],"in_flight":[]}"# .to_string(), format!( r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#, good_turn.replace("mm-", "xx-") ), format!( r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#, good_turn.replace("\"d0", "\"D0") ), ]; for (n, text) in cases.iter().enumerate() { let path = dir.write(&format!("s{n}.json"), text); match State::load(&path) { Err(e @ StateError::Read(..)) => { let message = e.to_string(); assert!( message.starts_with(&path.display().to_string()), "{message}" ); assert!( message.ends_with("\nsee docs/runbook.md#gateway-state-damaged"), "{message}" ); } Err(e) => panic!("{text}: {e}"), Ok(_) => panic!("accepted: {text}"), } } let ok = dir.write( "ok.json", &format!( r#"{{"channels":{{"{CHAN}":5}},"recent":[],"threads":[],"in_flight":[{good_turn}]}}"# ), ); assert_eq!(State::load(&ok).unwrap().since(CHAN), Some(5)); std::fs::create_dir(dir.path().join("adir")).unwrap(); assert!(matches!( State::load(&dir.path().join("adir")), Err(StateError::Read(..)) )); } #[test] fn a_failed_write_is_an_error_and_keeps_the_old_file() { let dir = TempDir::new("state-readonly"); let sub = dir.path().join("gateway"); let path = sub.join("state.json"); let mut s = State::load(&path).unwrap(); s.handled(&id(1), CHAN, 5).unwrap(); let before = std::fs::read(&path).unwrap(); std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o500)).unwrap(); let got = s.handled(&id(2), CHAN, 6); std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap(); let e = got.unwrap_err(); assert!(matches!(e, StateError::Write(..)), "{e}"); assert!( e.to_string() .ends_with("see docs/runbook.md#gateway-state-damaged"), "{e}" ); assert_eq!(std::fs::read(&path).unwrap(), before); }