feat(peer): remove downloaded game files safely

Downloaded but uninstalled games can still occupy significant disk space. Add a
separate removal path for that state instead of overloading uninstall, which is
reserved for deleting only `local/` installs.

The peer runtime now exposes `RemoveDownloadedGame` with matching lifecycle
and active-operation events. The filesystem delete is intentionally strict: the
id must be a catalog game and a single path component, the target must be a
direct child of the configured game directory, the root must not be a symlink,
it must have a regular root-level `version.ini`, and it must not contain
`local/`, `.local.installing/`, or `.local.backup/`. Only then do we recursively
remove the game root.

The Tauri bridge exposes this as `remove_downloaded_game`, the frontend shows a
matching danger action only for downloaded-but-uninstalled games, and a
confirmation dialog warns that re-downloading can take a long time.

Test Plan:
- git diff --check
- just fmt
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build

Refs: user redesign nitpick about removing downloaded uninstalled games
This commit is contained in:
2026-05-19 21:00:44 +02:00
parent 74d9266723
commit 62ceb063ac
18 changed files with 628 additions and 31 deletions
+2
View File
@@ -1,6 +1,8 @@
mod intent;
mod remove;
mod transaction;
pub mod unpack;
pub use remove::remove_downloaded;
pub use transaction::{install, recover_on_startup, uninstall, update};
pub use unpack::{UnpackFuture, Unpacker};
+212
View File
@@ -0,0 +1,212 @@
use std::{
ffi::OsStr,
io::ErrorKind,
path::{Component, Path, PathBuf},
};
use eyre::{WrapErr, bail};
const LOCAL_DIR: &str = "local";
const INSTALLING_DIR: &str = ".local.installing";
const BACKUP_DIR: &str = ".local.backup";
const VERSION_INI: &str = "version.ini";
/// Remove the downloaded files for an uninstalled game root.
///
/// This is intentionally stricter than the scanner: callers must pass a catalog
/// id that is a single path component, the target must be a direct child of the
/// configured game directory, and the root must still look like a downloaded
/// but uninstalled game immediately before recursive deletion.
pub async fn remove_downloaded(game_dir: &Path, id: &str) -> eyre::Result<()> {
validate_game_id(id)?;
let game_dir = canonical_game_dir(game_dir).await?;
let game_root = game_dir.join(id);
let Some(root_metadata) = symlink_metadata_if_exists(&game_root).await? else {
return Ok(());
};
if root_metadata.file_type().is_symlink() {
bail!(
"refusing to remove symlink game root {}",
game_root.display()
);
}
if !root_metadata.is_dir() {
bail!(
"refusing to remove non-directory game root {}",
game_root.display()
);
}
let game_root = tokio::fs::canonicalize(&game_root)
.await
.wrap_err_with(|| format!("failed to canonicalize {}", game_root.display()))?;
ensure_direct_child(&game_dir, &game_root, id)?;
ensure_downloaded_uninstalled_root(&game_root).await?;
tokio::fs::remove_dir_all(&game_root)
.await
.wrap_err_with(|| format!("failed to remove downloaded game {}", game_root.display()))
}
fn validate_game_id(id: &str) -> eyre::Result<()> {
let mut components = Path::new(id).components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(()),
_ => bail!("refusing to remove invalid game id {id:?}"),
}
}
async fn canonical_game_dir(game_dir: &Path) -> eyre::Result<PathBuf> {
let game_dir = tokio::fs::canonicalize(game_dir)
.await
.wrap_err_with(|| format!("failed to canonicalize game dir {}", game_dir.display()))?;
let metadata = tokio::fs::metadata(&game_dir).await?;
if !metadata.is_dir() {
bail!("game dir is not a directory: {}", game_dir.display());
}
Ok(game_dir)
}
fn ensure_direct_child(game_dir: &Path, game_root: &Path, id: &str) -> eyre::Result<()> {
if game_root == game_dir
|| game_root.parent() != Some(game_dir)
|| game_root.file_name() != Some(OsStr::new(id))
{
bail!(
"refusing to remove game root outside direct game-dir child: {}",
game_root.display()
);
}
Ok(())
}
async fn ensure_downloaded_uninstalled_root(game_root: &Path) -> eyre::Result<()> {
let version_path = game_root.join(VERSION_INI);
let version_metadata = tokio::fs::symlink_metadata(&version_path)
.await
.wrap_err_with(|| format!("download sentinel is missing: {}", version_path.display()))?;
if version_metadata.file_type().is_symlink() || !version_metadata.is_file() {
bail!(
"refusing to remove game without a regular version.ini sentinel: {}",
game_root.display()
);
}
ensure_absent(&game_root.join(LOCAL_DIR), "local install").await?;
ensure_absent(&game_root.join(INSTALLING_DIR), "install staging").await?;
ensure_absent(&game_root.join(BACKUP_DIR), "install backup").await
}
async fn ensure_absent(path: &Path, label: &str) -> eyre::Result<()> {
if symlink_metadata_if_exists(path).await?.is_some() {
bail!(
"refusing to remove game root with {label}: {}",
path.display()
);
}
Ok(())
}
async fn symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<std::fs::Metadata>> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => Ok(Some(metadata)),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::test_support::TempDir;
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
#[tokio::test]
async fn remove_downloaded_deletes_only_requested_game_root() {
let temp = TempDir::new("lanspread-remove-download");
let root = temp.game_root();
let sibling = temp.path().join("sibling");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
write_file(&sibling.join("version.ini"), b"20250101");
remove_downloaded(temp.path(), "game")
.await
.expect("downloaded game root should be removed");
assert!(!root.exists());
assert!(sibling.join("version.ini").is_file());
}
#[tokio::test]
async fn remove_downloaded_refuses_installed_game() {
let temp = TempDir::new("lanspread-remove-installed");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("payload.txt"), b"installed");
let err = remove_downloaded(temp.path(), "game")
.await
.expect_err("installed game must not be removed");
assert!(err.to_string().contains("local install"));
assert!(root.join("version.ini").is_file());
assert!(root.join("local").join("payload.txt").is_file());
}
#[tokio::test]
async fn remove_downloaded_refuses_missing_download_sentinel() {
let temp = TempDir::new("lanspread-remove-missing-sentinel");
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
let err = remove_downloaded(temp.path(), "game")
.await
.expect_err("undownloaded game root must not be removed");
assert!(err.to_string().contains("download sentinel is missing"));
assert!(root.join("game.eti").is_file());
}
#[tokio::test]
async fn remove_downloaded_rejects_path_traversal_id() {
let temp = TempDir::new("lanspread-remove-traversal");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
let err = remove_downloaded(temp.path(), "../game")
.await
.expect_err("path traversal id must be rejected");
assert!(err.to_string().contains("invalid game id"));
assert!(root.join("version.ini").is_file());
}
#[cfg(unix)]
#[tokio::test]
async fn remove_downloaded_refuses_symlink_game_root() {
use std::os::unix::fs::symlink;
let temp = TempDir::new("lanspread-remove-symlink");
let outside = temp.path().join("outside");
write_file(&outside.join("version.ini"), b"20250101");
symlink(&outside, temp.game_root()).expect("symlink should be created");
let err = remove_downloaded(temp.path(), "game")
.await
.expect_err("symlink game root must be rejected");
assert!(err.to_string().contains("symlink game root"));
assert!(outside.join("version.ini").is_file());
}
}