//! Integration tests for the wipe engine. //! //! Builds a temp directory tree that mirrors a Rust dedicated server layout //! and verifies each wipe type's targeting, the symlink-safety guarantee, //! backup behaviour, and graceful handling of missing directories. //! //! Symlink tests are POSIX-only (Unix creates symlinks; Windows needs elevated //! privileges or Developer Mode, so we skip there). #![cfg(unix)] use corrosion_host_agent::wipe::{execute, WipeRequest, WipeType}; use std::path::Path; use tempfile::TempDir; // --------------------------------------------------------------------------- // Helper: build a fake Rust server tree // // Layout: // / // server/ // myserver/ // proc.map // proc.sav // player.blueprints.1234.db // player.deaths.1234.db // player.identities.1234.db // player.states.1234.db // players.db // keepme.txt ← must survive every wipe // oxide/ // data/ // killfeed.json // another.json // server_readme.txt ← must survive every wipe // --------------------------------------------------------------------------- fn make_server_tree() -> TempDir { let dir = tempfile::tempdir().expect("create tempdir"); let root = dir.path(); let save_dir = root.join("server").join("myserver"); std::fs::create_dir_all(&save_dir).expect("create save dir"); std::fs::create_dir_all(root.join("oxide").join("data")).expect("create oxide/data"); // Save files write_file(&save_dir.join("proc.map"), b"map data"); write_file(&save_dir.join("proc.sav"), b"sav data"); write_file(&save_dir.join("player.blueprints.1234.db"), b"bp data"); write_file(&save_dir.join("player.deaths.1234.db"), b"deaths"); write_file(&save_dir.join("player.identities.1234.db"), b"identities"); write_file(&save_dir.join("player.states.1234.db"), b"states"); write_file(&save_dir.join("players.db"), b"player db"); // Innocent file — must never be deleted. write_file(&save_dir.join("keepme.txt"), b"keep me"); // oxide/data contents write_file(&root.join("oxide").join("data").join("killfeed.json"), b"{}"); write_file(&root.join("oxide").join("data").join("another.json"), b"{}"); // File at root level — must survive. write_file(&root.join("server_readme.txt"), b"readme"); dir } fn write_file(path: &Path, content: &[u8]) { std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); } fn wipe_req(wipe_type: WipeType) -> WipeRequest { WipeRequest { wipe_type, backup: false, backup_label: "test-backup".to_string(), } } fn exists(root: &Path, rel: &str) -> bool { root.join(rel).exists() } // --------------------------------------------------------------------------- // Map wipe: only *.map and *.sav deleted // --------------------------------------------------------------------------- #[test] fn map_wipe_deletes_map_and_sav_only() { let dir = make_server_tree(); let root = dir.path(); let result = execute(root, &wipe_req(WipeType::Map)).expect("map wipe should succeed"); // Deleted assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be gone"); assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be gone"); // Preserved assert!(exists(root, "server/myserver/player.blueprints.1234.db"), "blueprints must survive map wipe"); assert!(exists(root, "server/myserver/player.deaths.1234.db"), "deaths must survive map wipe"); assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive"); assert!(exists(root, "oxide/data/killfeed.json"), "oxide/data must survive map wipe"); assert!(exists(root, "server_readme.txt"), "server_readme.txt must survive"); assert_eq!(result.deleted_count, 2); assert_eq!(result.wipe_type, WipeType::Map); } // --------------------------------------------------------------------------- // Blueprint wipe: map/sav + blueprints deleted // --------------------------------------------------------------------------- #[test] fn blueprint_wipe_includes_map_files() { let dir = make_server_tree(); let root = dir.path(); let result = execute(root, &wipe_req(WipeType::Blueprint)).expect("blueprint wipe should succeed"); // Deleted assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be gone"); assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be gone"); assert!(!exists(root, "server/myserver/player.blueprints.1234.db"), "blueprints must be gone"); // Preserved assert!(exists(root, "server/myserver/player.deaths.1234.db"), "deaths must survive blueprint wipe"); assert!(exists(root, "server/myserver/player.identities.1234.db"), "identities must survive"); assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive"); assert!(exists(root, "oxide/data/killfeed.json"), "oxide/data must survive blueprint wipe"); assert_eq!(result.deleted_count, 3); assert_eq!(result.wipe_type, WipeType::Blueprint); } // --------------------------------------------------------------------------- // Full wipe: everything including player state + oxide/data // --------------------------------------------------------------------------- #[test] fn full_wipe_clears_all_game_data() { let dir = make_server_tree(); let root = dir.path(); let result = execute(root, &wipe_req(WipeType::Full)).expect("full wipe should succeed"); // All save-dir game files deleted assert!(!exists(root, "server/myserver/proc.map")); assert!(!exists(root, "server/myserver/proc.sav")); assert!(!exists(root, "server/myserver/player.blueprints.1234.db")); assert!(!exists(root, "server/myserver/player.deaths.1234.db")); assert!(!exists(root, "server/myserver/player.identities.1234.db")); assert!(!exists(root, "server/myserver/player.states.1234.db")); assert!(!exists(root, "server/myserver/players.db")); // oxide/data contents deleted (directory itself preserved) assert!(!exists(root, "oxide/data/killfeed.json"), "killfeed.json must be gone"); assert!(!exists(root, "oxide/data/another.json"), "another.json must be gone"); assert!(exists(root, "oxide/data"), "oxide/data directory itself must remain"); // Never-touched files preserved assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive full wipe"); assert!(exists(root, "server_readme.txt"), "server_readme.txt must survive full wipe"); // 7 save-dir files + 2 oxide/data files = 9 assert_eq!(result.deleted_count, 9); assert_eq!(result.wipe_type, WipeType::Full); } // --------------------------------------------------------------------------- // Missing directories: no error on fresh server // --------------------------------------------------------------------------- #[test] fn missing_server_dir_does_not_error() { let dir = tempfile::tempdir().expect("tempdir"); // Completely empty root — no server/ or oxide/ directories. let result = execute(dir.path(), &wipe_req(WipeType::Full)); assert!(result.is_ok(), "empty root must not error: {:?}", result); assert_eq!(result.unwrap().deleted_count, 0); } #[test] fn missing_oxide_data_does_not_error() { let dir = tempfile::tempdir().expect("tempdir"); // Has server dir but no oxide/data. let save_dir = dir.path().join("server").join("myserver"); std::fs::create_dir_all(&save_dir).expect("mkdir"); write_file(&save_dir.join("proc.map"), b"map"); let result = execute(dir.path(), &wipe_req(WipeType::Full)); assert!(result.is_ok(), "missing oxide/data must not error: {:?}", result); } // --------------------------------------------------------------------------- // Symlink safety: symlink inside root pointing outside must NOT be followed // --------------------------------------------------------------------------- #[test] fn symlink_in_save_dir_is_not_deleted_via_follow() { let dir = make_server_tree(); let root = dir.path(); // Create an external directory with sensitive data. let outside = tempfile::tempdir().expect("outside tempdir"); write_file(&outside.path().join("secret.txt"), b"TOP SECRET"); // Plant a symlink inside the save dir pointing to the external directory. let save_dir = root.join("server").join("myserver"); let link = save_dir.join("evil_link"); std::os::unix::fs::symlink(outside.path(), &link).expect("plant symlink"); // Perform a full wipe — should not follow the symlink or touch secret.txt let result = execute(root, &wipe_req(WipeType::Full)); assert!(result.is_ok(), "wipe with a symlink present must not error: {:?}", result); // External data must be untouched. assert!( outside.path().join("secret.txt").exists(), "external secret.txt must not be deleted via symlink follow" ); } #[test] fn symlink_at_identity_dir_level_is_skipped() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); std::fs::create_dir_all(root.join("server")).expect("mkdir server"); // The identity entry itself is a symlink to an external dir. let outside = tempfile::tempdir().expect("outside tempdir"); write_file(&outside.path().join("proc.map"), b"map"); let link = root.join("server").join("evil_identity"); std::os::unix::fs::symlink(outside.path(), &link).expect("plant identity symlink"); let result = execute(root, &wipe_req(WipeType::Map)); assert!(result.is_ok(), "symlink identity dir must be skipped, not error: {:?}", result); // The external proc.map must not have been deleted. assert!( outside.path().join("proc.map").exists(), "external proc.map must not be deleted via identity symlink" ); assert_eq!(result.unwrap().deleted_count, 0); } // --------------------------------------------------------------------------- // Backup: files are copied before deletion // --------------------------------------------------------------------------- #[test] fn backup_copies_targets_before_deletion() { let dir = make_server_tree(); let root = dir.path(); let req = WipeRequest { wipe_type: WipeType::Map, backup: true, backup_label: "before-map-wipe".to_string(), }; let result = execute(root, &req).expect("map wipe with backup should succeed"); // The files should be gone from the save dir… assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be deleted"); assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be deleted"); // …but must exist in the backup directory. let backup_base = root.join(".corrosion-backups").join("before-map-wipe"); assert!(backup_base.exists(), "backup directory must be created"); // Walk the backup to find the backed-up files. let backed_up = collect_files_recursively(&backup_base); let has_map = backed_up.iter().any(|p| p.ends_with("proc.map")); let has_sav = backed_up.iter().any(|p| p.ends_with("proc.sav")); assert!(has_map, "proc.map must be in backup, found: {backed_up:?}"); assert!(has_sav, "proc.sav must be in backup, found: {backed_up:?}"); assert_eq!(result.deleted_count, 2); } /// Recursively collect all file *names* (just the last component) under `dir`. fn collect_files_recursively(dir: &Path) -> Vec { let mut found = Vec::new(); if let Ok(rd) = std::fs::read_dir(dir) { for entry in rd.flatten() { let path = entry.path(); if path.is_dir() { found.extend(collect_files_recursively(&path)); } else { if let Some(name) = path.file_name() { found.push(name.to_string_lossy().into_owned()); } } } } found }