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:
ddidderr committed 2026-08-10 13:59:18 +02:00
1 parent 36c4785775
commit 60fd7ba0c2
128 files changed
+51759 -10784

No files matched your search

+384 -727
View File
@@ -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]),
}));
}
}