Files
lanspread/crates/lanspread-peer/src/services/transfer.rs
T
ddidderr 37420e26ed fix(peer): coalesce full UI view publications
Keep one queued and one replaceable pending snapshot for remote-library and Call-to-Play views while preserving lifecycle-event FIFO delivery. Generation barriers and fenced drains prevent stale nonempty views from crossing disable or acknowledgement boundaries.

Test Plan:
- just test
- just clippy
- focused burst, lifecycle ordering, fence, repeated-barrier, and stale-view tests
- independent ordering review
- git diff --check
2026-09-12 13:06:10 +02:00

1001 lines
34 KiB
Rust

//! 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<SendStream, LengthDelimitedCodec>;
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<Arc<CatalogContentManifest>> {
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<CatalogContentManifest>,
},
}
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<AdmittedOutboundTransfer> {
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<CatalogExtractedEntry>,
) -> 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<CountingStreamInstallProvider>,
) -> (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"
);
}
}