//! Catalog-authorized outbound chunk and streamed-install admission. use std::{ fs::File, path::PathBuf, sync::{ Arc, atomic::{AtomicU64, Ordering}, }, }; use lanspread_db::{ content_manifest::{ CanonicalCatalogPath, CatalogContentManifest, CatalogEntryKind, CatalogFileEntry, ContentId, }, db::Availability, }; use lanspread_proto::MAX_CONTROL_FRAME_BYTES; use s2n_quic::{application, stream::SendStream}; use tokio_util::{ codec::{FramedWrite, LengthDelimitedCodec}, sync::CancellationToken, }; use crate::{ context::PeerCtx, download::open_catalog_file_for_read, local_games::version_ini_is_regular_file, peer::send_game_file_chunk, scoped_blocking::scoped_blocking, stream_install::{send_game_install_stream, send_stream_install_error}, }; type ResponseWriter = FramedWrite; pub(super) enum ChunkDispatch { Finished(ResponseWriter), Reset(ResponseWriter), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ChunkSendDisposition { Finished, Reset, } fn chunk_send_disposition(result: &eyre::Result<()>) -> ChunkSendDisposition { if result.is_ok() { ChunkSendDisposition::Finished } else { ChunkSendDisposition::Reset } } fn control_codec() -> LengthDelimitedCodec { LengthDelimitedCodec::builder() .max_frame_length(MAX_CONTROL_FRAME_BYTES) .new_codec() } /// Cheap identity gate backed by the compact content index: no manifest body /// is read or retained for a game ID or content ID this catalog does not /// publish. fn content_identity_matches( ctx: &PeerCtx, game_id: &str, content_id: ContentId, request_label: &str, ) -> bool { match ctx.catalog.content_identity(game_id) { Some(identity) if identity.content_id == content_id => true, Some(_) => { log::warn!( "Declining {request_label} for {game_id}: requested content {content_id} does not match the local catalog" ); false } None => { log::warn!("Declining {request_label} for unknown catalog game {game_id}"); false } } } /// Resolves the manifest of a game that has already passed the identity and /// local-readiness gates. Every publishable game had its manifest primed by /// `prime_library_manifests` before its library revision became visible, so /// this reads the validated cache only and never touches disk on the /// public request path. fn load_expected_catalog_manifest( ctx: &PeerCtx, game_id: &str, ) -> Option> { let catalog = Arc::clone(&ctx.catalog); let manifest_game_id = game_id.to_owned(); match scoped_blocking(move || catalog.cached_manifest(&manifest_game_id)) { Ok(manifest) => Some(manifest), Err(error) => { log::error!("Failed to load catalog content manifest for {game_id}: {error}"); None } } } async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool { if ctx.recovery_quarantine.is_blocked(game_dir, game_id) || ctx.active_operations.read().await.contains_key(game_id) { return false; } let summary = ctx.local_library.read().await.games.get(game_id).cloned(); let Some(summary) = summary else { return false; }; if !summary.downloaded || summary.availability != Availability::Ready { return false; } let catalog = ctx.catalog.catalog(); if !catalog.contains(game_id) { return false; } let expected_version = catalog.expected_version(game_id).map(str::to_owned); if expected_version .as_deref() .is_some_and(|expected| summary.eti_version.as_deref() != Some(expected)) { return false; } let game_root = game_dir.join(game_id); if !version_ini_is_regular_file(&game_root).await { return false; } let expected_version_for_read = expected_version.clone(); scoped_blocking(move || { expected_version_for_read.as_deref().is_none_or(|expected| { lanspread_db::db::read_version_from_ini(&game_root) .is_ok_and(|version| version.as_deref() == Some(expected)) }) }) } fn authorize_catalog_file_request<'a>( manifest: &'a CatalogContentManifest, game_id: &str, content_id: ContentId, relative_path: &CanonicalCatalogPath, offset: u64, length: u64, ) -> Option<&'a CatalogFileEntry> { if manifest.game_id() != game_id || manifest.content_id() != content_id { return None; } // Exact lookup intentionally performs no normalization. Manifest keys have // already passed the canonical, portable, and reserved-path policy. let entry = manifest.file_entry(relative_path.as_str())?; if entry.kind() != CatalogEntryKind::File || !catalog_chunk_range_is_exact(entry.size(), manifest.chunk_size(), offset, length) { return None; } Some(entry) } fn catalog_chunk_range_is_exact(file_size: u64, chunk_size: u64, offset: u64, length: u64) -> bool { if chunk_size == 0 { return false; } if file_size == 0 { return offset == 0 && length == 0; } offset < file_size && offset.is_multiple_of(chunk_size) && length == std::cmp::min(chunk_size, file_size - offset) } static NEXT_TRANSFER_ID: AtomicU64 = AtomicU64::new(1); struct TransferGuard { game_id: String, id: u64, cancel_token: CancellationToken, active_outbound_transfers: crate::context::OutboundTransfers, notifier: crate::context::OutboundTransferNotifier, armed: bool, } impl TransferGuard { async fn new( game_id: String, active_outbound_transfers: crate::context::OutboundTransfers, notifier: crate::context::OutboundTransferNotifier, shutdown: &CancellationToken, ) -> (Self, CancellationToken) { let id = NEXT_TRANSFER_ID.fetch_add(1, Ordering::SeqCst); let token = shutdown.child_token(); { let mut active = active_outbound_transfers.write().await; active .entry(game_id.clone()) .or_default() .push((id, token.clone())); } notifier.notify(); ( Self { game_id, id, cancel_token: token.clone(), active_outbound_transfers, notifier, armed: true, }, token, ) } /// Removes the registry entry before returning. Dropping this future while /// it waits retains fail-closed tracking and cancels the transfer. async fn finish(mut self) { { let mut active = self.active_outbound_transfers.write().await; if let Some(tokens) = active.get_mut(&self.game_id) { tokens.retain(|(transfer_id, _)| *transfer_id != self.id); if tokens.is_empty() { active.remove(&self.game_id); } } } self.armed = false; self.notifier.notify(); } } impl Drop for TransferGuard { fn drop(&mut self) { if !self.armed { return; } log::error!( "Outbound transfer guard for {} ended unexpectedly; retaining transfer tracking until process restart", self.game_id ); self.cancel_token.cancel(); } } #[derive(Clone, Copy)] enum OutboundTransferRequest<'a> { CatalogChunk { content_id: ContentId, relative_path: &'a CanonicalCatalogPath, offset: u64, length: u64, }, StreamInstall { content_id: ContentId, }, } enum AdmittedOutboundPayload { CatalogFile { file: File, }, StreamInstall { game_dir: PathBuf, manifest: Arc, }, } struct AdmittedOutboundTransfer { guard: TransferGuard, cancel_token: CancellationToken, payload: AdmittedOutboundPayload, } /// Validates content identity before readiness checks, filesystem opens, /// transfer registration, or provider work. `SetGameDir` holds the same /// admission barrier while draining the prior directory epoch. /// /// Ordering matters for cost: the compact content index answers identity /// and streamed-install support without touching disk, and local readiness /// is an in-memory lookup. Only a request that passes both is allowed to /// load (and thereby cache) the full catalog manifest, so anonymous requests /// for games this node does not serve cannot populate the manifest cache. async fn admit_outbound_transfer( ctx: &PeerCtx, game_id: &str, request: OutboundTransferRequest<'_>, stream_shutdown: &CancellationToken, ) -> Option { let admission = ctx.operation_admission.lock().await; if stream_shutdown.is_cancelled() { return None; } let game_dir = ctx.game_dir.read().await.clone(); let payload = match request { OutboundTransferRequest::CatalogChunk { content_id, relative_path, offset, length, } => { if !content_identity_matches(ctx, game_id, content_id, "catalog chunk") { return None; } if !can_serve_game(ctx, &game_dir, game_id).await { return None; } let manifest = load_expected_catalog_manifest(ctx, game_id)?; let authorized_entry = authorize_catalog_file_request( &manifest, game_id, content_id, relative_path, offset, length, )?; let file = match open_catalog_file_for_read( &game_dir, game_id, authorized_entry.canonical_path(), authorized_entry.size(), ) { Ok(file) => file, Err(error) => { log::warn!("Declining catalog file transfer for {relative_path}: {error}"); return None; } }; AdmittedOutboundPayload::CatalogFile { file } } OutboundTransferRequest::StreamInstall { content_id } => { if !content_identity_matches(ctx, game_id, content_id, "StreamInstall") || !can_serve_game(ctx, &game_dir, game_id).await { return None; } let manifest = load_expected_catalog_manifest(ctx, game_id)?; if !manifest.supports_streamed_install() { return None; } AdmittedOutboundPayload::StreamInstall { game_dir, manifest } } }; if stream_shutdown.is_cancelled() { return None; } let (guard, cancel_token) = TransferGuard::new( game_id.to_string(), ctx.active_outbound_transfers.clone(), ctx.outbound_transfer_notifier.clone(), stream_shutdown, ) .await; drop(admission); Some(AdmittedOutboundTransfer { guard, cancel_token, payload, }) } #[allow(clippy::too_many_arguments)] pub(super) async fn handle_file_chunk_request( ctx: &PeerCtx, game_id: String, content_id: ContentId, relative_path: CanonicalCatalogPath, offset: u64, length: u64, framed_tx: ResponseWriter, stream_shutdown: &CancellationToken, ) -> ChunkDispatch { log::info!( "Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})" ); let mut tx = framed_tx.into_inner(); let Some(AdmittedOutboundTransfer { guard, cancel_token, payload: AdmittedOutboundPayload::CatalogFile { file }, }) = admit_outbound_transfer( ctx, &game_id, OutboundTransferRequest::CatalogChunk { content_id, relative_path: &relative_path, offset, length, }, stream_shutdown, ) .await else { log::info!("Declining GetGameFileChunk for {relative_path}"); reset_declined_transfer(&mut tx, "GetGameFileChunk"); return ChunkDispatch::Reset(FramedWrite::new(tx, control_codec())); }; let send_result = send_game_file_chunk( relative_path.as_str(), offset, length, file, &mut tx, cancel_token, ) .await; guard.finish().await; match chunk_send_disposition(&send_result) { ChunkSendDisposition::Finished => { ChunkDispatch::Finished(FramedWrite::new(tx, control_codec())) } ChunkSendDisposition::Reset => { // The sender resets on every exceptional exit. Preserve that // transport failure classification by preventing the dispatcher // from following it with a clean FIN. if let Err(error) = send_result { log::debug!("Chunk send ended with a reset: {error:#}"); } ChunkDispatch::Reset(FramedWrite::new(tx, control_codec())) } } } pub(super) async fn handle_stream_install_request( ctx: &PeerCtx, game_id: String, content_id: ContentId, framed_tx: ResponseWriter, stream_shutdown: &CancellationToken, _stream_install_permit: tokio::sync::OwnedSemaphorePermit, ) -> ResponseWriter { log::info!("Received StreamInstall request for {game_id} from peer"); let mut tx = framed_tx.into_inner(); let Some(AdmittedOutboundTransfer { guard, cancel_token, payload: AdmittedOutboundPayload::StreamInstall { game_dir, manifest }, }) = admit_outbound_transfer( ctx, &game_id, OutboundTransferRequest::StreamInstall { content_id }, stream_shutdown, ) .await else { tx = send_stream_install_error( tx, format!("game {game_id} is not transferable"), &game_id, stream_shutdown, ) .await; return FramedWrite::new(tx, control_codec()); }; let game_root = game_dir.join(&game_id); let (returned_tx, result) = send_game_install_stream( ctx.stream_install_provider.clone(), tx, &game_root, &game_id, manifest, cancel_token, ) .await; if let Err(error) = result { log::warn!("StreamInstall for {game_id} ended with error: {error}"); } guard.finish().await; FramedWrite::new(returned_tx, control_codec()) } fn reset_declined_transfer(tx: &mut SendStream, label: &str) { // A clean zero-length FIN is ambiguous with a valid empty catalog chunk, // and a short clean FIN is an integrity failure at the receiver. if let Err(error) = tx.reset(application::Error::UNKNOWN) { log::debug!("Failed to reset declined {label} response: {error}"); } } #[cfg(test)] mod tests { use std::{ collections::{HashMap, HashSet}, path::Path, sync::atomic::AtomicUsize, }; use lanspread_db::content_manifest::{ Blake3Digest, CATALOG_CHUNK_SIZE, CatalogBundle, CatalogContentManifestBody, CatalogExtractedEntry, }; use tokio::sync::RwLock; use tokio_util::task::TaskTracker; use super::*; use crate::{ StreamInstallFrameSink, StreamInstallFuture, StreamInstallProvider, UnpackFuture, Unpacker, context::{Ctx, OperationKind, PeerCtx}, identity::PeerIdentity, library::LocalGameSummary, network_generation::NetworkControl, peer_db::PeerGameDB, test_support::TempDir, }; struct NoopUnpacker; impl Unpacker for NoopUnpacker { fn unpack<'a>( &'a self, _archive: &'a Path, _dest: &'a Path, _cancel_token: CancellationToken, ) -> UnpackFuture<'a> { Box::pin(async { Ok(()) }) } } #[derive(Default)] struct CountingStreamInstallProvider { calls: AtomicUsize, } impl StreamInstallProvider for CountingStreamInstallProvider { fn stream_archive<'a>( &'a self, _archive: &'a Path, _frames: StreamInstallFrameSink, _cancel_token: CancellationToken, ) -> StreamInstallFuture<'a> { self.calls.fetch_add(1, Ordering::SeqCst); Box::pin(async { Ok(()) }) } } fn manifest_with_streamed_install( streamed_install_files: Vec, ) -> CatalogContentManifest { let bytes = b"payload"; let archive = b"archive"; let version = b"20250101"; CatalogContentManifest::seal( CatalogContentManifestBody::new( "game", "20250101", vec![ CatalogFileEntry::directory("directory") .expect("test directory path is canonical"), CatalogFileEntry::file("empty.bin", 0, Blake3Digest::hash(&[]), Vec::new()) .expect("test empty path is canonical"), CatalogFileEntry::file( "game.eti", u64::try_from(archive.len()).expect("test archive length fits"), Blake3Digest::hash(archive), vec![Blake3Digest::hash(archive)], ) .expect("test archive path is canonical"), CatalogFileEntry::file( "payload.bin", u64::try_from(bytes.len()).expect("test length fits"), Blake3Digest::hash(bytes), vec![Blake3Digest::hash(bytes)], ) .expect("test path is canonical"), CatalogFileEntry::file( "version.ini", u64::try_from(version.len()).expect("test length fits"), Blake3Digest::hash(version), vec![Blake3Digest::hash(version)], ) .expect("test version path is canonical"), ], streamed_install_files, ) .expect("test manifest body is valid"), ) .expect("test manifest seals") } fn manifest() -> CatalogContentManifest { manifest_with_streamed_install(Vec::new()) } fn streamable_manifest() -> CatalogContentManifest { manifest_with_streamed_install(vec![ CatalogExtractedEntry::file("installed/payload.bin", 7, Blake3Digest::hash(b"payload")) .expect("test extracted path is canonical"), ]) } async fn test_peer_ctx( root: &Path, manifest: &CatalogContentManifest, provider: Arc, ) -> (PeerCtx, crate::PeerEventReceiver) { let catalog = Arc::new( CatalogBundle::from_manifests([manifest.clone()]) .expect("test catalog should be complete"), ); let ctx = Ctx::new( Arc::new(RwLock::new(PeerGameDB::new())), Arc::new(PeerIdentity::generate().expect("test identity should generate")), root.to_path_buf(), root.join(".state"), Arc::new(NoopUnpacker), CancellationToken::new(), TaskTracker::new(), catalog, Arc::new(RwLock::new(HashMap::new())), provider, NetworkControl::disabled_for_test(), ) .expect("test context should initialize"); assert!(ctx.recovery_quarantine.settle(root, HashSet::new())); ctx.local_library.write().await.games.insert( "game".to_owned(), LocalGameSummary { id: "game".to_owned(), name: "game".to_owned(), size: 15, downloaded: true, installed: false, eti_version: Some("20250101".to_owned()), availability: Availability::Ready, }, ); let (tx, rx) = crate::peer_event_channel(); (ctx.to_peer_ctx(tx, CancellationToken::new()), rx) } async fn assert_chunk_rejected( ctx: &PeerCtx, content_id: ContentId, relative_path: &CanonicalCatalogPath, offset: u64, length: u64, ) { assert!( admit_outbound_transfer( ctx, "game", OutboundTransferRequest::CatalogChunk { content_id, relative_path, offset, length, }, &CancellationToken::new(), ) .await .is_none() ); assert!(ctx.active_outbound_transfers.read().await.is_empty()); } #[test] fn wrong_identity_path_or_range_never_authorizes_an_entry() { let manifest = manifest(); let path = CanonicalCatalogPath::new("payload.bin").expect("test path is canonical"); let wrong_path = CanonicalCatalogPath::new("other.bin").expect("test path is canonical"); let content_id = manifest.content_id(); assert!( authorize_catalog_file_request( &manifest, "game", ContentId::from_bytes([9; 32]), &path, 0, 7, ) .is_none() ); assert!( authorize_catalog_file_request(&manifest, "wrong-game", content_id, &path, 0, 7,) .is_none() ); assert!( authorize_catalog_file_request(&manifest, "game", content_id, &wrong_path, 0, 7,) .is_none() ); assert!( authorize_catalog_file_request(&manifest, "game", content_id, &path, 1, 6,).is_none() ); assert!( authorize_catalog_file_request(&manifest, "game", content_id, &path, 0, 7,).is_some() ); } #[test] fn chunk_boundaries_are_exact_including_empty_files() { assert!(catalog_chunk_range_is_exact(10, 4, 0, 4)); assert!(catalog_chunk_range_is_exact(10, 4, 4, 4)); assert!(catalog_chunk_range_is_exact(10, 4, 8, 2)); assert!(!catalog_chunk_range_is_exact(10, 4, 1, 4)); assert!(!catalog_chunk_range_is_exact(10, 4, 8, 1)); assert!(catalog_chunk_range_is_exact(0, CATALOG_CHUNK_SIZE, 0, 0)); assert!(!catalog_chunk_range_is_exact(0, CATALOG_CHUNK_SIZE, 0, 1)); } #[test] fn admitted_chunk_send_error_preserves_reset_dispatch() { let error = Err(eyre::eyre!("injected sender I/O failure")); assert_eq!(chunk_send_disposition(&error), ChunkSendDisposition::Reset); assert_eq!( chunk_send_disposition(&Ok(())), ChunkSendDisposition::Finished ); } #[allow(clippy::too_many_lines)] #[tokio::test] async fn admission_rejects_every_ineligible_v8_case_before_transfer_registration() { let temp = TempDir::new("lanspread-transfer-admission"); let game_root = temp.path().join("game"); std::fs::create_dir_all(&game_root).expect("game root should be created"); std::fs::write(game_root.join("version.ini"), b"20250101") .expect("version sentinel should be written"); std::fs::write(game_root.join("payload.bin"), b"payload") .expect("payload should be written"); std::fs::write(game_root.join("empty.bin"), b"").expect("empty payload should be written"); std::fs::write(game_root.join("game.eti"), b"archive").expect("archive should be written"); let manifest = manifest(); let content_id = manifest.content_id(); let payload = CanonicalCatalogPath::new("payload.bin").expect("path should be canonical"); let provider = Arc::new(CountingStreamInstallProvider::default()); let (ctx, mut events) = test_peer_ctx(temp.path(), &manifest, Arc::clone(&provider)).await; assert_chunk_rejected(&ctx, ContentId::from_bytes([9; 32]), &payload, 0, 7).await; assert!( ctx.catalog.cached_manifest("game").is_err(), "wrong content identity must not load a manifest body" ); assert!( admit_outbound_transfer( &ctx, "not-in-catalog", OutboundTransferRequest::CatalogChunk { content_id, relative_path: &payload, offset: 0, length: 7, }, &CancellationToken::new(), ) .await .is_none() ); let missing = CanonicalCatalogPath::new("missing.bin").expect("path should be canonical"); assert_chunk_rejected(&ctx, content_id, &missing, 0, 7).await; let local_path = CanonicalCatalogPath::new("local/payload.bin").expect("path should be canonical"); assert_chunk_rejected(&ctx, content_id, &local_path, 0, 7).await; let directory = CanonicalCatalogPath::new("directory").expect("path should be canonical"); assert_chunk_rejected(&ctx, content_id, &directory, 0, 0).await; assert_chunk_rejected(&ctx, content_id, &payload, 1, 6).await; ctx.local_library .write() .await .games .get_mut("game") .expect("summary should exist") .downloaded = false; assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; ctx.local_library .write() .await .games .get_mut("game") .expect("summary should exist") .downloaded = true; ctx.local_library .write() .await .games .get_mut("game") .expect("summary should exist") .availability = Availability::LocalOnly; assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; ctx.local_library .write() .await .games .get_mut("game") .expect("summary should exist") .availability = Availability::Ready; ctx.active_operations .write() .await .insert("game".to_owned(), OperationKind::Updating); assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; ctx.active_operations.write().await.remove("game"); ctx.recovery_quarantine.begin(temp.path().to_path_buf()); assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; assert!(ctx.recovery_quarantine.settle(temp.path(), HashSet::new())); ctx.local_library .write() .await .games .get_mut("game") .expect("summary should exist") .eti_version = Some("20240101".to_owned()); std::fs::write(game_root.join("version.ini"), b"20240101") .expect("wrong version should be written"); assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; ctx.local_library .write() .await .games .get_mut("game") .expect("summary should exist") .eti_version = Some("20250101".to_owned()); std::fs::write(game_root.join("version.ini"), b"20250101") .expect("correct version should be restored"); std::fs::remove_file(game_root.join("version.ini")).expect("sentinel should be removable"); assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; std::fs::create_dir(game_root.join("version.ini")) .expect("nonregular sentinel should be created"); assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; std::fs::remove_dir(game_root.join("version.ini")) .expect("nonregular sentinel should be removable"); std::fs::write(game_root.join("version.ini"), b"20250101") .expect("sentinel should be restored"); std::fs::write(game_root.join("payload.bin"), b"short") .expect("wrong-sized payload should be written"); assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; #[cfg(unix)] { use std::os::unix::fs::symlink; std::fs::remove_file(game_root.join("payload.bin")) .expect("wrong-sized payload should be removable"); std::fs::write(temp.path().join("outside.bin"), b"payload") .expect("outside payload should be written"); symlink( temp.path().join("outside.bin"), game_root.join("payload.bin"), ) .expect("payload symlink should be created"); assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; std::fs::remove_file(game_root.join("payload.bin")) .expect("payload symlink should be removable"); } assert!( admit_outbound_transfer( &ctx, "game", OutboundTransferRequest::StreamInstall { content_id: ContentId::from_bytes([9; 32]), }, &CancellationToken::new(), ) .await .is_none(), "wrong-content StreamInstall must be rejected" ); assert!( ctx.catalog.cached_manifest("game").is_err(), "wrong StreamInstall identity must not load a manifest body" ); assert!( admit_outbound_transfer( &ctx, "game", OutboundTransferRequest::StreamInstall { content_id }, &CancellationToken::new(), ) .await .is_none(), "manifest without extracted output must not admit StreamInstall" ); assert!(ctx.active_outbound_transfers.read().await.is_empty()); assert_eq!(provider.calls.load(Ordering::SeqCst), 0); assert!(events.try_recv().is_err()); std::fs::write(game_root.join("payload.bin"), b"payload") .expect("valid payload should be restored"); ctx.catalog .manifest("game") .expect("the server would preload the publishable manifest"); let admitted = admit_outbound_transfer( &ctx, "game", OutboundTransferRequest::CatalogChunk { content_id, relative_path: &payload, offset: 0, length: 7, }, &CancellationToken::new(), ) .await .expect("exact catalog request should be admitted"); assert_eq!(ctx.active_outbound_transfers.read().await.len(), 1); let AdmittedOutboundTransfer { guard, payload, .. } = admitted; assert!(matches!( payload, AdmittedOutboundPayload::CatalogFile { .. } )); drop(payload); guard.finish().await; assert!(ctx.active_outbound_transfers.read().await.is_empty()); assert_eq!(provider.calls.load(Ordering::SeqCst), 0); } #[tokio::test] async fn stream_install_admission_separates_identity_capability_and_valid_payload() { let temp = TempDir::new("lanspread-stream-install-admission"); let game_root = temp.path().join("game"); std::fs::create_dir_all(&game_root).expect("game root should be created"); std::fs::write(game_root.join("version.ini"), b"20250101") .expect("version sentinel should be written"); std::fs::write(game_root.join("game.eti"), b"archive").expect("archive should be written"); let manifest = streamable_manifest(); let content_id = manifest.content_id(); let provider = Arc::new(CountingStreamInstallProvider::default()); let (ctx, mut events) = test_peer_ctx(temp.path(), &manifest, Arc::clone(&provider)).await; assert!( admit_outbound_transfer( &ctx, "game", OutboundTransferRequest::StreamInstall { content_id: ContentId::from_bytes([9; 32]), }, &CancellationToken::new(), ) .await .is_none(), "a stream-capable manifest must still reject the wrong content identity" ); assert!( ctx.catalog.cached_manifest("game").is_err(), "wrong StreamInstall identity must not load a manifest body" ); assert!(ctx.active_outbound_transfers.read().await.is_empty()); assert_eq!(provider.calls.load(Ordering::SeqCst), 0); assert!(events.try_recv().is_err()); ctx.catalog .manifest("game") .expect("the server would preload the publishable manifest"); let admitted = admit_outbound_transfer( &ctx, "game", OutboundTransferRequest::StreamInstall { content_id }, &CancellationToken::new(), ) .await .expect("exact stream-capable request should cross the provider boundary"); assert_eq!(ctx.active_outbound_transfers.read().await.len(), 1); let AdmittedOutboundTransfer { guard, payload, .. } = admitted; let AdmittedOutboundPayload::StreamInstall { game_dir, manifest: admitted_manifest, } = payload else { panic!("StreamInstall admission returned a catalog-file payload"); }; assert_eq!(game_dir, temp.path()); assert_eq!(admitted_manifest.content_id(), content_id); assert!(admitted_manifest.supports_streamed_install()); guard.finish().await; assert!(ctx.active_outbound_transfers.read().await.is_empty()); assert_eq!( provider.calls.load(Ordering::SeqCst), 0, "provider work starts only after admission hands the payload to the sender" ); } }