feat: wire the panel command surface to the live Rust agent + wipe handler
All checks were successful
All checks were successful
The legacy Go agent was never deployed, so the entire backend command surface
published to a dead cmd.server/cmd.wipe/files.cmd void. Route it all to the
Rust agent's instance-scoped subjects.
Agent (corrosion-host-agent, alpha.10):
- New src/wipe.rs + 'wipe' func on {instance}.cmd: stop -> delete game files by
type (map/blueprint/full, with optional backup) -> restart. Jailed to the
instance root, symlink-safe (lstat, no cross-boundary follow — Lesson 26).
8 tests incl. jail-escape + symlink-skip proofs. Agent suite 64 tests green.
Backend (NestJS):
- InstancesService is now @Global with license-scoped convenience wrappers
(lifecycleForLicense/rconForLicense/writeFileForLicense/readFileForLicense/
deleteFileForLicense/wipeForLicense) + resolveDefaultInstance (license ->
primary instance).
- Routed to the agent: servers start/stop/restart/command; players kick/banid/
unban via RCON; schedules restart/announce/command/plugin-reload; wipes ->
wipeForLicense (real wipe now); plugins reload/unload/upload via rcon+file
ops; all 9 plugin-config module applies -> writeFileForLicense + oxide.reload
rcon, imports -> readFileForLicense (server:// prefix stripped).
- Honestly gated (need agent funcs not yet built): server deploy-from-panel,
Oxide install, one-click uMod install -> 503 coming-soon instead of dead
publishes.
Backend tsc green; agent cargo test green (64).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
298
corrosion-host-agent/tests/wipe.rs
Normal file
298
corrosion-host-agent/tests/wipe.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
//! 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:
|
||||
// <root>/
|
||||
// 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<String> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user