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
1001 lines
34 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|