use crate::{city::safe_id, protocol::RoomSave}; use prost::Message; use std::{ fs, io::Write, path::{Path, PathBuf}, }; pub fn save(root: &Path, snapshot: &RoomSave, keep: usize) -> Result<(), String> { if !safe_id(&snapshot.id) { return Err("Invalid room ID".into()); } let folder = root.join(&snapshot.id); fs::create_dir_all(&folder).map_err(|e| e.to_string())?; let name = format!("{:020}.pb", snapshot.tick); let file = folder.join(&name); // Replace through a complete temporary file, including control changes that // happened between two ticks. Never truncate the last committed snapshot. { let temporary = folder.join(format!("{name}.tmp")); let mut output = fs::File::create(&temporary).map_err(|e| e.to_string())?; output .write_all(&snapshot.encode_to_vec()) .map_err(|e| e.to_string())?; output.sync_all().map_err(|e| e.to_string())?; drop(output); fs::rename(temporary, file).map_err(|e| e.to_string())?; } let mut files = snapshots(&folder)?; files.sort(); let excess = files.len().saturating_sub(keep.max(1)); for path in files.into_iter().take(excess) { fs::remove_file(path).map_err(|e| e.to_string())?; } Ok(()) } fn snapshots(folder: &Path) -> Result, String> { Ok(fs::read_dir(folder) .map_err(|e| e.to_string())? .filter_map(Result::ok) .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|e| e == "pb")) .collect()) } pub fn load(root: &Path, id: &str) -> Result { if !safe_id(id) { return Err("Invalid room ID".into()); } let mut files = snapshots(&root.join(id))?; files.sort(); files.reverse(); // Try an older intact file if the latest is damaged. for file in files { if let Ok(bytes) = fs::read(file) { if let Ok(snapshot) = RoomSave::decode(&bytes[..]) { if snapshot.id != id { continue; } if snapshot.version != 1 || snapshot.city_version != 1 { return Err("Incompatible room snapshot".into()); } return Ok(snapshot); } } } Err("No compatible room snapshot".into()) } #[cfg(test)] mod tests { use super::*; #[test] fn atomic_save_rotation_and_corrupt_fallback() { let dir = tempfile::tempdir().unwrap(); let mut s = RoomSave { version: 1, city_version: 1, id: "r".into(), ..Default::default() }; for tick in 0..5 { s.tick = tick; save(dir.path(), &s, 3).unwrap(); } assert_eq!(snapshots(&dir.path().join("r")).unwrap().len(), 3); assert_eq!(load(dir.path(), "r").unwrap().tick, 4); s.speed = 4; save(dir.path(), &s, 3).unwrap(); assert_eq!(load(dir.path(), "r").unwrap().speed, 4); fs::write(dir.path().join("r/00000000000000000004.pb"), [255, 255]).unwrap(); assert_eq!(load(dir.path(), "r").unwrap().tick, 3); assert!(load(dir.path(), "../secret").is_err()); } }