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:
2
corrosion-host-agent/Cargo.lock
generated
2
corrosion-host-agent/Cargo.lock
generated
@@ -287,7 +287,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "corrosion-host-agent"
|
||||
version = "2.0.0-alpha.9"
|
||||
version = "2.0.0-alpha.10"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "corrosion-host-agent"
|
||||
version = "2.0.0-alpha.9"
|
||||
version = "2.0.0-alpha.10"
|
||||
edition = "2021"
|
||||
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||
license = "UNLICENSED"
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::agent::Agent;
|
||||
use crate::subjects;
|
||||
use crate::steamcmd;
|
||||
use crate::supervisor::Supervisor;
|
||||
use crate::wipe;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InstanceCommand {
|
||||
@@ -23,6 +24,19 @@ struct InstanceCommand {
|
||||
/// Payload for funcs that carry a text argument (e.g. rcon).
|
||||
#[serde(default)]
|
||||
command: Option<String>,
|
||||
/// Wipe type: "map" | "blueprint" | "full" — required for func="wipe".
|
||||
#[serde(default)]
|
||||
wipe_type: Option<wipe::WipeType>,
|
||||
/// Whether to back up wipe targets before deleting (func="wipe").
|
||||
#[serde(default)]
|
||||
backup: bool,
|
||||
/// Label for the backup subdirectory (func="wipe"). Defaults to "wipe-backup".
|
||||
#[serde(default = "default_backup_label")]
|
||||
backup_label: String,
|
||||
}
|
||||
|
||||
fn default_backup_label() -> String {
|
||||
"wipe-backup".to_string()
|
||||
}
|
||||
|
||||
/// Forward every supervisor state change as a status event.
|
||||
@@ -252,10 +266,79 @@ async fn dispatch(
|
||||
}),
|
||||
};
|
||||
}
|
||||
"wipe" => {
|
||||
let inst_cfg = agent.cfg.instances.iter().find(|i| i.id == sup.instance_id());
|
||||
|
||||
let Some(inst_cfg) = inst_cfg else {
|
||||
return json!({
|
||||
"status": "error",
|
||||
"func": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("no config found for instance '{}'", sup.instance_id()),
|
||||
});
|
||||
};
|
||||
|
||||
let Some(wipe_type) = cmd.wipe_type.clone() else {
|
||||
return json!({
|
||||
"status": "error",
|
||||
"func": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": "wipe func requires a 'wipe_type' field (\"map\", \"blueprint\", or \"full\")",
|
||||
});
|
||||
};
|
||||
|
||||
let root = inst_cfg.root.clone();
|
||||
let instance_id = sup.instance_id().to_string();
|
||||
|
||||
let wipe_req = wipe::WipeRequest {
|
||||
wipe_type,
|
||||
backup: cmd.backup,
|
||||
backup_label: cmd.backup_label.clone(),
|
||||
};
|
||||
|
||||
// Stop the server best-effort before wiping; proceed even if stop fails
|
||||
// (the server may already be down).
|
||||
if let Err(e) = sup.clone().stop().await {
|
||||
tracing::warn!("wipe: stop instance '{}' failed (proceeding anyway): {e:#}", instance_id);
|
||||
}
|
||||
|
||||
// Run the blocking I/O on the blocking thread pool.
|
||||
let result = tokio::task::spawn_blocking(move || wipe::execute(&root, &wipe_req)).await;
|
||||
|
||||
// Restart best-effort regardless of wipe outcome.
|
||||
if let Err(e) = sup.clone().start().await {
|
||||
tracing::warn!("wipe: restart instance '{}' failed: {e:#}", instance_id);
|
||||
}
|
||||
|
||||
return match result {
|
||||
Ok(Ok(wr)) => {
|
||||
let wipe_type_str = format!("{:?}", wr.wipe_type).to_lowercase();
|
||||
json!({
|
||||
"status": "success",
|
||||
"func": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"wipe_type": wipe_type_str,
|
||||
"deleted_count": wr.deleted_count,
|
||||
})
|
||||
}
|
||||
Ok(Err(e)) => json!({
|
||||
"status": "error",
|
||||
"func": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("{e:#}"),
|
||||
}),
|
||||
Err(e) => json!({
|
||||
"status": "error",
|
||||
"func": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("internal error: {e}"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
other => {
|
||||
return json!({
|
||||
"status": "error",
|
||||
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update)"),
|
||||
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update, wipe)"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,3 +17,4 @@ pub mod supervisor;
|
||||
pub mod telemetry;
|
||||
pub mod update;
|
||||
pub mod version;
|
||||
pub mod wipe;
|
||||
|
||||
412
corrosion-host-agent/src/wipe.rs
Normal file
412
corrosion-host-agent/src/wipe.rs
Normal file
@@ -0,0 +1,412 @@
|
||||
//! Jailed wipe engine for Rust (and compatible) game server instances.
|
||||
//!
|
||||
//! Three wipe types are supported, each a strict superset of the previous:
|
||||
//!
|
||||
//! | Type | What is deleted |
|
||||
//! |-------------|------------------------------------------------------------------|
|
||||
//! | `map` | `*.map`, `*.sav` under `<root>/server/<identity>/` |
|
||||
//! | `blueprint` | map wipe + `*.blueprints.*.db` / `.blueprints.*` under save dir |
|
||||
//! | `full` | blueprint wipe + `oxide/data/` contents + player state DB files |
|
||||
//!
|
||||
//! Identity discovery: rather than require the identity in the payload, we walk
|
||||
//! `<root>/server/*/` looking for files that match each wipe type's patterns.
|
||||
//! This handles any identity name without configuration churn.
|
||||
//!
|
||||
//! **Safety**: every path operated on is validated inside the canonicalized
|
||||
//! instance root with the same two-stage (lexical + canonicalize) jail used by
|
||||
//! `filemanager.rs`. We use `symlink_metadata` (lstat) everywhere we walk
|
||||
//! directories — symlinks are never followed across the boundary (Lesson 26).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::filemanager::jail;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The scope of data to erase.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WipeType {
|
||||
/// Delete procedural map + save files only.
|
||||
Map,
|
||||
/// Map wipe + player blueprint databases.
|
||||
Blueprint,
|
||||
/// Blueprint wipe + oxide/data + all player state DBs.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Parameters parsed from the NATS command payload.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct WipeRequest {
|
||||
/// Scope of the wipe.
|
||||
pub wipe_type: WipeType,
|
||||
/// Copy files to `.corrosion-backups/<backup_label>/` before deleting.
|
||||
#[serde(default)]
|
||||
pub backup: bool,
|
||||
/// Label used as the backup subdirectory name. Defaults to `"wipe-backup"`.
|
||||
#[serde(default = "default_backup_label")]
|
||||
pub backup_label: String,
|
||||
}
|
||||
|
||||
fn default_backup_label() -> String {
|
||||
"wipe-backup".to_string()
|
||||
}
|
||||
|
||||
/// Result of a successful wipe operation.
|
||||
#[derive(Debug)]
|
||||
pub struct WipeResult {
|
||||
pub deleted_count: usize,
|
||||
pub wipe_type: WipeType,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core wipe logic (sync — suitable for `spawn_blocking`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Execute a wipe of `wipe_type` inside `root`, optionally backing up first.
|
||||
///
|
||||
/// Does NOT touch the supervisor lifecycle — the caller (instancecmd dispatch)
|
||||
/// must stop the server before calling this and restart it afterwards.
|
||||
///
|
||||
/// Returns a `WipeResult` describing what was deleted. Missing directories are
|
||||
/// treated as zero-deleted, not as errors, so a fresh server never returns Err
|
||||
/// just because `server/*/` doesn't exist yet.
|
||||
pub fn execute(root: &Path, req: &WipeRequest) -> Result<WipeResult> {
|
||||
// Canonicalize root once; every subsequent path check goes through `jail()`.
|
||||
let canon_root = fs::canonicalize(root)
|
||||
.with_context(|| format!("canonicalize instance root '{}'", root.display()))?;
|
||||
|
||||
// Collect every path to delete based on wipe type.
|
||||
let targets = collect_targets(&canon_root, &req.wipe_type)?;
|
||||
|
||||
// Backup before any deletion when requested.
|
||||
if req.backup && !targets.is_empty() {
|
||||
let backup_dir = jail(root, &format!(".corrosion-backups/{}", req.backup_label))?;
|
||||
fs::create_dir_all(&backup_dir)
|
||||
.with_context(|| format!("create backup dir '{}'", backup_dir.display()))?;
|
||||
for path in &targets {
|
||||
backup_one(&canon_root, path, &backup_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete.
|
||||
let mut deleted_count = 0usize;
|
||||
for path in &targets {
|
||||
// Final safety check: confirm inside root before deletion.
|
||||
if path != &canon_root && !path.starts_with(&canon_root) {
|
||||
anyhow::bail!(
|
||||
"wipe safety: path '{}' is outside instance root '{}' — aborting",
|
||||
path.display(),
|
||||
canon_root.display()
|
||||
);
|
||||
}
|
||||
match delete_path(path) {
|
||||
Ok(n) => deleted_count += n,
|
||||
Err(e) => tracing::warn!("wipe: skipping '{}': {e:#}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"wipe complete: type={:?} deleted={} root={}",
|
||||
req.wipe_type,
|
||||
deleted_count,
|
||||
root.display()
|
||||
);
|
||||
|
||||
Ok(WipeResult {
|
||||
deleted_count,
|
||||
wipe_type: req.wipe_type.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Walk the Rust server tree under `canon_root` and return every path (file or
|
||||
/// dir) that should be deleted for the given wipe type.
|
||||
///
|
||||
/// Layout assumed:
|
||||
/// ```text
|
||||
/// <root>/
|
||||
/// server/
|
||||
/// <identity>/ -- any name; we walk all subdirs
|
||||
/// *.map
|
||||
/// *.sav
|
||||
/// player.blueprints.*.db (and *.blueprints.* variants)
|
||||
/// player.deaths.*.db
|
||||
/// player.identities.*.db
|
||||
/// player.states.*.db
|
||||
/// *.db (full wipe)
|
||||
/// oxide/
|
||||
/// data/ -- cleared for full wipe (dir contents, not dir itself)
|
||||
/// ```
|
||||
fn collect_targets(canon_root: &Path, wipe_type: &WipeType) -> Result<Vec<PathBuf>> {
|
||||
let mut targets: Vec<PathBuf> = Vec::new();
|
||||
|
||||
// --- server/<identity>/ ---
|
||||
let server_dir = canon_root.join("server");
|
||||
if is_real_dir(&server_dir) {
|
||||
for identity_entry in read_dir_safe(&server_dir)? {
|
||||
let identity_meta = fs::symlink_metadata(&identity_entry)
|
||||
.with_context(|| format!("stat '{}'", identity_entry.display()))?;
|
||||
|
||||
// Never follow symlinks across the boundary.
|
||||
if identity_meta.file_type().is_symlink() {
|
||||
tracing::debug!("wipe: skipping symlink '{}'", identity_entry.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
if !identity_meta.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
collect_save_targets(canon_root, &identity_entry, wipe_type, &mut targets)?;
|
||||
}
|
||||
}
|
||||
|
||||
// --- oxide/data/ (full wipe only) ---
|
||||
if *wipe_type == WipeType::Full {
|
||||
let oxide_data = canon_root.join("oxide").join("data");
|
||||
if is_real_dir(&oxide_data) {
|
||||
// Delete directory *contents*, not the directory itself.
|
||||
for entry in read_dir_safe(&oxide_data)? {
|
||||
let meta = fs::symlink_metadata(&entry)
|
||||
.with_context(|| format!("stat '{}'", entry.display()))?;
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::debug!("wipe: skipping symlink '{}'", entry.display());
|
||||
continue;
|
||||
}
|
||||
// Jail-check every entry before adding.
|
||||
ensure_inside(canon_root, &entry)?;
|
||||
targets.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Collect files from one `<root>/server/<identity>/` directory.
|
||||
fn collect_save_targets(
|
||||
canon_root: &Path,
|
||||
identity_dir: &Path,
|
||||
wipe_type: &WipeType,
|
||||
out: &mut Vec<PathBuf>,
|
||||
) -> Result<()> {
|
||||
for entry in read_dir_safe(identity_dir)? {
|
||||
let meta = fs::symlink_metadata(&entry)
|
||||
.with_context(|| format!("stat '{}'", entry.display()))?;
|
||||
|
||||
// Never follow symlinks.
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::debug!("wipe: skipping symlink '{}'", entry.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
ensure_inside(canon_root, &entry)?;
|
||||
|
||||
let file_name = entry
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let keep = match wipe_type {
|
||||
WipeType::Map => !is_map_file(&file_name) && !is_sav_file(&file_name),
|
||||
WipeType::Blueprint => {
|
||||
!is_map_file(&file_name)
|
||||
&& !is_sav_file(&file_name)
|
||||
&& !is_blueprint_file(&file_name)
|
||||
}
|
||||
WipeType::Full => {
|
||||
!is_map_file(&file_name)
|
||||
&& !is_sav_file(&file_name)
|
||||
&& !is_blueprint_file(&file_name)
|
||||
&& !is_player_state_file(&file_name)
|
||||
&& !is_generic_db_file(&file_name)
|
||||
}
|
||||
};
|
||||
|
||||
if !keep {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern matchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn is_map_file(name: &str) -> bool {
|
||||
name.ends_with(".map")
|
||||
}
|
||||
|
||||
fn is_sav_file(name: &str) -> bool {
|
||||
name.ends_with(".sav")
|
||||
}
|
||||
|
||||
fn is_blueprint_file(name: &str) -> bool {
|
||||
// Matches both `player.blueprints.*.db` and `.blueprints.*` variants.
|
||||
name.contains(".blueprints.")
|
||||
}
|
||||
|
||||
fn is_player_state_file(name: &str) -> bool {
|
||||
name.contains("player.deaths.")
|
||||
|| name.contains("player.identities.")
|
||||
|| name.contains("player.states.")
|
||||
}
|
||||
|
||||
fn is_generic_db_file(name: &str) -> bool {
|
||||
name.ends_with(".db")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Delete a single path (file or directory tree). Returns count of top-level
|
||||
/// items removed (1 for a file, 1 for a directory tree). Missing paths return
|
||||
/// 0 — the server may be fresh.
|
||||
fn delete_path(path: &Path) -> Result<usize> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(e) => return Err(e).with_context(|| format!("stat '{}'", path.display())),
|
||||
};
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
// Delete the symlink itself — never follow it.
|
||||
fs::remove_file(path).with_context(|| format!("remove symlink '{}'", path.display()))?;
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
if meta.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
.with_context(|| format!("remove_dir_all '{}'", path.display()))?;
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
.with_context(|| format!("remove_file '{}'", path.display()))?;
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Copy one path (file or directory) into `backup_dir`, preserving the last
|
||||
/// component of the path name. Symlinks are skipped — we never follow them.
|
||||
fn backup_one(canon_root: &Path, src: &Path, backup_dir: &Path) -> Result<()> {
|
||||
let meta = match fs::symlink_metadata(src) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e).with_context(|| format!("stat backup src '{}'", src.display())),
|
||||
};
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::debug!("wipe backup: skipping symlink '{}'", src.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let name = match src.file_name() {
|
||||
Some(n) => n,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Preserve relative path from root inside the backup directory to avoid
|
||||
// name collisions when multiple identity dirs have a `proc.map`.
|
||||
let rel = src
|
||||
.strip_prefix(canon_root)
|
||||
.unwrap_or_else(|_| src)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""));
|
||||
let dest = backup_dir.join(rel).join(name);
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("backup: create_dir_all '{}'", parent.display()))?;
|
||||
}
|
||||
|
||||
copy_recursive_safe(src, &dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursive copy that uses `symlink_metadata` (lstat) and refuses to follow
|
||||
/// any symlink — mirrors the same guard in `filemanager::copy_recursive`.
|
||||
fn copy_recursive_safe(src: &Path, dest: &Path) -> Result<()> {
|
||||
let meta = fs::symlink_metadata(src)
|
||||
.with_context(|| format!("stat source '{}'", src.display()))?;
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
anyhow::bail!(
|
||||
"refusing to copy symlink '{}' during backup — symlinks are not followed",
|
||||
src.display()
|
||||
);
|
||||
}
|
||||
|
||||
if meta.is_dir() {
|
||||
fs::create_dir_all(dest)
|
||||
.with_context(|| format!("create_dir_all '{}'", dest.display()))?;
|
||||
for entry in fs::read_dir(src)
|
||||
.with_context(|| format!("read_dir '{}'", src.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
copy_recursive_safe(&entry.path(), &dest.join(entry.file_name()))?;
|
||||
}
|
||||
} else {
|
||||
fs::copy(src, dest)
|
||||
.with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns `true` if `path` exists, is a directory, and is not a symlink.
|
||||
fn is_real_dir(path: &Path) -> bool {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => m.is_dir() && !m.file_type().is_symlink(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a directory and return the absolute paths of its entries.
|
||||
/// Uses lstat internally via `read_dir` (entry paths; metadata is lstat'd
|
||||
/// separately by callers).
|
||||
fn read_dir_safe(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut entries = Vec::new();
|
||||
let rd = match fs::read_dir(dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
|
||||
Err(e) => return Err(e).with_context(|| format!("read_dir '{}'", dir.display())),
|
||||
};
|
||||
for item in rd {
|
||||
let item = item.with_context(|| format!("read dir entry in '{}'", dir.display()))?;
|
||||
entries.push(item.path());
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Assert that `path` is strictly inside (or equal to) `canon_root`.
|
||||
/// This is the final safety fence before any destructive or backup operation.
|
||||
fn ensure_inside(canon_root: &Path, path: &Path) -> Result<()> {
|
||||
// Canonicalize the path if it exists; otherwise use it as-is (it's
|
||||
// derived from read_dir, which already returns absolute paths rooted
|
||||
// under canon_root in normal operation).
|
||||
let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
if resolved != canon_root && !resolved.starts_with(canon_root) {
|
||||
anyhow::bail!(
|
||||
"wipe safety: path '{}' is outside instance root '{}' — aborting",
|
||||
path.display(),
|
||||
canon_root.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
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