From 18f978dde1d1ee1c75607548420a5982516e19d0 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 11 Jun 2026 11:51:46 -0400 Subject: [PATCH] =?UTF-8?q?feat(host-agent):=20Phase=201c=20=E2=80=94=20St?= =?UTF-8?q?eamCMD=20update=20+=20jailed=20file=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit steam_update func runs SteamCMD per game (rust/conan/soulmask app-ids; dune rejected), streaming stdout to {instance}.steam_status. Jailed file manager on {instance}.files.cmd: list/read/write/delete/rename/ mkdir/mkfile/move/copy, all confined to instance root via two-stage lexical-normalize + canonicalize (defeats ../ traversal AND symlink escape — incl chained symlinks). Replaces the Go agent's UNJAILED legacy files API (retired, not ported). 5MiB read cap. 42/42 tests green: 24 filemanager incl 7 jail-escape attempts (dotdot, deep dotdot, absolute, symlink-inside, direct symlink, chained symlink), 5 steamcmd app-id (cfg-gated win/linux soulmask). Jail logic reviewed line-by-line: Path::starts_with is component-wise (no sibling-prefix bypass), non-existent suffix components can't be symlinks, leading .. normalizes to / and fails the prefix check. Co-Authored-By: Claude Fable 5 --- corrosion-host-agent/Cargo.lock | 257 ++++++++++- corrosion-host-agent/Cargo.toml | 3 + corrosion-host-agent/PROTOCOL.md | 26 +- corrosion-host-agent/agent.example.toml | 10 + corrosion-host-agent/src/config.rs | 5 + corrosion-host-agent/src/filemanager.rs | 527 ++++++++++++++++++++++ corrosion-host-agent/src/instancecmd.rs | 77 +++- corrosion-host-agent/src/lib.rs | 2 + corrosion-host-agent/src/main.rs | 23 +- corrosion-host-agent/src/steamcmd.rs | 126 ++++++ corrosion-host-agent/src/subjects.rs | 11 + corrosion-host-agent/tests/filemanager.rs | 405 +++++++++++++++++ corrosion-host-agent/tests/steamcmd.rs | 45 ++ corrosion-host-agent/tests/supervisor.rs | 1 + 14 files changed, 1508 insertions(+), 10 deletions(-) create mode 100644 corrosion-host-agent/src/filemanager.rs create mode 100644 corrosion-host-agent/src/steamcmd.rs create mode 100644 corrosion-host-agent/tests/filemanager.rs create mode 100644 corrosion-host-agent/tests/steamcmd.rs diff --git a/corrosion-host-agent/Cargo.lock b/corrosion-host-agent/Cargo.lock index 1ffc2c4..6374550 100644 --- a/corrosion-host-agent/Cargo.lock +++ b/corrosion-host-agent/Cargo.lock @@ -276,6 +276,7 @@ dependencies = [ "serde", "serde_json", "sysinfo", + "tempfile", "tokio", "tokio-tungstenite", "tokio-util", @@ -446,6 +447,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -458,6 +465,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -576,6 +589,28 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -710,6 +745,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -738,7 +779,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -770,12 +813,24 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -832,7 +887,7 @@ dependencies = [ "data-encoding", "ed25519", "ed25519-dalek", - "getrandom", + "getrandom 0.2.17", "log", "rand", "signatory", @@ -982,6 +1037,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1000,6 +1065,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.6" @@ -1027,7 +1098,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -1096,7 +1167,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1111,6 +1182,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1455,6 +1539,19 @@ dependencies = [ "windows", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1731,6 +1828,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1785,6 +1888,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.123" @@ -1830,6 +1951,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2055,6 +2210,100 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/corrosion-host-agent/Cargo.toml b/corrosion-host-agent/Cargo.toml index 935e8fa..56bd3de 100644 --- a/corrosion-host-agent/Cargo.toml +++ b/corrosion-host-agent/Cargo.toml @@ -30,6 +30,9 @@ tokio-tungstenite = "0.24" [target.'cfg(unix)'.dependencies] libc = "0.2" +[dev-dependencies] +tempfile = "3" + # Size-optimized release: single static binary living next to RAM-heavy game # servers. Panic stays 'unwind' so a panicking task surfaces through its # JoinHandle instead of killing the whole agent. diff --git a/corrosion-host-agent/PROTOCOL.md b/corrosion-host-agent/PROTOCOL.md index c7cd6f6..1acd4d6 100644 --- a/corrosion-host-agent/PROTOCOL.md +++ b/corrosion-host-agent/PROTOCOL.md @@ -110,9 +110,29 @@ conan/soulmask; explicit `kind` override available in the instance's Errors reply `{ "status": "error", "message": ... }` — including start on an unmanaged instance, double start, missing rcon config, and unknown funcs. -Planned funcs: `steam_update`, `oxide_install` (rust), plus -game-adapter-specific commands (Dune: docker lifecycle, RabbitMQ bus -commands, Coriolis reset). +Also implemented: `steam_update` — `{ "func": "steam_update" }` runs +SteamCMD for the instance's game (app ids: rust 258550, conan 443030, +soulmask 3017310/3017300; dune rejects — Docker images, no SteamCMD), +streaming progress lines to `corrosion.{license}.{instance}.steam_status` +and replying on completion. + +Planned funcs: `oxide_install` (rust), plus game-adapter-specific +commands (Dune: docker lifecycle, RabbitMQ bus commands, Coriolis reset). + +### `corrosion.{license_id}.{instance_id}.steam_status` (agent → backend, publish) — LIVE + +Per-line SteamCMD stdout during a `steam_update`, so the panel can show +live update progress. Payload: `{ "timestamp", "instance_id", "line" }`. + +### `corrosion.{license_id}.{instance_id}.files.cmd` (backend → agent, request-reply) — LIVE + +Jailed file manager, confined to the instance `root` (two-stage check: +lexical normalize + canonicalize, defeating `../` traversal and symlink +escape). Request `{ "op": "list|read|write|delete|rename|mkdir|mkfile|move|copy", +"path": "rel/path", "dest"?, "content"?, "name"? }`; reply +`{ "status": "success", "data": ... }` or `{ "status": "error", "message": ... }`. +`read` caps at 5 MiB. Replaces the Go agent's UNJAILED legacy files API, +which is retired and will not be ported. ### `corrosion.{license_id}.{instance_id}.status` (agent → backend, publish) — LIVE diff --git a/corrosion-host-agent/agent.example.toml b/corrosion-host-agent/agent.example.toml index 6004b79..6339f62 100644 --- a/corrosion-host-agent/agent.example.toml +++ b/corrosion-host-agent/agent.example.toml @@ -46,6 +46,16 @@ password = "changeme" # password = "changeme" # # kind = "source" # inferred automatically for soulmask +# SteamCMD update settings — optional sub-table for any instance. +# Absent = defaults: steamcmd binary resolved via PATH, validate = false. +# +# [instance.steamcmd] +# steamcmd_path = "/opt/steamcmd/steamcmd.sh" # omit to use PATH +# validate = true # enable file-hash check pass +# +# Dune instances do not use SteamCMD (Docker images); the steam_update func +# will return a clear error if invoked on a dune instance. + [prober] interval_seconds = 300 diff --git a/corrosion-host-agent/src/config.rs b/corrosion-host-agent/src/config.rs index e715b4c..db2c951 100644 --- a/corrosion-host-agent/src/config.rs +++ b/corrosion-host-agent/src/config.rs @@ -11,6 +11,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::rcon::RconConfig; +use crate::steamcmd::SteamcmdConfig; /// Instance ids share the NATS subject namespace with host-level segments. const RESERVED_INSTANCE_IDS: &[&str] = &["host", "cmd", "files", "update", "agent"]; @@ -65,6 +66,10 @@ pub struct InstanceConfig { /// Protocol defaults to WebRcon for rust, Source for conan/soulmask. #[serde(default)] pub rcon: Option, + /// SteamCMD update settings. Absent = defaults apply (steamcmd on PATH, + /// validate = false). + #[serde(default)] + pub steamcmd: Option, } impl InstanceConfig { diff --git a/corrosion-host-agent/src/filemanager.rs b/corrosion-host-agent/src/filemanager.rs new file mode 100644 index 0000000..01e308e --- /dev/null +++ b/corrosion-host-agent/src/filemanager.rs @@ -0,0 +1,527 @@ +//! Jailed file manager for game-server install directories. +//! +//! Every path operation is confined to the instance `root` — the directory +//! declared as `root` in `[[instance]]` config. A two-stage check (lexical +//! Clean + `std::fs::canonicalize`) prevents both `../..` traversals and +//! symlink-based escapes: even if an attacker plants a symlink inside the root +//! that points outside it, `canonicalize` resolves the target and the prefix +//! check catches the escape. +//! +//! The NATS request/reply contract mirrors the Go companion agent's jailed file +//! manager (see `companion-agent/internal/filemanager/`) but uses a simpler +//! flat JSON envelope rather than the VueFinder storage-path protocol — the +//! Rust agent is the replacement, and the panel's backend talks to whichever +//! agent is present. +//! +//! Subject: `corrosion.{license}.{instance}.files.cmd` +//! Request: `{"op":"list"|"read"|"write"|"delete"|"rename"|"mkdir"|"mkfile"|"move"|"copy", +//! "path":"rel/path", "dest"?:"...", "content"?:"...", "name"?:"..."}` +//! Response: `{"status":"success","data":...}` or `{"status":"error","message":"..."}` + +use anyhow::{bail, Context}; +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Maximum size for a `read` operation (5 MiB). Larger files must be +/// transferred through a dedicated download endpoint, not the file manager. +const MAX_READ_SIZE: u64 = 5 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct FileRequest { + pub op: String, + /// Relative path within the instance root (the "subject" of the operation). + #[serde(default)] + pub path: String, + /// Destination for `rename`, `move`, `copy` — relative to instance root. + #[serde(default)] + pub dest: Option, + /// Text content for `write`. + #[serde(default)] + pub content: Option, + /// Bare filename for `mkdir` and `mkfile`. + #[serde(default)] + pub name: Option, +} + +/// A single directory entry returned by `list`. +#[derive(Debug, Serialize)] +pub struct FileEntry { + pub name: String, + /// Path relative to the instance root, using forward slashes. + pub path: String, + pub is_dir: bool, + /// File size in bytes. Zero for directories. + pub size: u64, + /// RFC 3339 modification timestamp. + pub modified: String, +} + +// --------------------------------------------------------------------------- +// Jail helper — the security core of this module +// --------------------------------------------------------------------------- + +/// Resolve `rel` against `root`, then canonicalize to reject any form of +/// escape including `../..` traversals and symlinks that point outside root. +/// +/// For paths that do not yet exist (e.g. write targets), we canonicalize the +/// nearest existing ancestor and then re-join the remaining components, which +/// are lexically-clean because they went through `std::path::Path` building. +/// +/// Returns the absolute, canonicalized path if it is within `root`. +pub fn jail(root: &Path, rel: &str) -> anyhow::Result { + // Canonicalize root once to get a stable prefix for comparison. + // We do this on every call rather than caching so the function stays + // pure and testable without Agent state. + let canon_root = fs::canonicalize(root) + .with_context(|| format!("canonicalize instance root '{}'", root.display()))?; + + // Build the candidate absolute path. We use Path joining so that an + // absolute `rel` (e.g. "/etc/passwd") replaces the root entirely — we + // detect and reject that case immediately. + let candidate = if rel.is_empty() || rel == "." { + root.to_path_buf() + } else { + let rel_path = Path::new(rel); + if rel_path.is_absolute() { + bail!( + "absolute path '{}' is not allowed; supply a path relative to the instance root", + rel + ); + } + root.join(rel_path) + }; + + // Normalize lexically first (removes `..` / `.` without filesystem access). + // This is a defence-in-depth step; the authoritative check is below. + let lexical = normalize_lexical(&candidate); + + // Canonicalize: resolve symlinks and `..` via the kernel. + // For a not-yet-existing path we walk up to the nearest existing ancestor. + let canon = canonicalize_lenient(&lexical)?; + + // Authoritative prefix check: the resolved path must be equal to or a + // child of the canonicalized root. + if canon != canon_root && !canon.starts_with(&canon_root) { + bail!( + "path '{}' resolves to '{}' which is outside the instance root '{}'", + rel, + canon.display(), + canon_root.display() + ); + } + + Ok(canon) +} + +/// Canonicalize a path that may not fully exist yet by walking up to the +/// nearest existing ancestor, canonicalizing it, then re-joining the remaining +/// (lexically-clean) suffix. +fn canonicalize_lenient(path: &Path) -> anyhow::Result { + // Fast path: path already exists. + if let Ok(c) = fs::canonicalize(path) { + return Ok(c); + } + + // Walk up until we find an ancestor that exists. + let mut existing = path.to_path_buf(); + let mut suffix: Vec = Vec::new(); + + loop { + match fs::canonicalize(&existing) { + Ok(canon) => { + // Re-attach the non-existing suffix. + let mut result = canon; + for component in suffix.iter().rev() { + result = result.join(component); + } + return Ok(result); + } + Err(_) => { + let file_name = match existing.file_name() { + Some(n) => n.to_os_string(), + None => bail!("cannot resolve path '{}'", path.display()), + }; + suffix.push(file_name); + existing = match existing.parent() { + Some(p) => p.to_path_buf(), + None => bail!("cannot resolve path '{}'", path.display()), + }; + } + } + } +} + +/// Lexically normalize a path (remove `.` and `..` components) without +/// touching the filesystem. This mirrors `filepath.Clean` in Go. +fn normalize_lexical(path: &Path) -> PathBuf { + let mut components: Vec = Vec::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + // Only pop a normal component — we cannot pop a root prefix. + if matches!(components.last(), Some(std::path::Component::Normal(_))) { + components.pop(); + } else { + components.push(component); + } + } + other => components.push(other), + } + } + components.iter().collect() +} + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +/// List the contents of a directory. Returns an entry per item, sorted +/// (directories first, then files, both alphabetical). +pub fn list(root: &Path, rel: &str) -> anyhow::Result> { + let abs = jail(root, rel)?; + // Use the canonicalized root as the prefix for relative path computation so + // that symlinked root paths (e.g. macOS /var → /private/var) don't cause + // strip_prefix to fail and fall back to leaking the absolute path. + let canon_root = fs::canonicalize(root) + .with_context(|| format!("canonicalize root '{}'", root.display()))?; + + let rd = fs::read_dir(&abs) + .with_context(|| format!("read_dir '{}'", abs.display()))?; + + let mut entries: Vec = Vec::new(); + for item in rd { + let item = item.with_context(|| format!("reading directory entry in '{}'", abs.display()))?; + let meta = item.metadata().with_context(|| format!("stat '{}'", item.path().display()))?; + + let name = item.file_name().to_string_lossy().into_owned(); + let is_dir = meta.is_dir(); + let size = if is_dir { 0 } else { meta.len() }; + + // Build the relative path from the canonicalized root. + let entry_abs = item.path(); + let entry_rel = entry_abs + .strip_prefix(&canon_root) + .unwrap_or(&entry_abs) + .to_string_lossy() + .replace('\\', "/"); + + let modified = meta + .modified() + .ok() + .map(|t| { + let dt: DateTime = t.into(); + dt.to_rfc3339_opts(SecondsFormat::Secs, true) + }) + .unwrap_or_default(); + + entries.push(FileEntry { name, path: entry_rel, is_dir, size, modified }); + } + + // Stable sort: dirs first, then alphabetical within each group. + entries.sort_by(|a, b| { + b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)) + }); + + Ok(entries) +} + +/// Read a text file. Capped at `MAX_READ_SIZE` bytes. +pub fn read(root: &Path, rel: &str) -> anyhow::Result { + let abs = jail(root, rel)?; + + let meta = fs::metadata(&abs) + .with_context(|| format!("stat '{}'", abs.display()))?; + + if meta.is_dir() { + bail!("'{}' is a directory, not a file", rel); + } + if meta.len() > MAX_READ_SIZE { + bail!( + "file '{}' is {} bytes which exceeds the {} byte read limit", + rel, + meta.len(), + MAX_READ_SIZE + ); + } + + fs::read_to_string(&abs).with_context(|| format!("read '{}'", abs.display())) +} + +/// Write (create or overwrite) a file. Parent directories are created as +/// needed. +pub fn write(root: &Path, rel: &str, content: &str) -> anyhow::Result<()> { + let abs = jail(root, rel)?; + + if let Some(parent) = abs.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create_dir_all '{}'", parent.display()))?; + } + + fs::write(&abs, content.as_bytes()) + .with_context(|| format!("write '{}'", abs.display())) +} + +/// Delete a file or directory tree. +pub fn delete(root: &Path, rel: &str) -> anyhow::Result<()> { + let abs = jail(root, rel)?; + + let meta = fs::metadata(&abs) + .with_context(|| format!("stat '{}'", abs.display()))?; + + if meta.is_dir() { + fs::remove_dir_all(&abs).with_context(|| format!("remove_dir_all '{}'", abs.display())) + } else { + fs::remove_file(&abs).with_context(|| format!("remove_file '{}'", abs.display())) + } +} + +/// Rename/move `rel` to a new bare name (`new_name`) within the same parent. +/// `new_name` must not contain path separators. +pub fn rename(root: &Path, rel: &str, new_name: &str) -> anyhow::Result<()> { + if new_name.is_empty() || new_name == "." || new_name == ".." { + bail!("new_name '{}' is not a valid filename", new_name); + } + if new_name.contains('/') || new_name.contains('\\') { + bail!("new_name '{}' must not contain path separators", new_name); + } + + let src_abs = jail(root, rel)?; + + // Construct the destination relative path by replacing the filename part + // of `rel` with `new_name`. This keeps everything in relative-path space + // so we never hand an absolute path to `jail`. + let src_rel = Path::new(rel); + let dest_rel = match src_rel.parent() { + Some(parent) if parent != Path::new("") => { + parent.join(new_name).to_string_lossy().replace('\\', "/") + } + _ => new_name.to_string(), + }; + + let dest_abs = jail(root, &dest_rel)?; + + fs::rename(&src_abs, &dest_abs) + .with_context(|| format!("rename '{}' -> '{}'", src_abs.display(), dest_abs.display())) +} + +/// Create a directory (and any missing parents) at `rel`. +pub fn mkdir(root: &Path, rel: &str) -> anyhow::Result<()> { + let abs = jail(root, rel)?; + fs::create_dir_all(&abs).with_context(|| format!("mkdir '{}'", abs.display())) +} + +/// Create an empty file at `rel`. Fails if it already exists. +pub fn mkfile(root: &Path, rel: &str) -> anyhow::Result<()> { + let abs = jail(root, rel)?; + + if let Some(parent) = abs.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create_dir_all '{}'", parent.display()))?; + } + + let _ = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&abs) + .with_context(|| format!("mkfile '{}'", abs.display()))?; + + Ok(()) +} + +/// Move `src` to `dest` (both relative to root). +pub fn move_path(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> { + let src_abs = jail(root, src)?; + let dest_abs = jail(root, dest)?; + + if let Some(parent) = dest_abs.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create_dir_all '{}'", parent.display()))?; + } + + fs::rename(&src_abs, &dest_abs).or_else(|_| { + // Cross-device move: copy then delete. + copy_recursive(&src_abs, &dest_abs)?; + fs::remove_dir_all(&src_abs) + .with_context(|| format!("remove source '{}' after cross-device move", src_abs.display())) + }).with_context(|| format!("move '{}' -> '{}'", src_abs.display(), dest_abs.display())) +} + +/// Copy `src` to `dest` (both relative to root). +pub fn copy(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> { + let src_abs = jail(root, src)?; + let dest_abs = jail(root, dest)?; + + if let Some(parent) = dest_abs.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create_dir_all '{}'", parent.display()))?; + } + + copy_recursive(&src_abs, &dest_abs) + .with_context(|| format!("copy '{}' -> '{}'", src_abs.display(), dest_abs.display())) +} + +/// Recursive copy helper (mirrors Go's `copyRecursive`). +fn copy_recursive(src: &Path, dest: &Path) -> anyhow::Result<()> { + let meta = fs::metadata(src) + .with_context(|| format!("stat source '{}'", 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(&entry.path(), &dest.join(entry.file_name()))?; + } + } else { + fs::copy(src, dest) + .with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// NATS request dispatch +// --------------------------------------------------------------------------- + +/// Dispatch a `FileRequest` against `root` and return a JSON `serde_json::Value` +/// ready for the NATS reply. +pub fn dispatch(root: &Path, req: &FileRequest) -> serde_json::Value { + use serde_json::json; + + let result = match req.op.as_str() { + "list" => { + list(root, &req.path).map(|entries| json!({ "entries": entries })) + } + "read" => { + read(root, &req.path).map(|content| json!({ "content": content })) + } + "write" => { + let content = req.content.as_deref().unwrap_or(""); + write(root, &req.path, content).map(|_| json!(null)) + } + "delete" => { + delete(root, &req.path).map(|_| json!(null)) + } + "rename" => { + let new_name = req.name.as_deref().unwrap_or(""); + rename(root, &req.path, new_name).map(|_| json!(null)) + } + "mkdir" => { + mkdir(root, &req.path).map(|_| json!(null)) + } + "mkfile" => { + mkfile(root, &req.path).map(|_| json!(null)) + } + "move" => { + let dest = req.dest.as_deref().unwrap_or(""); + move_path(root, &req.path, dest).map(|_| json!(null)) + } + "copy" => { + let dest = req.dest.as_deref().unwrap_or(""); + copy(root, &req.path, dest).map(|_| json!(null)) + } + other => Err(anyhow::anyhow!( + "unknown op '{}' (supported: list, read, write, delete, rename, mkdir, mkfile, move, copy)", + other + )), + }; + + match result { + Ok(data) => json!({ "status": "success", "data": data }), + Err(e) => { + tracing::warn!("filemanager op='{}' path='{}': {e:#}", req.op, req.path); + json!({ "status": "error", "message": format!("{e:#}") }) + } + } +} + +/// Subscribe to `corrosion.{license}.{instance}.files.cmd` and serve file +/// manager requests for `instance_id` jailed to `root`. +/// +/// This function runs until the agent's cancellation token fires or the NATS +/// subscription ends. It is spawned once per instance in `main.rs`. +pub async fn run( + agent: std::sync::Arc, + instance_id: String, + root: PathBuf, +) -> anyhow::Result<()> { + use futures::StreamExt; + + let subject = crate::subjects::instance_files_cmd(&agent.cfg.license_id, &instance_id); + let mut sub = agent.nats.subscribe(subject.clone()).await?; + tracing::info!("file manager handler listening on {subject}"); + + let cancel = agent.shutdown.clone(); + loop { + tokio::select! { + msg = sub.next() => { + match msg { + Some(msg) => { + let agent = agent.clone(); + let root = root.clone(); + let instance_id = instance_id.clone(); + tokio::spawn(async move { handle(agent, &instance_id, &root, msg).await }); + } + None => { + tracing::warn!("file manager subscription ended for '{instance_id}'"); + break; + } + } + } + _ = cancel.cancelled() => { + tracing::info!("file manager handler stopping for '{instance_id}'"); + break; + } + } + } + Ok(()) +} + +async fn handle( + agent: std::sync::Arc, + instance_id: &str, + root: &Path, + msg: async_nats::Message, +) { + let Some(reply) = msg.reply.clone() else { + tracing::warn!("file manager message without reply subject ignored (instance '{instance_id}')"); + return; + }; + + let response = match serde_json::from_slice::(&msg.payload) { + Ok(req) => { + // Blocking fs calls — offload from the async executor. + let root = root.to_path_buf(); + tokio::task::spawn_blocking(move || dispatch(&root, &req)) + .await + .unwrap_or_else(|e| { + serde_json::json!({ "status": "error", "message": format!("internal error: {e}") }) + }) + } + Err(e) => { + serde_json::json!({ "status": "error", "message": format!("invalid request payload: {e}") }) + } + }; + + let bytes = match serde_json::to_vec(&response) { + Ok(b) => b, + Err(e) => { + tracing::error!("file manager response serialize failed: {e}"); + return; + } + }; + if let Err(e) = agent.nats.publish(reply, bytes.into()).await { + tracing::warn!("file manager response publish failed: {e}"); + } +} diff --git a/corrosion-host-agent/src/instancecmd.rs b/corrosion-host-agent/src/instancecmd.rs index d291815..483dade 100644 --- a/corrosion-host-agent/src/instancecmd.rs +++ b/corrosion-host-agent/src/instancecmd.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use crate::agent::Agent; use crate::process::ProcessSupervisor; use crate::subjects; +use crate::steamcmd; #[derive(Debug, Deserialize)] struct InstanceCommand { @@ -175,10 +176,84 @@ async fn dispatch( }), }; } + "steam_update" => { + // Look up instance config for game name, root, and optional steamcmd + // settings. The supervisor only carries process-control state, not + // the full config, so we reach into agent.cfg.instances here as the + // rcon dispatch does. + 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": "steam_update", + "instance_id": sup.instance_id, + "message": format!("no config found for instance '{}'", sup.instance_id), + }); + }; + + let game = inst_cfg.game.as_str(); + let root = inst_cfg.root.clone(); + + // Resolve steamcmd path and validate flag from config or use defaults. + let (steamcmd_path, validate) = match inst_cfg.steamcmd.as_ref() { + Some(s) => { + let path = s + .steamcmd_path + .as_ref() + .and_then(|p| p.to_str().map(|s| s.to_string())) + .unwrap_or_else(|| "steamcmd".to_string()); + (path, s.validate) + } + None => ("steamcmd".to_string(), false), + }; + + let license = agent.cfg.license_id.clone(); + let instance_id = sup.instance_id.clone(); + let nats = agent.nats.clone(); + + // Publish each progress line to the steam_status subject. + let on_progress = move |line: &str| { + let subject = subjects::instance_steam_status(&license, &instance_id); + let event = json!({ + "timestamp": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + "instance_id": instance_id, + "line": line, + }); + match serde_json::to_vec(&event) { + Ok(bytes) => { + // Fire-and-forget; the async publish is non-blocking on + // the caller side. We create a mini-runtime task via + // a oneshot since on_progress is Fn (not async). + let nats = nats.clone(); + tokio::spawn(async move { + if let Err(e) = nats.publish(subject, bytes.into()).await { + tracing::warn!("steam_status publish failed: {e}"); + } + }); + } + Err(e) => tracing::error!("steam_status serialize failed: {e}"), + } + }; + + return match steamcmd::update(game, &root, &steamcmd_path, validate, on_progress).await { + Ok(()) => json!({ + "status": "success", + "func": "steam_update", + "instance_id": sup.instance_id, + }), + Err(e) => json!({ + "status": "error", + "func": "steam_update", + "instance_id": sup.instance_id, + "message": format!("{e:#}"), + }), + }; + } other => { return json!({ "status": "error", - "message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon)"), + "message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update)"), }); } }; diff --git a/corrosion-host-agent/src/lib.rs b/corrosion-host-agent/src/lib.rs index e59c5e8..ea37b89 100644 --- a/corrosion-host-agent/src/lib.rs +++ b/corrosion-host-agent/src/lib.rs @@ -4,11 +4,13 @@ pub mod agent; pub mod bus; pub mod config; +pub mod filemanager; pub mod hostcmd; pub mod instancecmd; pub mod prober; pub mod process; pub mod rcon; +pub mod steamcmd; pub mod subjects; pub mod telemetry; pub mod version; diff --git a/corrosion-host-agent/src/main.rs b/corrosion-host-agent/src/main.rs index de5eb6b..6aa7cdd 100644 --- a/corrosion-host-agent/src/main.rs +++ b/corrosion-host-agent/src/main.rs @@ -5,7 +5,8 @@ //! game adapters arrive in Phase 1+ (see PROTOCOL.md). use corrosion_host_agent::{ - agent, bus, config, hostcmd, instancecmd, prober, process, subjects, telemetry, version, + agent, bus, config, filemanager, hostcmd, instancecmd, prober, process, subjects, telemetry, + version, }; use anyhow::{Context, Result}; @@ -117,7 +118,7 @@ async fn run(settings: config::Settings) -> Result<()> { } })); } - for sup in agent.supervisors.values() { + for (instance_id, sup) in &agent.supervisors { { let agent = agent.clone(); let sup = sup.clone(); @@ -131,6 +132,24 @@ async fn run(settings: config::Settings) -> Result<()> { agent.clone(), sup.clone(), ))); + // File manager: one handler task per instance, jailed to root. + { + let agent = agent.clone(); + let inst_cfg = agent + .cfg + .instances + .iter() + .find(|i| &i.id == instance_id) + .cloned(); + if let Some(cfg) = inst_cfg { + let id = instance_id.clone(); + handles.push(tokio::spawn(async move { + if let Err(e) = filemanager::run(agent, id, cfg.root).await { + tracing::error!("file manager handler failed: {e:#}"); + } + })); + } + } } wait_for_shutdown_signal().await; diff --git a/corrosion-host-agent/src/steamcmd.rs b/corrosion-host-agent/src/steamcmd.rs new file mode 100644 index 0000000..47f8fd8 --- /dev/null +++ b/corrosion-host-agent/src/steamcmd.rs @@ -0,0 +1,126 @@ +//! SteamCMD update integration for process-managed game instances. +//! +//! Wraps the `steamcmd` binary to perform an `+app_update` for a given game +//! instance, streaming stdout lines to a caller-supplied progress callback so +//! the panel can display live update output. The agent already runs a task per +//! command in a separate `tokio::spawn`, so the blocking-until-done semantics +//! here are intentional — the NATS reply is sent only when SteamCMD exits. +//! +//! Dune is Docker-image-based and explicitly has no SteamCMD integration — any +//! attempt to invoke `update` on a Dune instance returns a clear error rather +//! than a silent no-op. + +use std::path::Path; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +/// Return the Steam app ID for a given game name, or `None` for Dune (Docker). +/// +/// Soulmask returns the Windows or Linux server app ID depending on the compile +/// target so this function is `#[cfg]`-gated at the platform level. +pub fn app_id_for_game(game: &str) -> Option { + match game { + "rust" => Some(258550), + "conan" => Some(443030), + "soulmask" => { + #[cfg(windows)] + { + Some(3017310) + } + #[cfg(not(windows))] + { + Some(3017300) + } + } + // Dune uses Docker images — SteamCMD has no role here. + "dune" => None, + _ => None, + } +} + +/// Configuration controlling SteamCMD behaviour for one instance. +/// Serialised as `[instance.steamcmd]` in agent.toml. +#[derive(Debug, Clone, serde::Deserialize, Default)] +pub struct SteamcmdConfig { + /// Absolute or relative path to the `steamcmd` binary. + /// Defaults to `"steamcmd"` (resolved via `PATH`) when absent. + #[serde(default)] + pub steamcmd_path: Option, + + /// Whether to pass `validate` to `+app_update`. Adds a file-hash check + /// pass that catches corruption at the cost of a longer update time. + #[serde(default)] + pub validate: bool, +} + +/// Run a SteamCMD update for `game` into `install_dir`. +/// +/// - `steamcmd_path`: path to the binary (or `"steamcmd"` to use PATH). +/// - `validate`: appends `validate` to the `+app_update` call. +/// - `on_progress`: receives each stdout line as it arrives so callers can +/// forward progress to the panel in real time. +/// +/// Returns `Ok(())` on a zero exit code, otherwise an error describing the +/// failure. Dune is rejected before any process is spawned. +pub async fn update( + game: &str, + install_dir: &Path, + steamcmd_path: &str, + validate: bool, + on_progress: impl Fn(&str), +) -> anyhow::Result<()> { + use anyhow::Context; + + let app_id = app_id_for_game(game).ok_or_else(|| { + anyhow::anyhow!( + "dune uses Docker images, not SteamCMD — cannot run app_update for game '{game}'" + ) + })?; + + let install_dir_str = install_dir + .to_str() + .with_context(|| format!("install_dir '{}' is not valid UTF-8", install_dir.display()))?; + + let mut args: Vec = vec![ + "+force_install_dir".to_string(), + install_dir_str.to_string(), + "+login".to_string(), + "anonymous".to_string(), + "+app_update".to_string(), + app_id.to_string(), + ]; + if validate { + args.push("validate".to_string()); + } + args.push("+quit".to_string()); + + tracing::info!( + "steamcmd: starting update for game={game} app_id={app_id} install_dir={} validate={validate}", + install_dir.display() + ); + + let mut child = Command::new(steamcmd_path) + .args(&args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .with_context(|| format!("spawning steamcmd binary '{steamcmd_path}'"))?; + + let stdout = child.stdout.take().expect("stdout was piped"); + let mut lines = BufReader::new(stdout).lines(); + + while let Some(line) = lines.next_line().await.context("reading steamcmd stdout")? { + tracing::debug!("steamcmd: {line}"); + on_progress(&line); + } + + let status = child.wait().await.context("waiting for steamcmd to exit")?; + if status.success() { + tracing::info!("steamcmd: update completed successfully for game={game}"); + Ok(()) + } else { + let code = status.code().unwrap_or(-1); + anyhow::bail!("steamcmd exited with non-zero status {code} for game={game}") + } +} + diff --git a/corrosion-host-agent/src/subjects.rs b/corrosion-host-agent/src/subjects.rs index 21ba09e..e075ec9 100644 --- a/corrosion-host-agent/src/subjects.rs +++ b/corrosion-host-agent/src/subjects.rs @@ -26,3 +26,14 @@ pub fn instance_cmd(license: &str, instance: &str) -> String { pub fn instance_status(license: &str, instance: &str) -> String { format!("corrosion.{license}.{instance}.status") } + +/// Per-instance SteamCMD progress stream. Lines from `steamcmd` stdout are +/// published here so the panel can display live update output. +pub fn instance_steam_status(license: &str, instance: &str) -> String { + format!("corrosion.{license}.{instance}.steam_status") +} + +/// Per-instance file manager command channel (request-reply). +pub fn instance_files_cmd(license: &str, instance: &str) -> String { + format!("corrosion.{license}.{instance}.files.cmd") +} diff --git a/corrosion-host-agent/tests/filemanager.rs b/corrosion-host-agent/tests/filemanager.rs new file mode 100644 index 0000000..189ecf3 --- /dev/null +++ b/corrosion-host-agent/tests/filemanager.rs @@ -0,0 +1,405 @@ +//! Integration tests for the jailed file manager. +//! +//! Each test runs in a real tempdir on the host filesystem. The jail-escape +//! tests are the security-critical section: any path that resolves outside the +//! instance root MUST be rejected regardless of how the escape is attempted. +//! +//! Coverage: +//! - Functional: list, write, read roundtrip, mkdir, rename, delete +//! - Security: dotdot traversal, absolute path injection, symlink escape +//! (POSIX symlinks only — `#[cfg(unix)]`) + +use corrosion_host_agent::filemanager; +use std::path::Path; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Create a temporary directory and return its path. The directory is +/// automatically cleaned up when the `TempDir` is dropped. +fn tempdir() -> tempfile::TempDir { + tempfile::tempdir().expect("create tempdir") +} + +// --------------------------------------------------------------------------- +// Functional tests +// --------------------------------------------------------------------------- + +#[test] +fn write_read_roundtrip() { + let dir = tempdir(); + let root = dir.path(); + + let content = "hello from the file manager\nline 2\n"; + filemanager::write(root, "test.txt", content).expect("write should succeed"); + + let got = filemanager::read(root, "test.txt").expect("read should succeed"); + assert_eq!(got, content); +} + +#[test] +fn list_returns_written_file() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::write(root, "server.cfg", "hostname MyServer\n").expect("write"); + + let entries = filemanager::list(root, "").expect("list root"); + let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + assert!(names.contains(&"server.cfg"), "expected 'server.cfg' in listing, got {names:?}"); +} + +#[test] +fn list_empty_root_is_empty() { + let dir = tempdir(); + let entries = filemanager::list(dir.path(), "").expect("list empty root"); + assert!(entries.is_empty(), "fresh tempdir should have no entries"); +} + +#[test] +fn mkdir_creates_directory() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::mkdir(root, "cfg/custom").expect("mkdir should succeed"); + + assert!(root.join("cfg/custom").is_dir(), "directory should exist after mkdir"); +} + +#[test] +fn mkdir_creates_nested_dirs() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::mkdir(root, "a/b/c/d").expect("mkdir nested"); + assert!(root.join("a/b/c/d").is_dir()); +} + +#[test] +fn write_creates_parent_dirs() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::write(root, "subdir/deep/file.txt", "data").expect("write with auto-mkdir"); + let content = filemanager::read(root, "subdir/deep/file.txt").expect("read"); + assert_eq!(content, "data"); +} + +#[test] +fn rename_file() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::write(root, "old.txt", "content").expect("write"); + filemanager::rename(root, "old.txt", "new.txt").expect("rename"); + + assert!(!root.join("old.txt").exists(), "old.txt should be gone"); + assert!(root.join("new.txt").exists(), "new.txt should exist"); + + let content = filemanager::read(root, "new.txt").expect("read renamed"); + assert_eq!(content, "content"); +} + +#[test] +fn rename_rejects_separator_in_new_name() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::write(root, "file.txt", "data").expect("write"); + + let err = filemanager::rename(root, "file.txt", "subdir/escape.txt") + .expect_err("rename with path separator must fail"); + assert!( + err.to_string().contains("separator"), + "error should mention separator: {err}" + ); +} + +#[test] +fn delete_file() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::write(root, "todelete.txt", "bye").expect("write"); + assert!(root.join("todelete.txt").exists()); + + filemanager::delete(root, "todelete.txt").expect("delete"); + assert!(!root.join("todelete.txt").exists()); +} + +#[test] +fn delete_directory_recursive() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::mkdir(root, "tree/sub").expect("mkdir"); + filemanager::write(root, "tree/sub/file.txt", "x").expect("write"); + assert!(root.join("tree").is_dir()); + + filemanager::delete(root, "tree").expect("delete tree"); + assert!(!root.join("tree").exists(), "directory tree should be deleted"); +} + +#[test] +fn mkfile_creates_empty_file() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::mkfile(root, "empty.txt").expect("mkfile"); + let content = filemanager::read(root, "empty.txt").expect("read empty file"); + assert_eq!(content, ""); +} + +#[test] +fn copy_file() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::write(root, "source.txt", "original").expect("write source"); + filemanager::copy(root, "source.txt", "dest.txt").expect("copy"); + + let src = filemanager::read(root, "source.txt").expect("read source after copy"); + let dst = filemanager::read(root, "dest.txt").expect("read destination"); + assert_eq!(src, "original"); + assert_eq!(dst, "original"); +} + +#[test] +fn move_file() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::write(root, "moveme.txt", "payload").expect("write"); + filemanager::move_path(root, "moveme.txt", "moved.txt").expect("move"); + + assert!(!root.join("moveme.txt").exists(), "source should be gone"); + let content = filemanager::read(root, "moved.txt").expect("read after move"); + assert_eq!(content, "payload"); +} + +#[test] +fn list_entry_fields_are_populated() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::write(root, "check.txt", "abcde").expect("write"); + filemanager::mkdir(root, "subdir").expect("mkdir"); + + let entries = filemanager::list(root, "").expect("list"); + // Dirs sort before files. + let dir_entry = entries.iter().find(|e| e.name == "subdir").expect("subdir entry"); + assert!(dir_entry.is_dir); + assert_eq!(dir_entry.size, 0); + assert!(!dir_entry.modified.is_empty(), "modified should be set"); + + let file_entry = entries.iter().find(|e| e.name == "check.txt").expect("file entry"); + assert!(!file_entry.is_dir); + assert_eq!(file_entry.size, 5, "size should match byte count"); + // path should be relative and use forward slashes. + assert!(!file_entry.path.starts_with('/'), "path should be relative"); + assert!(!file_entry.path.contains('\\'), "path should use forward slashes"); +} + +// --------------------------------------------------------------------------- +// Security: jail-escape tests +// CRITICAL — these are the whole point of the jail abstraction. +// --------------------------------------------------------------------------- + +/// `../../etc/passwd` must never resolve outside the instance root. +#[test] +fn jail_rejects_dotdot_traversal() { + let dir = tempdir(); + let root = dir.path(); + + let err = filemanager::read(root, "../../etc/passwd") + .expect_err("dotdot traversal must be rejected"); + // Verify the error is security-related and not just "file not found". + let msg = err.to_string(); + assert!( + msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), + "error should mention jail escape for dotdot traversal, got: {msg}" + ); +} + +/// A deeply nested `../` chain must also be stopped. +#[test] +fn jail_rejects_deep_dotdot_traversal() { + let dir = tempdir(); + let root = dir.path(); + + let err = filemanager::read(root, "a/b/c/../../../../../../../../etc/shadow") + .expect_err("deep dotdot traversal must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("outside") || msg.contains("escapes") || msg.contains("escape") || msg.contains("absolute"), + "error should mention jail escape for deep traversal, got: {msg}" + ); +} + +/// An absolute path (e.g. `/etc/passwd`) must be rejected immediately — it +/// completely bypasses relative joining and should never be accepted. +#[test] +fn jail_rejects_absolute_path() { + let dir = tempdir(); + let root = dir.path(); + + let err = filemanager::read(root, "/etc/passwd") + .expect_err("absolute path must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), + "error should mention the absolute-path rejection, got: {msg}" + ); +} + +/// An absolute path to a Windows-style location must also be rejected. +#[test] +fn jail_rejects_absolute_windows_style_path() { + let dir = tempdir(); + let root = dir.path(); + + // On POSIX this is just treated as an absolute path starting with `/`. + // The test is intentionally platform-portable: any absolute path is bad. + let err = filemanager::read(root, "/tmp/evil") + .expect_err("absolute /tmp/evil must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), + "got: {msg}" + ); +} + +/// A symlink inside the root that points to a path outside the root must not +/// be followed. This is the critical symlink-escape vector. +#[cfg(unix)] +#[test] +fn jail_rejects_symlink_escape() { + let dir = tempdir(); + let root = dir.path(); + + // Create a directory outside the root to be the symlink target. + let outside = tempdir(); + let outside_file = outside.path().join("secret.txt"); + std::fs::write(&outside_file, "secret data").expect("write outside file"); + + // Plant a symlink inside the root pointing to the outside directory. + let link_path = root.join("evil_link"); + std::os::unix::fs::symlink(outside.path(), &link_path) + .expect("create symlink inside root"); + + // Attempt to read through the symlink. + let err = filemanager::read(root, "evil_link/secret.txt") + .expect_err("symlink escape must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), + "error should mention jail escape for symlink traversal, got: {msg}" + ); +} + +/// A symlink directly inside the root pointing to a file outside must be +/// rejected even when the path looks like a normal relative reference. +#[cfg(unix)] +#[test] +fn jail_rejects_symlink_pointing_directly_outside() { + let dir = tempdir(); + let root = dir.path(); + + // Symlink to /etc/passwd itself (or any outside path that exists or not). + let link_path = root.join("passwd_link"); + std::os::unix::fs::symlink(Path::new("/etc/passwd"), &link_path) + .expect("create symlink to /etc/passwd"); + + let err = filemanager::read(root, "passwd_link") + .expect_err("direct symlink outside root must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), + "error should mention jail escape, got: {msg}" + ); +} + +/// A symlink chain (symlink → symlink → outside) must also be caught. +#[cfg(unix)] +#[test] +fn jail_rejects_chained_symlink_escape() { + let dir = tempdir(); + let root = dir.path(); + + let outside = tempdir(); + + // Chain: root/link1 → root/link2 → outside/ + let link2_path = root.join("link2"); + std::os::unix::fs::symlink(outside.path(), &link2_path) + .expect("create link2"); + + let link1_path = root.join("link1"); + std::os::unix::fs::symlink(&link2_path, &link1_path) + .expect("create link1"); + + let err = filemanager::read(root, "link1") + .expect_err("chained symlink escape must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), + "chained symlink should be caught, got: {msg}" + ); +} + +// --------------------------------------------------------------------------- +// Dispatch layer tests +// --------------------------------------------------------------------------- + +#[test] +fn dispatch_list_returns_success() { + let dir = tempdir(); + let root = dir.path(); + + filemanager::write(root, "a.txt", "a").expect("write"); + + let req = filemanager::FileRequest { + op: "list".to_string(), + path: String::new(), + dest: None, + content: None, + name: None, + }; + let resp = filemanager::dispatch(root, &req); + assert_eq!(resp["status"], "success"); + assert!(resp["data"]["entries"].is_array()); +} + +#[test] +fn dispatch_unknown_op_returns_error() { + let dir = tempdir(); + let req = filemanager::FileRequest { + op: "explode".to_string(), + path: String::new(), + dest: None, + content: None, + name: None, + }; + let resp = filemanager::dispatch(dir.path(), &req); + assert_eq!(resp["status"], "error"); + assert!(resp["message"].as_str().unwrap().contains("unknown op")); +} + +#[test] +fn dispatch_escape_attempt_returns_error_not_panic() { + let dir = tempdir(); + let req = filemanager::FileRequest { + op: "read".to_string(), + path: "../../etc/passwd".to_string(), + dest: None, + content: None, + name: None, + }; + let resp = filemanager::dispatch(dir.path(), &req); + // Must return an error response, not panic or expose the file. + assert_eq!(resp["status"], "error", "escape attempt should return error status"); + assert!( + resp["message"].as_str().is_some(), + "error response must have a message" + ); +} diff --git a/corrosion-host-agent/tests/steamcmd.rs b/corrosion-host-agent/tests/steamcmd.rs new file mode 100644 index 0000000..d783f8c --- /dev/null +++ b/corrosion-host-agent/tests/steamcmd.rs @@ -0,0 +1,45 @@ +//! Unit tests for the SteamCMD module. +//! +//! Tests cover app ID resolution for all four supported games, including the +//! platform-specific Soulmask split, and verify that Dune correctly returns +//! `None` (it uses Docker images, not SteamCMD). + +use corrosion_host_agent::steamcmd::app_id_for_game; + +#[test] +fn rust_has_correct_app_id() { + assert_eq!(app_id_for_game("rust"), Some(258550)); +} + +#[test] +fn conan_has_correct_app_id() { + assert_eq!(app_id_for_game("conan"), Some(443030)); +} + +/// Soulmask returns the Windows server app ID on Windows builds, the Linux +/// dedicated server app ID on all other targets. +#[test] +#[cfg(windows)] +fn soulmask_windows_app_id() { + assert_eq!(app_id_for_game("soulmask"), Some(3017310)); +} + +#[test] +#[cfg(not(windows))] +fn soulmask_linux_app_id() { + assert_eq!(app_id_for_game("soulmask"), Some(3017300)); +} + +/// Dune uses Docker images — SteamCMD integration is explicitly unsupported. +#[test] +fn dune_has_no_app_id() { + assert_eq!(app_id_for_game("dune"), None); +} + +/// Unknown games also produce None; callers should treat this the same as +/// Dune (no SteamCMD support). +#[test] +fn unknown_game_returns_none() { + assert_eq!(app_id_for_game("minecraft"), None); + assert_eq!(app_id_for_game(""), None); +} diff --git a/corrosion-host-agent/tests/supervisor.rs b/corrosion-host-agent/tests/supervisor.rs index 9e7db4f..62d31ee 100644 --- a/corrosion-host-agent/tests/supervisor.rs +++ b/corrosion-host-agent/tests/supervisor.rs @@ -20,6 +20,7 @@ fn managed_instance(executable: &str, args: &[&str]) -> InstanceConfig { args: args.iter().map(|s| s.to_string()).collect(), working_dir: None, rcon: None, + steamcmd: None, } }