test(peer): cover serve gating dispatch
Add focused serve-side tests for the gates around peer requests. GetGame now has coverage for the non-catalog, active-operation, and missing-sentinel cases that should return GameNotFound instead of exposing local files. The full-file and chunk handlers both depend on the same transfer gate before touching the QUIC send stream. Extract that gate into a small helper and test the same cases there, plus the existing local-path exclusion, so both dispatch paths stay aligned without adding fake QUIC stream plumbing. Test Plan: - git diff --check - just fmt - just clippy - just test Follow-up-Plan: FOLLOW_UP_2.md
This commit is contained in:
@@ -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
|
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 {
|
fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
|
||||||
let normalised = relative_path.replace('\\', "/");
|
let normalised = relative_path.replace('\\', "/");
|
||||||
let mut parts = normalised.split('/').filter(|part| !part.is_empty());
|
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 mut tx = framed_tx.into_inner();
|
||||||
let game_dir = ctx.game_dir.read().await.clone();
|
let game_dir = ctx.game_dir.read().await.clone();
|
||||||
if path_points_inside_local(&desc.game_id, &desc.relative_path)
|
if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await {
|
||||||
|| !can_serve_game(ctx, &game_dir, &desc.game_id).await
|
|
||||||
{
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Declining GetGameFileData for {} because the game is not currently transferable",
|
"Declining GetGameFileData for {} because the game is not currently transferable",
|
||||||
desc.relative_path
|
desc.relative_path
|
||||||
@@ -347,9 +355,7 @@ async fn handle_file_chunk_request(
|
|||||||
|
|
||||||
let mut tx = framed_tx.into_inner();
|
let mut tx = framed_tx.into_inner();
|
||||||
let game_dir = ctx.game_dir.read().await.clone();
|
let game_dir = ctx.game_dir.read().await.clone();
|
||||||
if path_points_inside_local(&game_id, &relative_path)
|
if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await {
|
||||||
|| !can_serve_game(ctx, &game_dir, &game_id).await
|
|
||||||
{
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
|
"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<SocketAddr>, g
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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<String>) -> 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]
|
#[test]
|
||||||
fn local_relative_paths_are_never_transferable() {
|
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/version.ini"));
|
||||||
assert!(!path_points_inside_local("game", "game/archive.eti"));
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user