feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
128 files changed
+51759
-10784
No files matched your search
@@ -0,0 +1,952 @@
|
||||
//! 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()
|
||||
}
|
||||
|
||||
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.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.
|
||||
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,
|
||||
} => {
|
||||
let manifest = load_expected_catalog_manifest(ctx, game_id)?;
|
||||
if manifest.content_id() != content_id {
|
||||
log::warn!(
|
||||
"Declining catalog chunk for {game_id}: requested content {content_id} does not match the local catalog"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if !can_serve_game(ctx, &game_dir, game_id).await {
|
||||
return None;
|
||||
}
|
||||
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 } => {
|
||||
let manifest = load_expected_catalog_manifest(ctx, game_id)?;
|
||||
if manifest.content_id() != content_id {
|
||||
log::warn!(
|
||||
"Declining StreamInstall for {game_id}: requested content {content_id} does not match the local catalog"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if !can_serve_game(ctx, &game_dir, game_id).await
|
||||
|| !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,
|
||||
) -> 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, mpsc};
|
||||
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, mpsc::UnboundedReceiver<crate::PeerEvent>) {
|
||||
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) = mpsc::unbounded_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!(
|
||||
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!(
|
||||
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");
|
||||
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.active_outbound_transfers.read().await.is_empty());
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
assert!(events.try_recv().is_err());
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user