diff --git a/crates/lanspread-peer/src/services/stream.rs b/crates/lanspread-peer/src/services/stream.rs index 106082d..bd1d98e 100644 --- a/crates/lanspread-peer/src/services/stream.rs +++ b/crates/lanspread-peer/src/services/stream.rs @@ -296,6 +296,16 @@ async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str local_download_available(game_dir, game_id, &active_operations, &catalog).await } +async fn can_dispatch_file_transfer( + ctx: &PeerCtx, + game_dir: &std::path::Path, + game_id: &str, + relative_path: &str, +) -> bool { + !path_points_inside_local(game_id, relative_path) + && can_serve_game(ctx, game_dir, game_id).await +} + fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool { let normalised = relative_path.replace('\\', "/"); let mut parts = normalised.split('/').filter(|part| !part.is_empty()); @@ -318,9 +328,7 @@ async fn handle_file_data_request( let mut tx = framed_tx.into_inner(); let game_dir = ctx.game_dir.read().await.clone(); - if path_points_inside_local(&desc.game_id, &desc.relative_path) - || !can_serve_game(ctx, &game_dir, &desc.game_id).await - { + if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await { log::info!( "Declining GetGameFileData for {} because the game is not currently transferable", desc.relative_path @@ -347,9 +355,7 @@ async fn handle_file_chunk_request( let mut tx = framed_tx.into_inner(); let game_dir = ctx.game_dir.read().await.clone(); - if path_points_inside_local(&game_id, &relative_path) - || !can_serve_game(ctx, &game_dir, &game_id).await - { + if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await { log::info!( "Declining GetGameFileChunk for {relative_path} because the game is not currently transferable" ); @@ -389,7 +395,87 @@ async fn handle_announce_games(ctx: &PeerCtx, remote_addr: Option, g #[cfg(test)] mod tests { - use super::path_points_inside_local; + use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{SystemTime, UNIX_EPOCH}, + }; + + use tokio::sync::{RwLock, mpsc}; + use tokio_util::{sync::CancellationToken, task::TaskTracker}; + + use super::*; + use crate::{ + UnpackFuture, + Unpacker, + context::{Ctx, OperationKind}, + peer_db::PeerGameDB, + }; + + struct TempDir(PathBuf); + + static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + + impl TempDir { + fn new() -> Self { + let mut path = std::env::temp_dir(); + let unique_id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + path.push(format!( + "lanspread-stream-{}-{}-{}", + std::process::id(), + unique_id, + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("temp dir should be created"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + struct NoopUnpacker; + + impl Unpacker for NoopUnpacker { + fn unpack<'a>(&'a self, _archive: &'a Path, _dest: &'a Path) -> UnpackFuture<'a> { + Box::pin(async { Ok(()) }) + } + } + + 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"); + } + + fn test_ctx(game_dir: PathBuf, catalog: HashSet) -> PeerCtx { + let (tx_notify_ui, _rx) = mpsc::unbounded_channel(); + Ctx::new( + Arc::new(RwLock::new(PeerGameDB::new())), + "peer".to_string(), + game_dir, + Arc::new(NoopUnpacker), + CancellationToken::new(), + TaskTracker::new(), + Arc::new(RwLock::new(catalog)), + ) + .to_peer_ctx(tx_notify_ui) + } #[test] fn local_relative_paths_are_never_transferable() { @@ -399,4 +485,99 @@ mod tests { assert!(!path_points_inside_local("game", "game/version.ini")); assert!(!path_points_inside_local("game", "game/archive.eti")); } + + #[tokio::test] + async fn get_game_response_respects_serve_gates() { + let temp = TempDir::new(); + write_file(&temp.path().join("ready").join("version.ini"), b"20250101"); + write_file( + &temp.path().join("non-catalog").join("version.ini"), + b"20250101", + ); + write_file(&temp.path().join("active").join("version.ini"), b"20250101"); + std::fs::create_dir_all(temp.path().join("missing-sentinel")) + .expect("missing sentinel root should be created"); + + let ctx = test_ctx( + temp.path().to_path_buf(), + HashSet::from([ + "ready".to_string(), + "active".to_string(), + "missing-sentinel".to_string(), + ]), + ); + ctx.active_operations + .write() + .await + .insert("active".to_string(), OperationKind::Downloading); + + assert!(matches!( + get_game_response(&ctx, "ready".to_string()).await, + Response::GetGame { id, .. } if id == "ready" + )); + assert!(matches!( + get_game_response(&ctx, "non-catalog".to_string()).await, + Response::GameNotFound(id) if id == "non-catalog" + )); + assert!(matches!( + get_game_response(&ctx, "active".to_string()).await, + Response::GameNotFound(id) if id == "active" + )); + assert!(matches!( + get_game_response(&ctx, "missing-sentinel".to_string()).await, + Response::GameNotFound(id) if id == "missing-sentinel" + )); + } + + #[tokio::test] + async fn file_transfer_dispatch_respects_serve_gates() { + let temp = TempDir::new(); + write_file(&temp.path().join("ready").join("version.ini"), b"20250101"); + write_file( + &temp.path().join("non-catalog").join("version.ini"), + b"20250101", + ); + write_file(&temp.path().join("active").join("version.ini"), b"20250101"); + std::fs::create_dir_all(temp.path().join("missing-sentinel")) + .expect("missing sentinel root should be created"); + + let ctx = test_ctx( + temp.path().to_path_buf(), + HashSet::from([ + "ready".to_string(), + "active".to_string(), + "missing-sentinel".to_string(), + ]), + ); + ctx.active_operations + .write() + .await + .insert("active".to_string(), OperationKind::Downloading); + + assert!(can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/version.ini").await); + assert!( + !can_dispatch_file_transfer( + &ctx, + temp.path(), + "non-catalog", + "non-catalog/version.ini", + ) + .await + ); + assert!( + !can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await + ); + assert!( + !can_dispatch_file_transfer( + &ctx, + temp.path(), + "missing-sentinel", + "missing-sentinel/archive.eti", + ) + .await + ); + assert!( + !can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/local/save.dat").await + ); + } }