feat(host-agent): Phase 1c — SteamCMD update + jailed file manager
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 <noreply@anthropic.com>
This commit is contained in:
405
corrosion-host-agent/tests/filemanager.rs
Normal file
405
corrosion-host-agent/tests/filemanager.rs
Normal file
@@ -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"
|
||||
);
|
||||
}
|
||||
45
corrosion-host-agent/tests/steamcmd.rs
Normal file
45
corrosion-host-agent/tests/steamcmd.rs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user