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>
127 lines
4.4 KiB
Rust
127 lines
4.4 KiB
Rust
//! 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<u32> {
|
|
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<std::path::PathBuf>,
|
|
|
|
/// 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<String> = 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}")
|
|
}
|
|
}
|
|
|