use std::{io::ErrorKind, path::Path}; use lanspread_db::db::GameFileDescription; use tokio::fs::OpenOptions; use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path}; const SYNC_DIR: &str = ".sync"; /// Prepares storage for game files by creating directories and pre-allocating files. pub(super) async fn prepare_game_storage( games_folder: &Path, file_descs: &[GameFileDescription], ) -> eyre::Result<()> { for desc in file_descs { if desc.is_version_ini() { continue; } let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?; if desc.is_dir { tokio::fs::create_dir_all(&validated_path).await?; } else { if let Some(parent) = validated_path.parent() { tokio::fs::create_dir_all(parent).await?; } let file = OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&validated_path) .await?; let size = desc.size; if let Err(e) = file.set_len(size).await { log::warn!( "Failed to pre-allocate file {} (size: {}): {}", desc.relative_path, size, e ); } else { log::debug!( "Pre-allocated file {} with {} bytes", desc.relative_path, size ); } } } Ok(()) } /// Discards the peer-owned downloaded payload after a cancelled transfer. /// /// Downloads own the root archive/cache files, but not `local/` or install /// transaction metadata. Preserving those paths lets a cancelled update settle /// as a local-only install instead of deleting user-owned extracted files. pub(super) async fn discard_cancelled_download( games_folder: &Path, game_id: &str, ) -> eyre::Result<()> { let game_root = games_folder.join(game_id); let Some(metadata) = symlink_metadata_if_exists(&game_root).await? else { return Ok(()); }; if metadata.file_type().is_symlink() { eyre::bail!( "refusing to discard cancelled download through symlink root {}", game_root.display() ); } if !metadata.is_dir() { eyre::bail!( "refusing to discard cancelled download from non-directory root {}", game_root.display() ); } let mut entries = tokio::fs::read_dir(&game_root).await?; while let Some(entry) = entries.next_entry().await? { let name = entry.file_name(); if name .to_str() .is_some_and(should_preserve_on_download_discard) { continue; } remove_entry(&entry.path()).await?; } remove_dir_if_empty(&game_root).await } fn should_preserve_on_download_discard(name: &str) -> bool { is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR } async fn remove_entry(path: &Path) -> eyre::Result<()> { let Some(metadata) = symlink_metadata_if_exists(path).await? else { return Ok(()); }; if metadata.file_type().is_symlink() || metadata.is_file() { remove_file_if_exists(path).await } else if metadata.is_dir() { tokio::fs::remove_dir_all(path).await?; Ok(()) } else { remove_file_if_exists(path).await } } async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> { match tokio::fs::remove_file(path).await { Ok(()) => Ok(()), Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), Err(err) => Err(err.into()), } } async fn remove_dir_if_empty(path: &Path) -> eyre::Result<()> { match tokio::fs::remove_dir(path).await { Ok(()) => Ok(()), Err(err) if matches!( err.kind(), ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty ) => { Ok(()) } Err(err) => Err(err.into()), } } async fn symlink_metadata_if_exists(path: &Path) -> eyre::Result> { 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 lanspread_db::db::GameFileDescription; 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 prepare_game_storage_skips_version_ini_sentinel() { let temp = TempDir::new("lanspread-download"); let descs = vec![GameFileDescription { game_id: "game".to_string(), relative_path: "game/version.ini".to_string(), is_dir: false, size: 8, }]; prepare_game_storage(temp.path(), &descs) .await .expect("storage preparation should succeed"); assert!(!temp.path().join("game").join("version.ini").exists()); } #[tokio::test] async fn discard_cancelled_download_removes_peer_owned_payload() { let temp = TempDir::new("lanspread-download-discard"); let root = temp.game_root(); write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join(".version.ini.tmp"), b"tmp"); write_file(&root.join(".version.ini.discarded"), b"old"); write_file(&root.join("archive.eti"), b"partial"); write_file(&root.join("nested").join("payload.bin"), b"partial"); discard_cancelled_download(temp.path(), "game") .await .expect("cancelled payload should be discarded"); assert!(!root.exists()); } #[tokio::test] async fn discard_cancelled_download_preserves_local_install_state() { let temp = TempDir::new("lanspread-download-discard-local"); let root = temp.game_root(); write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("archive.eti"), b"partial"); write_file(&root.join("local").join("save.dat"), b"user-data"); write_file(&root.join(".local.backup").join(".lanspread_owned"), b""); discard_cancelled_download(temp.path(), "game") .await .expect("cancelled payload should be discarded"); assert!(!root.join("version.ini").exists()); assert!(!root.join("archive.eti").exists()); assert_eq!( std::fs::read(root.join("local").join("save.dat")) .expect("local install should remain"), b"user-data" ); assert!(root.join(".local.backup").is_dir()); } }