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
@@ -1,187 +1,334 @@
|
||||
//! Request dispatch for a single bidirectional QUIC stream.
|
||||
//! Bounded one-control-frame dispatch for a bidirectional QUIC stream.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use lanspread_proto::{CallToPlayAck, LibraryDelta, Message, Request, Response};
|
||||
use s2n_quic::stream::{BidirectionalStream, SendStream};
|
||||
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
|
||||
use futures::{SinkExt as _, StreamExt as _};
|
||||
use lanspread_proto::{
|
||||
ControlErrorCode,
|
||||
ControlMessage,
|
||||
MAX_CONTROL_FRAME_BYTES,
|
||||
Request,
|
||||
Response,
|
||||
};
|
||||
use s2n_quic::{
|
||||
application,
|
||||
stream::{BidirectionalStream, SendStream},
|
||||
};
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
use tokio_util::{
|
||||
codec::{FramedRead, FramedWrite, LengthDelimitedCodec},
|
||||
sync::CancellationToken,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
context::PeerCtx,
|
||||
error::PeerError,
|
||||
events,
|
||||
game_paths::is_local_dir_name,
|
||||
local_games::{get_game_file_descriptions, local_download_matches_catalog},
|
||||
peer::{send_game_file_chunk, send_game_file_data},
|
||||
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
|
||||
stream_install::{send_game_install_stream, send_stream_install_error},
|
||||
services::{
|
||||
remote_state,
|
||||
state_sync::StateDomain,
|
||||
transfer::{ChunkDispatch, handle_file_chunk_request, handle_stream_install_request},
|
||||
},
|
||||
};
|
||||
|
||||
type ResponseWriter = FramedWrite<SendStream, LengthDelimitedCodec>;
|
||||
|
||||
/// Handles a bidirectional stream from a peer.
|
||||
const INBOUND_CONTROL_FRAME_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const OUTBOUND_CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
fn control_codec() -> LengthDelimitedCodec {
|
||||
LengthDelimitedCodec::builder()
|
||||
.max_frame_length(MAX_CONTROL_FRAME_BYTES)
|
||||
.new_codec()
|
||||
}
|
||||
|
||||
/// Reads exactly one bounded request frame, requires request-side EOF, sends at
|
||||
/// most one control response, and then closes the stream. Raw transfer requests
|
||||
/// consume the response side after the same single control-frame admission.
|
||||
pub(super) async fn handle_peer_stream(
|
||||
stream: BidirectionalStream,
|
||||
ctx: PeerCtx,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
stream_shutdown: CancellationToken,
|
||||
control_permit: OwnedSemaphorePermit,
|
||||
bulk_transfer_permits: Arc<Semaphore>,
|
||||
) -> eyre::Result<()> {
|
||||
let (rx, tx) = stream.split();
|
||||
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
|
||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
|
||||
let mut framed_rx = FramedRead::new(rx, control_codec());
|
||||
let mut framed_tx = FramedWrite::new(tx, control_codec());
|
||||
log::trace!("{remote_addr:?} peer stream opened");
|
||||
|
||||
loop {
|
||||
let next_message = tokio::select! {
|
||||
() = ctx.shutdown.cancelled() => break,
|
||||
next_message = framed_rx.next() => next_message,
|
||||
};
|
||||
|
||||
match next_message {
|
||||
Some(Ok(data)) => {
|
||||
log::trace!(
|
||||
"{:?} msg: (raw): {}",
|
||||
remote_addr,
|
||||
String::from_utf8_lossy(&data)
|
||||
);
|
||||
|
||||
let request = Request::decode(data.freeze());
|
||||
log::debug!("{remote_addr:?} msg: {request:?}");
|
||||
note_peer_activity(&ctx, remote_addr).await;
|
||||
framed_tx = dispatch_request(&ctx, remote_addr, request, framed_tx).await;
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log::error!("{remote_addr:?} peer stream error: {err}");
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
log::trace!("{remote_addr:?} peer stream closed");
|
||||
break;
|
||||
let first_frame = read_expected_frame(&mut framed_rx, &stream_shutdown).await;
|
||||
let mut control_permit = Some(control_permit);
|
||||
let mut _bulk_permit = None;
|
||||
let mut response_reset = false;
|
||||
match first_frame {
|
||||
FrameRead::Frame(data) => {
|
||||
let trailing = read_expected_eof(&mut framed_rx, &stream_shutdown).await;
|
||||
if trailing == TrailingRead::Eof {
|
||||
match Request::decode(data.freeze()) {
|
||||
Ok(request) => {
|
||||
log::debug!("{remote_addr:?} msg: {request:?}");
|
||||
if request_is_bulk(&request) {
|
||||
let bulk_permit =
|
||||
Arc::clone(&bulk_transfer_permits).try_acquire_owned();
|
||||
// Once the single bounded request is decoded, bulk
|
||||
// work moves to its smaller pool so it cannot hold
|
||||
// every control-plane permit during long egress.
|
||||
drop(control_permit.take());
|
||||
if let Ok(permit) = bulk_permit {
|
||||
_bulk_permit = Some(permit);
|
||||
let dispatched =
|
||||
dispatch_request(&ctx, request, framed_tx, &stream_shutdown)
|
||||
.await;
|
||||
framed_tx = dispatched.writer;
|
||||
response_reset = dispatched.response_reset;
|
||||
} else {
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let _ = tx.reset(application::Error::UNKNOWN);
|
||||
framed_tx = FramedWrite::new(tx, control_codec());
|
||||
response_reset = true;
|
||||
}
|
||||
} else {
|
||||
let dispatched =
|
||||
dispatch_request(&ctx, request, framed_tx, &stream_shutdown).await;
|
||||
framed_tx = dispatched.writer;
|
||||
response_reset = dispatched.response_reset;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Rejecting invalid control request from {remote_addr:?}: {error}"
|
||||
);
|
||||
framed_tx = send_response(
|
||||
framed_tx,
|
||||
Response::Error(ControlErrorCode::InvalidRequest),
|
||||
"invalid-request",
|
||||
&stream_shutdown,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else if trailing != TrailingRead::Cancelled {
|
||||
log::warn!("Rejecting non-singular control request from {remote_addr:?}");
|
||||
framed_tx = send_response(
|
||||
framed_tx,
|
||||
Response::Error(ControlErrorCode::InvalidRequest),
|
||||
"invalid-request",
|
||||
&stream_shutdown,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
FrameRead::Invalid(error) => {
|
||||
log::warn!("Rejecting malformed control frame from {remote_addr:?}: {error}");
|
||||
framed_tx = send_response(
|
||||
framed_tx,
|
||||
Response::Error(ControlErrorCode::InvalidRequest),
|
||||
"invalid-request",
|
||||
&stream_shutdown,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
FrameRead::Eof => log::trace!("{remote_addr:?} peer stream closed without a request"),
|
||||
FrameRead::Cancelled => {}
|
||||
}
|
||||
|
||||
close_or_reset_stream(
|
||||
framed_rx,
|
||||
framed_tx,
|
||||
remote_addr,
|
||||
&stream_shutdown,
|
||||
response_reset,
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const fn request_is_bulk(request: &Request) -> bool {
|
||||
matches!(
|
||||
request,
|
||||
Request::GetGameFileChunk { .. } | Request::StreamInstall { .. }
|
||||
)
|
||||
}
|
||||
|
||||
enum FrameRead {
|
||||
Frame(bytes::BytesMut),
|
||||
Invalid(std::io::Error),
|
||||
Eof,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
async fn read_expected_frame(
|
||||
framed_rx: &mut FramedRead<s2n_quic::stream::ReceiveStream, LengthDelimitedCodec>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> FrameRead {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancellation.cancelled() => FrameRead::Cancelled,
|
||||
() = tokio::time::sleep(INBOUND_CONTROL_FRAME_TIMEOUT) => FrameRead::Invalid(
|
||||
std::io::Error::new(std::io::ErrorKind::TimedOut, "control request timed out")
|
||||
),
|
||||
frame = framed_rx.next() => match frame {
|
||||
Some(Ok(bytes)) => FrameRead::Frame(bytes),
|
||||
Some(Err(error)) => FrameRead::Invalid(error),
|
||||
None => FrameRead::Eof,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum TrailingRead {
|
||||
Eof,
|
||||
ExtraFrame,
|
||||
Invalid,
|
||||
TimedOut,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
async fn read_expected_eof(
|
||||
framed_rx: &mut FramedRead<s2n_quic::stream::ReceiveStream, LengthDelimitedCodec>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> TrailingRead {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancellation.cancelled() => TrailingRead::Cancelled,
|
||||
() = tokio::time::sleep(INBOUND_CONTROL_FRAME_TIMEOUT) => TrailingRead::TimedOut,
|
||||
frame = framed_rx.next() => match frame {
|
||||
Some(Ok(_)) => TrailingRead::ExtraFrame,
|
||||
Some(Err(_)) => TrailingRead::Invalid,
|
||||
None => TrailingRead::Eof,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_request(
|
||||
ctx: &PeerCtx,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
request: Request,
|
||||
framed_tx: ResponseWriter,
|
||||
) -> ResponseWriter {
|
||||
stream_shutdown: &CancellationToken,
|
||||
) -> DispatchResult {
|
||||
match request {
|
||||
Request::Ping => send_response(framed_tx, Response::Pong, "pong").await,
|
||||
Request::Hello(hello) => match accept_inbound_hello(ctx, remote_addr, hello).await {
|
||||
Ok(ack) => send_response(framed_tx, Response::HelloAck(ack), "HelloAck").await,
|
||||
Err(err) => {
|
||||
log::error!("Failed to accept inbound hello: {err}");
|
||||
send_response(
|
||||
framed_tx,
|
||||
Response::InternalPeerError(err.to_string()),
|
||||
"HelloAck",
|
||||
)
|
||||
Request::Ping => {
|
||||
match control_io_with_deadline(remote_state::local_revisions(ctx), stream_shutdown)
|
||||
.await
|
||||
{
|
||||
Some(Ok(revisions)) => DispatchResult::close(
|
||||
send_response(
|
||||
framed_tx,
|
||||
Response::Pong(revisions),
|
||||
"pong",
|
||||
stream_shutdown,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
Some(Err(error)) => {
|
||||
log::error!("Failed to build local revisions: {error:#}");
|
||||
DispatchResult::close(
|
||||
send_response(
|
||||
framed_tx,
|
||||
Response::Error(ControlErrorCode::Internal),
|
||||
"pong-error",
|
||||
stream_shutdown,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
None => reset_response_writer(framed_tx, "pong-computation"),
|
||||
}
|
||||
},
|
||||
Request::ListGames => handle_list_games(ctx, framed_tx).await,
|
||||
Request::LibraryDelta { peer_id, delta } => {
|
||||
handle_library_delta(ctx, peer_id, delta).await;
|
||||
framed_tx
|
||||
}
|
||||
Request::CallToPlayEvents {
|
||||
peer_id,
|
||||
events: incoming,
|
||||
} => {
|
||||
let ack = handle_call_to_play_events(ctx, &peer_id, incoming).await;
|
||||
send_response(framed_tx, Response::CallToPlayAck(ack), "CallToPlayAck").await
|
||||
Request::Hello => {
|
||||
match control_io_with_deadline(remote_state::local_snapshot(ctx), stream_shutdown).await
|
||||
{
|
||||
Some(Ok(snapshot)) => DispatchResult::close(
|
||||
send_response(
|
||||
framed_tx,
|
||||
Response::HelloSnapshot(snapshot),
|
||||
"hello-snapshot",
|
||||
stream_shutdown,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
Some(Err(error)) => {
|
||||
log::error!("Failed to build local peer snapshot: {error:#}");
|
||||
DispatchResult::close(
|
||||
send_response(
|
||||
framed_tx,
|
||||
Response::Error(ControlErrorCode::Internal),
|
||||
"hello-error",
|
||||
stream_shutdown,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
None => reset_response_writer(framed_tx, "hello-computation"),
|
||||
}
|
||||
}
|
||||
Request::LibraryChanged(hint) => {
|
||||
ctx.state_sync.schedule_hint(StateDomain::Library, hint);
|
||||
DispatchResult::close(framed_tx)
|
||||
}
|
||||
Request::CallToPlayChanged(hint) => {
|
||||
ctx.state_sync.schedule_hint(StateDomain::CallToPlay, hint);
|
||||
DispatchResult::close(framed_tx)
|
||||
}
|
||||
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
|
||||
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
|
||||
Request::GetGameFileChunk {
|
||||
game_id,
|
||||
content_id,
|
||||
relative_path,
|
||||
offset,
|
||||
length,
|
||||
} => {
|
||||
handle_file_chunk_request(ctx, game_id, relative_path, offset, length, framed_tx).await
|
||||
}
|
||||
Request::StreamInstall { game_id } => {
|
||||
handle_stream_install_request(ctx, game_id, framed_tx).await
|
||||
}
|
||||
Request::Goodbye { peer_id } => {
|
||||
handle_goodbye(ctx, remote_addr, peer_id).await;
|
||||
framed_tx
|
||||
}
|
||||
Request::Invalid(_, _) => {
|
||||
log::error!("Received invalid request from peer");
|
||||
framed_tx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_call_to_play_events(
|
||||
ctx: &PeerCtx,
|
||||
peer_id: &str,
|
||||
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
||||
) -> CallToPlayAck {
|
||||
let peer_id = peer_id.to_string();
|
||||
if ctx.peer_game_db.read().await.peer_addr(&peer_id).is_none() {
|
||||
log::debug!("Requesting a handshake before accepting Call to Play events from {peer_id}");
|
||||
return CallToPlayAck::NeedHandshake;
|
||||
}
|
||||
if incoming.iter().any(|event| event.actor_id != peer_id) {
|
||||
let reason = format!("event actor does not match envelope peer {peer_id}");
|
||||
log::warn!("Rejecting Call to Play events: {reason}");
|
||||
return CallToPlayAck::Rejected { reason };
|
||||
}
|
||||
|
||||
match ctx.call_to_play.write().await.merge_batch(incoming) {
|
||||
Ok(merged) => {
|
||||
let ack = if merged.needs_history() {
|
||||
CallToPlayAck::NeedHistory
|
||||
} else if !merged.applied.is_empty() {
|
||||
CallToPlayAck::Applied
|
||||
} else if merged.obsolete > 0 {
|
||||
CallToPlayAck::Obsolete
|
||||
} else if merged.duplicates > 0 {
|
||||
CallToPlayAck::Duplicate
|
||||
} else {
|
||||
CallToPlayAck::Rejected {
|
||||
reason: "empty Call to Play event batch".to_string(),
|
||||
}
|
||||
};
|
||||
if merged.needs_history() {
|
||||
log::warn!(
|
||||
"Ignoring Call to Play actions without history from {peer_id}: {}",
|
||||
merged.missing_call_ids.join(", ")
|
||||
);
|
||||
}
|
||||
if !merged.applied.is_empty() {
|
||||
events::send(
|
||||
&ctx.tx_notify_ui,
|
||||
crate::PeerEvent::CallToPlayEvents(merged.applied),
|
||||
);
|
||||
}
|
||||
ack
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Rejecting Call to Play events from {peer_id}: {err}");
|
||||
CallToPlayAck::Rejected {
|
||||
reason: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
|
||||
if let Some(addr) = remote_addr {
|
||||
ctx.peer_game_db
|
||||
.write()
|
||||
match handle_file_chunk_request(
|
||||
ctx,
|
||||
game_id,
|
||||
content_id,
|
||||
relative_path,
|
||||
offset,
|
||||
length,
|
||||
framed_tx,
|
||||
stream_shutdown,
|
||||
)
|
||||
.await
|
||||
.update_last_seen_by_addr(&addr);
|
||||
{
|
||||
ChunkDispatch::Finished(writer) => DispatchResult::close(writer),
|
||||
ChunkDispatch::Reset(writer) => DispatchResult::reset(writer),
|
||||
}
|
||||
}
|
||||
Request::StreamInstall {
|
||||
game_id,
|
||||
content_id,
|
||||
} => DispatchResult::close(
|
||||
handle_stream_install_request(ctx, game_id, content_id, framed_tx, stream_shutdown)
|
||||
.await,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_response_writer(framed_tx: ResponseWriter, label: &str) -> DispatchResult {
|
||||
let mut tx = framed_tx.into_inner();
|
||||
if let Err(error) = tx.reset(application::Error::UNKNOWN) {
|
||||
log::debug!("Failed to reset timed-out {label} response: {error}");
|
||||
}
|
||||
DispatchResult::reset(FramedWrite::new(tx, control_codec()))
|
||||
}
|
||||
|
||||
struct DispatchResult {
|
||||
writer: ResponseWriter,
|
||||
response_reset: bool,
|
||||
}
|
||||
|
||||
impl DispatchResult {
|
||||
const fn close(writer: ResponseWriter) -> Self {
|
||||
Self {
|
||||
writer,
|
||||
response_reset: false,
|
||||
}
|
||||
}
|
||||
|
||||
const fn reset(writer: ResponseWriter) -> Self {
|
||||
Self {
|
||||
writer,
|
||||
response_reset: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,610 +336,120 @@ async fn send_response(
|
||||
mut framed_tx: ResponseWriter,
|
||||
response: Response,
|
||||
label: &str,
|
||||
stream_shutdown: &CancellationToken,
|
||||
) -> ResponseWriter {
|
||||
if let Err(err) = framed_tx.send(response.encode()).await {
|
||||
log::error!("Failed to send {label} response: {err}");
|
||||
let encoded = match response.encode() {
|
||||
Ok(encoded) => encoded,
|
||||
Err(error) => {
|
||||
log::error!("Failed to encode {label} response: {error}");
|
||||
let mut tx = framed_tx.into_inner();
|
||||
if let Err(reset_error) = tx.reset(application::Error::UNKNOWN) {
|
||||
log::debug!("Failed to reset unencodable {label} response: {reset_error}");
|
||||
}
|
||||
return FramedWrite::new(tx, control_codec());
|
||||
}
|
||||
};
|
||||
let send_result = control_io_with_deadline(framed_tx.send(encoded), stream_shutdown).await;
|
||||
let Some(send_result) = send_result else {
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let _ = tx.reset(application::Error::UNKNOWN);
|
||||
return FramedWrite::new(tx, control_codec());
|
||||
};
|
||||
if let Err(error) = send_result {
|
||||
log::debug!("Failed to send {label} response: {error}");
|
||||
}
|
||||
framed_tx
|
||||
}
|
||||
|
||||
async fn handle_list_games(ctx: &PeerCtx, framed_tx: ResponseWriter) -> ResponseWriter {
|
||||
log::info!("Received ListGames request from peer");
|
||||
let snapshot = {
|
||||
let db_guard = ctx.local_game_db.read().await;
|
||||
if let Some(db) = db_guard.as_ref() {
|
||||
db.all_games().into_iter().cloned().collect::<Vec<Game>>()
|
||||
} else {
|
||||
log::info!("Local game database not yet loaded, responding with empty game list");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let games = if snapshot.is_empty() {
|
||||
snapshot
|
||||
} else {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
snapshot
|
||||
.into_iter()
|
||||
.filter(|game| !active_operations.contains_key(&game.id))
|
||||
.collect()
|
||||
};
|
||||
|
||||
send_response(framed_tx, Response::ListGames(games), "ListGames").await
|
||||
}
|
||||
|
||||
async fn handle_library_delta(ctx: &PeerCtx, peer_id: String, delta: LibraryDelta) {
|
||||
let applied = {
|
||||
let mut db = ctx.peer_game_db.write().await;
|
||||
db.apply_library_delta(&peer_id, delta)
|
||||
};
|
||||
|
||||
if applied {
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
} else {
|
||||
let addr = {
|
||||
let db = ctx.peer_game_db.read().await;
|
||||
db.peer_addr(&peer_id)
|
||||
};
|
||||
let Some(addr) = addr else {
|
||||
log::debug!("Ignoring library delta from unknown peer {peer_id}");
|
||||
return;
|
||||
};
|
||||
|
||||
spawn_library_resync(HandshakeCtx::from_peer_ctx(ctx), addr, peer_id, "resync");
|
||||
async fn close_or_reset_stream(
|
||||
framed_rx: FramedRead<s2n_quic::stream::ReceiveStream, LengthDelimitedCodec>,
|
||||
mut framed_tx: ResponseWriter,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
cancellation: &CancellationToken,
|
||||
response_reset: bool,
|
||||
) {
|
||||
if cancellation.is_cancelled() {
|
||||
let mut rx = framed_rx.into_inner();
|
||||
let _ = rx.stop_sending(application::Error::UNKNOWN);
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let _ = tx.reset(application::Error::UNKNOWN);
|
||||
return;
|
||||
}
|
||||
if response_reset {
|
||||
// The transfer handler already sent RESET_STREAM. A later clean FIN
|
||||
// would make a rejected zero-byte or truncated raw chunk ambiguous to
|
||||
// the receiver.
|
||||
drop(framed_rx);
|
||||
drop(framed_tx);
|
||||
return;
|
||||
}
|
||||
let close_result = control_io_with_deadline(framed_tx.close(), cancellation).await;
|
||||
if close_result.is_none() {
|
||||
let mut rx = framed_rx.into_inner();
|
||||
let _ = rx.stop_sending(application::Error::UNKNOWN);
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let _ = tx.reset(application::Error::UNKNOWN);
|
||||
return;
|
||||
}
|
||||
if let Some(Err(error)) = close_result {
|
||||
log::debug!("{remote_addr:?} failed to close peer response stream: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_get_game(ctx: &PeerCtx, id: String, framed_tx: ResponseWriter) -> ResponseWriter {
|
||||
log::info!("Received GetGame request for {id} from peer");
|
||||
let response = get_game_response(ctx, id).await;
|
||||
send_response(framed_tx, response, "GetGame").await
|
||||
}
|
||||
|
||||
async fn get_game_response(ctx: &PeerCtx, id: String) -> Response {
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_serve_game(ctx, &game_dir, &id).await {
|
||||
return Response::GameNotFound(id);
|
||||
async fn control_io_with_deadline<T>(
|
||||
operation: impl std::future::Future<Output = T>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Option<T> {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancellation.cancelled() => None,
|
||||
() = tokio::time::sleep(OUTBOUND_CONTROL_IO_TIMEOUT) => None,
|
||||
result = operation => Some(result),
|
||||
}
|
||||
|
||||
match get_game_file_descriptions(&id, &game_dir).await {
|
||||
Ok(file_descriptions) => Response::GetGame {
|
||||
id,
|
||||
file_descriptions,
|
||||
},
|
||||
Err(PeerError::FileSizeDetermination { path, source }) => {
|
||||
let error_msg = format!("Failed to determine file size for {path}: {source}");
|
||||
log::error!("File size determination error for game {id}: {error_msg}");
|
||||
Response::InternalPeerError(error_msg)
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to get game file descriptions for {id}: {err}");
|
||||
Response::GameNotFound(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
local_download_matches_catalog(game_dir, game_id, &active_operations, &catalog).await
|
||||
}
|
||||
|
||||
async fn can_dispatch_file_transfer(
|
||||
ctx: &PeerCtx,
|
||||
game_dir: &std::path::Path,
|
||||
game_id: &str,
|
||||
relative_path: &str,
|
||||
) -> bool {
|
||||
relative_path_belongs_to_game(game_id, relative_path)
|
||||
&& !path_points_inside_local(game_id, relative_path)
|
||||
&& can_serve_game(ctx, game_dir, game_id).await
|
||||
}
|
||||
|
||||
fn relative_path_belongs_to_game(game_id: &str, relative_path: &str) -> bool {
|
||||
let normalised = relative_path.replace('\\', "/");
|
||||
if normalised.starts_with('/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
normalised
|
||||
.split('/')
|
||||
.find(|part| !part.is_empty())
|
||||
.is_some_and(|first| first == game_id)
|
||||
}
|
||||
|
||||
fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
|
||||
let normalised = relative_path.replace('\\', "/");
|
||||
let mut parts = normalised.split('/').filter(|part| !part.is_empty());
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some(first), _) if is_local_dir_name(first) => true,
|
||||
(Some(first), Some(second)) if first == game_id && is_local_dir_name(second) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_TRANSFER_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
struct TransferGuard {
|
||||
game_id: String,
|
||||
id: u64,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
|
||||
}
|
||||
|
||||
impl TransferGuard {
|
||||
async fn new(
|
||||
game_id: String,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
|
||||
shutdown: &tokio_util::sync::CancellationToken,
|
||||
) -> (Self, tokio_util::sync::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()));
|
||||
}
|
||||
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
|
||||
(
|
||||
Self {
|
||||
game_id,
|
||||
id,
|
||||
active_outbound_transfers,
|
||||
tx_notify_ui,
|
||||
},
|
||||
token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TransferGuard {
|
||||
fn drop(&mut self) {
|
||||
let game_id = self.game_id.clone();
|
||||
let id = self.id;
|
||||
let active_outbound_transfers = self.active_outbound_transfers.clone();
|
||||
let tx_notify_ui = self.tx_notify_ui.clone();
|
||||
tokio::spawn(async move {
|
||||
{
|
||||
let mut active = active_outbound_transfers.write().await;
|
||||
if let Some(tokens) = active.get_mut(&game_id) {
|
||||
tokens.retain(|(tid, _)| *tid != id);
|
||||
if tokens.is_empty() {
|
||||
active.remove(&game_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_file_data_request(
|
||||
ctx: &PeerCtx,
|
||||
desc: GameFileDescription,
|
||||
framed_tx: ResponseWriter,
|
||||
) -> ResponseWriter {
|
||||
log::info!(
|
||||
"Received GetGameFileData request for {} from peer",
|
||||
desc.relative_path
|
||||
);
|
||||
|
||||
let (guard, cancel_token) = TransferGuard::new(
|
||||
desc.game_id.clone(),
|
||||
ctx.active_outbound_transfers.clone(),
|
||||
ctx.tx_notify_ui.clone(),
|
||||
&ctx.shutdown,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await {
|
||||
log::info!(
|
||||
"Declining GetGameFileData for {} because the game is not currently transferable",
|
||||
desc.relative_path
|
||||
);
|
||||
drop(guard);
|
||||
let _ = tx.close().await;
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
send_game_file_data(&desc, &mut tx, &game_dir, cancel_token).await;
|
||||
drop(guard);
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
async fn handle_file_chunk_request(
|
||||
ctx: &PeerCtx,
|
||||
game_id: String,
|
||||
relative_path: String,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
framed_tx: ResponseWriter,
|
||||
) -> ResponseWriter {
|
||||
log::info!(
|
||||
"Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})"
|
||||
);
|
||||
|
||||
let (guard, cancel_token) = TransferGuard::new(
|
||||
game_id.clone(),
|
||||
ctx.active_outbound_transfers.clone(),
|
||||
ctx.tx_notify_ui.clone(),
|
||||
&ctx.shutdown,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await {
|
||||
log::info!(
|
||||
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
|
||||
);
|
||||
drop(guard);
|
||||
let _ = tx.close().await;
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
send_game_file_chunk(
|
||||
&game_id,
|
||||
&relative_path,
|
||||
offset,
|
||||
length,
|
||||
&mut tx,
|
||||
&game_dir,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
drop(guard);
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
async fn handle_stream_install_request(
|
||||
ctx: &PeerCtx,
|
||||
game_id: String,
|
||||
framed_tx: ResponseWriter,
|
||||
) -> ResponseWriter {
|
||||
log::info!("Received StreamInstall request for {game_id} from peer");
|
||||
|
||||
let (guard, cancel_token) = TransferGuard::new(
|
||||
game_id.clone(),
|
||||
ctx.active_outbound_transfers.clone(),
|
||||
ctx.tx_notify_ui.clone(),
|
||||
&ctx.shutdown,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_serve_game(ctx, &game_dir, &game_id).await {
|
||||
log::info!(
|
||||
"Declining StreamInstall for {game_id} because the game is not currently transferable"
|
||||
);
|
||||
tx = send_stream_install_error(tx, format!("game {game_id} is not transferable")).await;
|
||||
drop(guard);
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
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,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
log::warn!("StreamInstall for {game_id} ended with error: {err}");
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
FramedWrite::new(returned_tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
async fn handle_goodbye(ctx: &PeerCtx, _remote_addr: Option<SocketAddr>, peer_id: String) {
|
||||
log::info!("Received Goodbye from peer {peer_id}");
|
||||
let removed = { ctx.peer_game_db.write().await.remove_peer(&peer_id) };
|
||||
let Some(peer) = removed else { return };
|
||||
|
||||
events::emit_peer_lost(&ctx.peer_game_db, &ctx.tx_notify_ui, peer.addr).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
use lanspread_db::content_manifest::ContentId;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
UnpackFuture,
|
||||
Unpacker,
|
||||
context::{Ctx, OperationKind},
|
||||
peer_db::PeerGameDB,
|
||||
test_support::TempDir,
|
||||
};
|
||||
|
||||
struct NoopUnpacker;
|
||||
|
||||
impl Unpacker for NoopUnpacker {
|
||||
fn unpack<'a>(&'a self, _archive: &'a Path, _dest: &'a Path) -> UnpackFuture<'a> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("parent dir should be created");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
|
||||
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> PeerCtx {
|
||||
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
|
||||
let state_dir = game_dir.join(".test-state");
|
||||
Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
"peer".to_string(),
|
||||
game_dir,
|
||||
state_dir,
|
||||
Arc::new(NoopUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
)
|
||||
.to_peer_ctx(tx_notify_ui)
|
||||
}
|
||||
|
||||
fn call_to_play_event(actor_id: &str, action: CallToPlayAction) -> CallToPlayEvent {
|
||||
CallToPlayEvent {
|
||||
id: "event-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
actor_id: actor_id.to_string(),
|
||||
actor_name: "Alice".to_string(),
|
||||
at: 8_000_000_000_000,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn call_to_play_create(actor_id: &str) -> CallToPlayEvent {
|
||||
call_to_play_event(
|
||||
actor_id,
|
||||
CallToPlayAction::Create {
|
||||
game_id: "game".to_string(),
|
||||
max_players: 4,
|
||||
scheduled_for: None,
|
||||
deadline: 8_000_000_060_000,
|
||||
},
|
||||
)
|
||||
#[test]
|
||||
fn every_control_codec_enforces_the_protocol_frame_bound() {
|
||||
let codec = control_codec();
|
||||
assert_eq!(codec.max_frame_length(), MAX_CONTROL_FRAME_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_relative_paths_are_never_transferable() {
|
||||
assert!(path_points_inside_local("game", "game/local/save.dat"));
|
||||
assert!(path_points_inside_local("game", "local/save.dat"));
|
||||
assert!(path_points_inside_local("game", "game\\local\\save.dat"));
|
||||
assert!(!path_points_inside_local("game", "game/version.ini"));
|
||||
assert!(!path_points_inside_local("game", "game/archive.eti"));
|
||||
fn trailing_frame_outcomes_are_never_accepted_as_eof() {
|
||||
for outcome in [
|
||||
TrailingRead::ExtraFrame,
|
||||
TrailingRead::Invalid,
|
||||
TrailingRead::TimedOut,
|
||||
] {
|
||||
assert_ne!(outcome, TrailingRead::Eof);
|
||||
assert_ne!(outcome, TrailingRead::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn public_control_computation_and_egress_have_an_absolute_deadline() {
|
||||
let cancellation = CancellationToken::new();
|
||||
let start = tokio::time::Instant::now();
|
||||
assert!(
|
||||
control_io_with_deadline(std::future::pending::<()>(), &cancellation)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(start.elapsed(), OUTBOUND_CONTROL_IO_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferable_paths_must_belong_to_requested_game() {
|
||||
assert!(relative_path_belongs_to_game("game", "game/version.ini"));
|
||||
assert!(relative_path_belongs_to_game("game", "game\\archive.eti"));
|
||||
assert!(!relative_path_belongs_to_game("game", "other/archive.eti"));
|
||||
assert!(!relative_path_belongs_to_game("game", "archive.eti"));
|
||||
assert!(!relative_path_belongs_to_game("game", "/game/archive.eti"));
|
||||
assert!(!relative_path_belongs_to_game(
|
||||
"game",
|
||||
"../game/archive.eti"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_peer_id_accepts_live_events_without_transport_ip_matching() {
|
||||
let temp = TempDir::new("lanspread-call-to-play-known-peer");
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
|
||||
ctx.peer_game_db.write().await.upsert_peer(
|
||||
"peer-alice".to_string(),
|
||||
SocketAddr::from(([10, 66, 0, 2], 40000)),
|
||||
);
|
||||
|
||||
let ack =
|
||||
handle_call_to_play_events(&ctx, "peer-alice", vec![call_to_play_create("peer-alice")])
|
||||
.await;
|
||||
|
||||
assert_eq!(ack, CallToPlayAck::Applied);
|
||||
assert_eq!(ctx.call_to_play.write().await.snapshot().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_peer_and_mismatched_actor_receive_explicit_acks() {
|
||||
let temp = TempDir::new("lanspread-call-to-play-identity");
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
|
||||
|
||||
assert_eq!(
|
||||
handle_call_to_play_events(
|
||||
&ctx,
|
||||
"peer-alice",
|
||||
vec![call_to_play_create("peer-alice")],
|
||||
)
|
||||
.await,
|
||||
CallToPlayAck::NeedHandshake
|
||||
);
|
||||
|
||||
ctx.peer_game_db.write().await.upsert_peer(
|
||||
"peer-alice".to_string(),
|
||||
SocketAddr::from(([10, 66, 0, 2], 40000)),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_call_to_play_events(
|
||||
&ctx,
|
||||
"peer-alice",
|
||||
vec![call_to_play_create("peer-mallory")],
|
||||
)
|
||||
.await,
|
||||
CallToPlayAck::Rejected { reason }
|
||||
if reason.contains("does not match envelope peer")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_event_ack_reports_missing_history_and_duplicates() {
|
||||
let temp = TempDir::new("lanspread-call-to-play-outcomes");
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
|
||||
ctx.peer_game_db.write().await.upsert_peer(
|
||||
"peer-alice".to_string(),
|
||||
SocketAddr::from(([10, 66, 0, 2], 40000)),
|
||||
);
|
||||
let orphan = call_to_play_event(
|
||||
"peer-alice",
|
||||
CallToPlayAction::AddTime {
|
||||
deadline: 8_000_000_600_000,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
handle_call_to_play_events(&ctx, "peer-alice", vec![orphan]).await,
|
||||
CallToPlayAck::NeedHistory
|
||||
);
|
||||
|
||||
let create = call_to_play_create("peer-alice");
|
||||
assert_eq!(
|
||||
handle_call_to_play_events(&ctx, "peer-alice", vec![create.clone()]).await,
|
||||
CallToPlayAck::Applied
|
||||
);
|
||||
assert_eq!(
|
||||
handle_call_to_play_events(&ctx, "peer-alice", vec![create]).await,
|
||||
CallToPlayAck::Duplicate
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_game_response_respects_serve_gates() {
|
||||
let temp = TempDir::new("lanspread-stream");
|
||||
write_file(&temp.path().join("ready").join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&temp.path().join("non-catalog").join("version.ini"),
|
||||
b"20250101",
|
||||
);
|
||||
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&temp.path().join("wrong-version").join("version.ini"),
|
||||
b"20260101",
|
||||
);
|
||||
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
||||
.expect("missing sentinel root should be created");
|
||||
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("ready".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("active".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("active".to_string(), OperationKind::Downloading);
|
||||
|
||||
assert!(matches!(
|
||||
get_game_response(&ctx, "ready".to_string()).await,
|
||||
Response::GetGame { id, .. } if id == "ready"
|
||||
));
|
||||
assert!(matches!(
|
||||
get_game_response(&ctx, "non-catalog".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "non-catalog"
|
||||
));
|
||||
assert!(matches!(
|
||||
get_game_response(&ctx, "active".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "active"
|
||||
));
|
||||
assert!(matches!(
|
||||
get_game_response(&ctx, "wrong-version".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "wrong-version"
|
||||
));
|
||||
assert!(matches!(
|
||||
get_game_response(&ctx, "missing-sentinel".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "missing-sentinel"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_transfer_dispatch_respects_serve_gates() {
|
||||
let temp = TempDir::new("lanspread-stream");
|
||||
write_file(&temp.path().join("ready").join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&temp.path().join("non-catalog").join("version.ini"),
|
||||
b"20250101",
|
||||
);
|
||||
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&temp.path().join("wrong-version").join("version.ini"),
|
||||
b"20260101",
|
||||
);
|
||||
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
||||
.expect("missing sentinel root should be created");
|
||||
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("ready".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("active".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("active".to_string(), OperationKind::Downloading);
|
||||
|
||||
assert!(can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/version.ini").await);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(&ctx, temp.path(), "ready", "active/version.ini").await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(
|
||||
&ctx,
|
||||
temp.path(),
|
||||
"non-catalog",
|
||||
"non-catalog/version.ini",
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(
|
||||
&ctx,
|
||||
temp.path(),
|
||||
"wrong-version",
|
||||
"wrong-version/version.ini",
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(
|
||||
&ctx,
|
||||
temp.path(),
|
||||
"missing-sentinel",
|
||||
"missing-sentinel/archive.eti",
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/local/save.dat").await
|
||||
);
|
||||
fn only_long_lived_payload_requests_move_to_the_reserved_bulk_pool() {
|
||||
assert!(!request_is_bulk(&Request::Ping));
|
||||
assert!(request_is_bulk(&Request::StreamInstall {
|
||||
game_id: "game".to_owned(),
|
||||
content_id: ContentId::from_bytes([1; 32]),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user