Files
lanspread/crates/lanspread-peer/src/lib.rs
T
ddidderr 60fd7ba0c2 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
2026-08-10 13:59:18 +02:00

1159 lines
40 KiB
Rust

//! Peer-to-peer game distribution system.
//!
//! This crate provides the core P2P networking engine for `LanSpread`, handling:
//! - Peer discovery via mDNS
//! - QUIC-based communication
//! - Game file synchronization with chunked transfers
//! - Consensus validation for file integrity
#![allow(clippy::missing_errors_doc)]
// =============================================================================
// Module declarations
// =============================================================================
mod call_to_play;
mod config;
mod content_quarantine;
mod context;
mod download;
mod error;
mod events;
mod game_paths;
mod handlers;
mod identity;
mod install;
mod launch_settings;
mod library;
mod local_games;
mod migration;
mod network;
mod network_generation;
mod path_validation;
mod peer;
mod peer_db;
mod quic_runtime;
mod recovery_quarantine;
mod scoped_blocking;
mod scoped_process;
mod services;
mod startup;
mod state_paths;
mod stream_install;
#[cfg(test)]
mod test_support;
mod tls;
#[cfg(test)]
mod tls_identity_spike;
mod transfer_status;
// =============================================================================
// Public re-exports
// =============================================================================
use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
pub use config::CHUNK_SIZE;
pub use error::PeerError;
pub use identity::{
LoadedPeerIdentity,
PeerIdentity,
PeerIdentityPersistence,
PeerIdentityPersistenceFailure,
PeerIdentityPersistenceFailureKind,
load_peer_identity,
};
pub use install::{UnpackFuture, Unpacker};
use lanspread_db::{
content_manifest::{CanonicalCatalogPath, CatalogBundle, ContentId},
db::Game,
};
pub use lanspread_proto::{CallId, CallToPlayAction, EventNonce, PeerEndpoint};
pub use migration::{MigrationReport, migrate_legacy_state};
pub use peer_db::{
PeerEndpointGeneration,
PeerGameDB,
PeerId,
PeerInfo,
PeerLivenessSnapshot,
PeerSnapshot,
PeerUpsert,
};
pub use scoped_blocking::scoped_blocking;
pub use scoped_process::{ScopedProcess, ScopedProcessOutput};
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
oneshot,
};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
pub use transfer_status::{
DownloadAttemptId,
DownloadAttemptKey,
DownloadFailureReason,
DownloadVerificationActivity,
};
use crate::{
context::Ctx,
handlers::{
handle_cancel_download_command,
handle_connect_peer_command,
handle_download_game_files_command,
handle_get_peer_count_command,
handle_install_game_command,
handle_list_games_command,
handle_remove_downloaded_game_command,
handle_set_game_dir_command,
handle_uninstall_game_command,
load_local_library,
},
network_generation::NetworkManager,
state_paths::resolve_state_dir,
};
pub use crate::{
context::{OutboundTransferChange, OutboundTransfers},
launch_settings::{
LaunchSettingsOutcome,
StreamInstallSettings,
apply_launch_settings_once,
apply_launch_settings_to_verified_tree,
mark_launch_settings_applied,
},
startup::PeerRuntimeHandle,
state_paths::{launch_settings_applied_path, setup_done_path},
stream_install::{
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
StreamInstallFrameSink,
StreamInstallFuture,
StreamInstallProvider,
},
};
// =============================================================================
// Public API types
// =============================================================================
/// One user-authored Call-to-Play mutation request.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CallToPlayLocalIntent {
/// Existing call to mutate, or `None` when creating a new call.
pub call_id: Option<CallId>,
pub action: CallToPlayLocalAction,
}
/// User-controlled Call-to-Play action fields. Identity, nonces, and time are core-owned.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub enum CallToPlayLocalAction {
Create {
game_id: String,
max_players: u16,
scheduled_for: Option<i64>,
deadline: i64,
},
Respond {
ready_at: Option<i64>,
},
Rsvp,
SendMessage {
text: String,
},
Leave,
Cancel,
Start,
AddTime {
deadline: i64,
},
}
/// Core-generated identity and revision for an accepted local intent.
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CallToPlayReceipt {
pub call_id: CallId,
pub event_id: EventNonce,
pub revision: u64,
}
/// Complete Call-to-Play projection. Every delivery replaces the previous projection.
#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CallToPlayView {
pub events: Vec<CallToPlayViewEvent>,
}
/// One author-attributed event in a complete Call-to-Play projection.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CallToPlayViewEvent {
pub id: EventNonce,
pub call_id: CallId,
pub author_id: PeerId,
pub author_name: String,
pub at: i64,
pub action: CallToPlayAction,
}
/// Exact remotely available catalog content and authenticated source count.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RemoteGameAvailability {
pub game_id: String,
pub content_id: ContentId,
pub peer_count: u32,
}
/// Pure remote availability projection, independent of UI catalog metadata.
#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RemoteLibraryView {
pub games: Vec<RemoteGameAvailability>,
}
impl From<CallToPlayLocalIntent> for call_to_play::CallToPlayLocalIntent {
fn from(intent: CallToPlayLocalIntent) -> Self {
Self {
call_id: intent.call_id,
action: intent.action.into(),
}
}
}
impl From<CallToPlayLocalAction> for call_to_play::CallToPlayLocalAction {
fn from(action: CallToPlayLocalAction) -> Self {
match action {
CallToPlayLocalAction::Create {
game_id,
max_players,
scheduled_for,
deadline,
} => Self::Create {
game_id,
max_players,
scheduled_for,
deadline,
},
CallToPlayLocalAction::Respond { ready_at } => Self::Respond { ready_at },
CallToPlayLocalAction::Rsvp => Self::Rsvp,
CallToPlayLocalAction::SendMessage { text } => Self::SendMessage { text },
CallToPlayLocalAction::Leave => Self::Leave,
CallToPlayLocalAction::Cancel => Self::Cancel,
CallToPlayLocalAction::Start => Self::Start,
CallToPlayLocalAction::AddTime { deadline } => Self::AddTime { deadline },
}
}
}
impl From<call_to_play::CallToPlayReceipt> for CallToPlayReceipt {
fn from(receipt: call_to_play::CallToPlayReceipt) -> Self {
Self {
call_id: receipt.call_id,
event_id: receipt.event_id,
revision: receipt.revision,
}
}
}
impl From<call_to_play::CallToPlayView> for CallToPlayView {
fn from(view: call_to_play::CallToPlayView) -> Self {
Self {
events: view.events.into_iter().map(Into::into).collect(),
}
}
}
impl From<call_to_play::CallToPlayViewEvent> for CallToPlayViewEvent {
fn from(event: call_to_play::CallToPlayViewEvent) -> Self {
Self {
id: event.id,
call_id: event.call_id,
author_id: event.author_id,
author_name: event.author_name,
at: event.at,
action: event.action,
}
}
}
/// Lifecycle state of the process-wide Local network sharing policy.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
pub enum LocalNetworkSharingState {
Disabled,
Enabling,
Enabled { peer_id: String, addr: SocketAddr },
Disabling,
}
/// Redacted durability outcome for the installation identity used by a runtime.
///
/// This deliberately exposes no persistence path, failure category, key
/// material, or backend error text.
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
pub enum PeerIdentityDurability {
Persistent,
Ephemeral,
CallerProvided,
}
/// Events sent from the peer system to the UI.
#[derive(Debug, strum::IntoStaticStr)]
pub enum PeerEvent {
/// The local QUIC server is listening and ready to accept peer connections.
LocalPeerReady { peer_id: String, addr: SocketAddr },
/// The current lifecycle state of the global Local network sharing policy.
LocalNetworkSharingStateChanged(LocalNetworkSharingState),
/// Complete exact-content availability projection from authenticated peers.
RemoteLibraryView(RemoteLibraryView),
/// Download has started for one exact command attempt.
DownloadGameFilesBegin { attempt: DownloadAttemptKey },
/// A file chunk has been downloaded from a peer.
DownloadGameFileChunkFinished {
id: String,
peer_id: PeerId,
peer_addr: SocketAddr,
content_id: ContentId,
relative_path: CanonicalCatalogPath,
offset: u64,
length: u64,
},
/// Download progress sampled while game files are being received.
DownloadGameFilesProgress(DownloadProgress),
/// Verification-specific activity for one download attempt changed.
DownloadGameFilesActivityChanged {
attempt: DownloadAttemptKey,
activity: Option<DownloadVerificationActivity>,
},
/// Download has completed successfully.
DownloadGameFilesFinished { attempt: DownloadAttemptKey },
/// Download has failed with a stable user-facing classification.
DownloadGameFilesFailed {
attempt: DownloadAttemptKey,
reason: DownloadFailureReason,
},
/// Install or update transaction has completed successfully.
InstallGameFinished { id: String },
/// Install or update transaction has failed after rollback.
InstallGameFailed { id: String },
/// Uninstall transaction has completed successfully.
UninstallGameFinished { id: String },
/// Uninstall transaction has failed after rollback.
UninstallGameFailed { id: String },
/// Downloaded archive removal has completed successfully.
RemoveDownloadedGameFinished { id: String },
/// Downloaded archive removal has failed before deleting the game root.
RemoveDownloadedGameFailed { id: String },
/// A new peer was discovered via mDNS.
PeerDiscovered(PeerEndpoint),
/// A peer was lost (timed out or disconnected).
PeerLost(PeerEndpoint),
/// The total peer count has changed.
PeerCountUpdated(usize),
/// mDNS observed a nearby installation speaking a different wire protocol.
IncompatibleProtocolDetected {
observed: Option<u32>,
expected: u32,
},
/// The local library contents changed after a scan.
LocalLibraryChanged { games: Vec<Game> },
/// The number of active outbound transfers changed.
OutboundTransferCountChanged(OutboundTransferChange),
/// The set of in-progress local operations changed.
ActiveOperationsChanged {
active_operations: Vec<ActiveOperation>,
},
/// Complete Call-to-Play projection; consumers replace their previous view.
CallToPlayView(CallToPlayView),
/// A required peer runtime component failed.
RuntimeFailed {
component: PeerRuntimeComponent,
error: String,
},
}
/// Sampled byte progress for one active game download.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct DownloadProgress {
pub attempt: DownloadAttemptKey,
pub downloaded_bytes: u64,
pub total_bytes: u64,
pub bytes_per_second: u64,
/// Unique peers currently streaming at least one chunk for this download.
pub active_peer_count: usize,
}
/// Long-running peer runtime components reported in failure events.
#[derive(Clone, Copy, Debug, strum::IntoStaticStr)]
pub enum PeerRuntimeComponent {
/// Command/control message loop.
CommandLoop,
/// Inbound QUIC server and its mDNS advertisement.
QuicServer,
/// mDNS peer discovery.
Discovery,
/// Peer liveness monitoring.
Liveness,
/// Local game directory monitor.
LocalMonitor,
}
/// Install-side operation represented in lifecycle events.
#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
pub enum InstallOperation {
/// Fresh install into a missing `local/` directory.
Installing,
/// Update that replaces an existing `local/` directory.
Updating,
}
/// In-progress operation snapshot sent when operation state changes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ActiveOperation {
pub id: String,
pub operation: ActiveOperationKind,
}
/// Operation kinds visible to UI reconciliation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
pub enum ActiveOperationKind {
/// Downloading or replacing archive files.
Downloading,
/// Extracting into a previously uninstalled game root.
Installing,
/// Replacing an existing `local/` install.
Updating,
/// Removing an existing `local/` install.
Uninstalling,
/// Removing downloaded archive files for an uninstalled game.
RemovingDownload,
}
/// Commands sent to the peer system from the UI.
#[derive(Debug)]
pub enum PeerCommand {
/// Request a list of all available games.
ListGames,
/// Download game files.
DownloadGameFiles { id: String },
/// Download game files with an explicit install policy.
DownloadGameFilesWithOptions {
id: String,
install_after_download: bool,
},
/// Stream archive-expanded bytes directly into `local/` without keeping root archives.
StreamInstallGame {
id: String,
settings: StreamInstallSettings,
},
/// Install already-downloaded archives into `local/`.
InstallGame { id: String },
/// Remove only the `local/` install for a game.
UninstallGame { id: String },
/// Remove downloaded archive files for an uninstalled game.
RemoveDownloadedGame { id: String },
/// Cancel an active peer download without emitting a user-facing failure.
CancelDownload { id: String },
/// Set the local game directory and acknowledge the canonical path that
/// the peer accepted.
SetGameDir {
path: PathBuf,
reply: oneshot::Sender<Result<PathBuf, String>>,
},
/// Enable or disable all Local network sharing services and await drainage.
SetLocalNetworkSharing {
enabled: bool,
reply: oneshot::Sender<Result<bool, String>>,
},
/// Request the current peer count.
GetPeerCount,
/// Connect directly to an authenticated peer endpoint without waiting for mDNS discovery.
ConnectPeer(PeerEndpoint),
/// Apply one local Call-to-Play intent; core generates all identity and time fields.
ApplyCallToPlayIntent {
intent: CallToPlayLocalIntent,
display_name: String,
reply: oneshot::Sender<Result<CallToPlayReceipt, String>>,
},
/// Update the locally authored display name, including without a new event.
SetCallToPlayDisplayName {
display_name: String,
reply: oneshot::Sender<Result<bool, String>>,
},
/// Request the complete in-memory Call-to-Play projection.
GetCallToPlayView {
reply: Option<oneshot::Sender<Result<CallToPlayView, String>>>,
},
}
/// Optional startup settings for non-GUI callers and tests.
#[derive(Clone)]
pub struct PeerStartOptions {
/// Directory used for peer identity and other state.
pub state_dir: Option<PathBuf>,
/// Explicit installation identity. When absent, the state directory owns it.
pub identity: Option<Arc<PeerIdentity>>,
pub active_outbound_transfers: Option<crate::context::OutboundTransfers>,
/// Provider used to stream archive entries for low-disk streamed installs.
pub stream_install_provider: Option<Arc<dyn StreamInstallProvider>>,
/// Whether Local network sharing services start enabled.
pub local_network_sharing: bool,
}
impl Default for PeerStartOptions {
fn default() -> Self {
Self {
state_dir: None,
identity: None,
active_outbound_transfers: None,
stream_install_provider: None,
local_network_sharing: true,
}
}
}
impl std::fmt::Debug for PeerStartOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PeerStartOptions")
.field("state_dir", &self.state_dir)
.field(
"identity",
&self.identity.as_ref().map(|identity| identity.peer_id()),
)
.field(
"active_outbound_transfers",
&self.active_outbound_transfers.as_ref().map(|_| "..."),
)
.field(
"stream_install_provider",
&self.stream_install_provider.as_ref().map(|_| "..."),
)
.field("local_network_sharing", &self.local_network_sharing)
.finish()
}
}
// =============================================================================
// Public API functions
// =============================================================================
/// Initialize and start the peer system.
///
/// This is the main entry point for the peer system. It starts all background
/// services (server, discovery, ping, local monitor) and returns a handle that
/// owns the command sender plus a shutdown signal callers can use for clean
/// teardown.
///
/// A successful return acknowledges construction of the isolated runtime and
/// outgoing QUIC endpoint. Initial recovery and required-service startup remain
/// asynchronous. Later events report bootstrap progress and failures, including
/// [`PeerEvent::LocalPeerReady`] for listener readiness and
/// [`PeerEvent::RuntimeFailed`] for a required component failure.
///
/// # Arguments
///
/// * `game_dir` - Path to the local game directory
/// * `tx_notify_ui` - Channel for sending events to the UI
/// * `peer_game_db` - Shared peer game database
#[allow(clippy::implicit_hasher)]
pub fn start_peer(
game_dir: impl Into<PathBuf>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<CatalogBundle>,
) -> eyre::Result<PeerRuntimeHandle> {
start_peer_with_options(
game_dir,
tx_notify_ui,
peer_game_db,
unpacker,
catalog,
PeerStartOptions::default(),
)
}
/// Initialize and start the peer system with explicit startup settings.
#[allow(clippy::implicit_hasher)]
pub fn start_peer_with_options(
game_dir: impl Into<PathBuf>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<CatalogBundle>,
options: PeerStartOptions,
) -> eyre::Result<PeerRuntimeHandle> {
let PeerStartOptions {
state_dir,
identity: explicit_identity,
active_outbound_transfers,
stream_install_provider,
local_network_sharing,
} = options;
let state_dir = resolve_state_dir(state_dir.as_deref());
let requested_game_dir = game_dir.into();
let game_dir = canonicalize_game_dir(&requested_game_dir)?;
install::intent::scan_active_install_intents(&state_dir, &game_dir)?;
let active_outbound_transfers = active_outbound_transfers
.unwrap_or_else(|| Arc::new(RwLock::new(std::collections::HashMap::new())));
let stream_install_provider =
stream_install_provider.unwrap_or_else(|| Arc::new(NoopStreamInstallProvider));
log::info!(
"Starting peer system with game directory: {}",
game_dir.display()
);
let (peer_identity, identity_durability) = if let Some(identity) = explicit_identity {
(identity, PeerIdentityDurability::CallerProvided)
} else {
let loaded = identity::load_or_generate_peer_identity(&state_dir)?;
let durability = identity_durability(&loaded.persistence);
match &loaded.persistence {
PeerIdentityPersistence::Loaded => {
log::debug!("Loaded peer identity {}", loaded.identity.peer_id());
}
PeerIdentityPersistence::Created => {
log::info!("Created peer identity {}", loaded.identity.peer_id());
}
PeerIdentityPersistence::ReplacedCorrupt { quarantine_path } => {
log::warn!(
"Replaced corrupt peer identity; original preserved at {}",
quarantine_path.display()
);
}
PeerIdentityPersistence::Ephemeral(failure) => {
log::warn!(
"Peer identity persistence {:?} failed at {}: {}; using an ephemeral identity for this runtime",
failure.kind,
failure.path.display(),
failure.message
);
}
}
(Arc::new(loaded.identity), durability)
};
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
startup::spawn_peer_runtime(
tx_control,
rx_control,
tx_notify_ui,
peer_game_db,
peer_identity,
identity_durability,
game_dir,
state_dir,
unpacker,
catalog,
active_outbound_transfers,
stream_install_provider,
local_network_sharing,
)
}
const fn identity_durability(persistence: &PeerIdentityPersistence) -> PeerIdentityDurability {
match persistence {
PeerIdentityPersistence::Loaded
| PeerIdentityPersistence::Created
| PeerIdentityPersistence::ReplacedCorrupt { .. } => PeerIdentityDurability::Persistent,
PeerIdentityPersistence::Ephemeral(_) => PeerIdentityDurability::Ephemeral,
}
}
/// Main peer execution loop that handles peer commands and manages the peer system.
#[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
async fn run_peer(
mut rx_control: UnboundedReceiver<PeerCommand>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
peer_identity: Arc<PeerIdentity>,
game_dir: PathBuf,
state_dir: PathBuf,
unpacker: Arc<dyn Unpacker>,
shutdown: CancellationToken,
task_tracker: TaskTracker,
catalog: Arc<CatalogBundle>,
active_outbound_transfers: crate::context::OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
local_network_sharing: bool,
) -> eyre::Result<()> {
let (network_manager, network) = NetworkManager::new();
let ctx = Ctx::new(
peer_game_db,
peer_identity,
game_dir,
state_dir,
unpacker,
shutdown,
task_tracker,
catalog,
active_outbound_transfers,
stream_install_provider,
network,
)?;
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
log::error!("Failed to load initial local game database: {err}");
}
startup::spawn_core_services(&ctx, &tx_notify_ui);
let manager_ctx = ctx.clone();
let manager_tx = tx_notify_ui.clone();
ctx.task_tracker.clone().spawn(async move {
network_manager
.run(manager_ctx, manager_tx, local_network_sharing)
.await;
});
if let Err(err) = handle_peer_commands(&ctx, &tx_notify_ui, &mut rx_control).await {
let error = err.to_string();
log::error!("Peer command loop failed: {error}");
events::send(
&tx_notify_ui,
PeerEvent::RuntimeFailed {
component: PeerRuntimeComponent::CommandLoop,
error,
},
);
ctx.shutdown.cancel();
}
Ok(())
}
async fn handle_peer_commands(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
rx_control: &mut UnboundedReceiver<PeerCommand>,
) -> eyre::Result<()> {
loop {
let cmd = tokio::select! {
() = ctx.shutdown.cancelled() => return Ok(()),
cmd = rx_control.recv() => cmd,
};
let Some(cmd) = cmd else {
if ctx.shutdown.is_cancelled() {
return Ok(());
}
eyre::bail!("peer command channel closed unexpectedly");
};
match cmd {
PeerCommand::ListGames => {
handle_list_games_command(ctx, tx_notify_ui).await;
}
PeerCommand::DownloadGameFiles { id } => {
handle_download_game_files_command(ctx, tx_notify_ui, id, true).await;
}
PeerCommand::DownloadGameFilesWithOptions {
id,
install_after_download,
} => {
handle_download_game_files_command(ctx, tx_notify_ui, id, install_after_download)
.await;
}
PeerCommand::StreamInstallGame { id, settings } => {
handlers::handle_stream_install_game_command(ctx, tx_notify_ui, id, settings).await;
}
PeerCommand::InstallGame { id } => {
handle_install_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::UninstallGame { id } => {
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::RemoveDownloadedGame { id } => {
handle_remove_downloaded_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::CancelDownload { id } => {
handle_cancel_download_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::SetGameDir { path, reply } => {
let result = handle_set_game_dir_command(ctx, tx_notify_ui, path).await;
let _ = reply.send(result);
}
PeerCommand::SetLocalNetworkSharing { enabled, reply } => {
let _ = reply.send(ctx.network.set_enabled(enabled).await);
}
PeerCommand::GetPeerCount => {
handle_get_peer_count_command(ctx, tx_notify_ui).await;
}
PeerCommand::ConnectPeer(endpoint) => {
handle_connect_peer_command(ctx, tx_notify_ui, endpoint).await;
}
PeerCommand::ApplyCallToPlayIntent {
intent,
display_name,
reply,
} => {
handle_apply_call_to_play_intent(ctx, tx_notify_ui, intent, display_name, reply)
.await;
}
PeerCommand::SetCallToPlayDisplayName {
display_name,
reply,
} => {
handle_set_call_to_play_display_name(ctx, tx_notify_ui, display_name, reply).await;
}
PeerCommand::GetCallToPlayView { reply } => {
handle_get_call_to_play_view(ctx, tx_notify_ui, reply).await;
}
}
}
}
async fn handle_apply_call_to_play_intent(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
intent: CallToPlayLocalIntent,
display_name: String,
reply: oneshot::Sender<Result<CallToPlayReceipt, String>>,
) {
let _network_permit = match ctx.network.try_acquire() {
Ok(permit) => permit,
Err(error) => {
let _ = reply.send(Err(error.to_string()));
return;
}
};
let result = {
let mut store = ctx.call_to_play.write().await;
match store.publish_local(intent.into(), display_name) {
Ok((receipt, publication)) => {
ctx.state_sync
.publish_call_to_play_revision(publication.local_revision);
events::send(
tx_notify_ui,
PeerEvent::CallToPlayView(CallToPlayView::from(publication.view)),
);
Ok(CallToPlayReceipt::from(receipt))
}
Err(error) => Err(error.to_string()),
}
};
let _ = reply.send(result);
}
async fn handle_set_call_to_play_display_name(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
display_name: String,
reply: oneshot::Sender<Result<bool, String>>,
) {
let result = {
let mut store = ctx.call_to_play.write().await;
match store.set_local_display_name(display_name) {
Ok((mutation, publication)) => {
if publication.local_changed {
ctx.state_sync
.publish_call_to_play_revision(publication.local_revision);
events::send(
tx_notify_ui,
PeerEvent::CallToPlayView(CallToPlayView::from(publication.view)),
);
}
Ok(mutation.is_some())
}
Err(error) => Err(error.to_string()),
}
};
let _ = reply.send(result);
}
async fn handle_get_call_to_play_view(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
reply: Option<oneshot::Sender<Result<CallToPlayView, String>>>,
) {
let result = {
let mut store = ctx.call_to_play.write().await;
match store.current_publication() {
Ok(publication) => {
if publication.local_changed {
ctx.state_sync
.publish_call_to_play_revision(publication.local_revision);
}
let view = CallToPlayView::from(publication.view);
events::send(tx_notify_ui, PeerEvent::CallToPlayView(view.clone()));
Ok(view)
}
Err(error) => Err(error.to_string()),
}
};
if let Some(reply) = reply {
let _ = reply.send(result);
}
}
pub(crate) fn canonicalize_game_dir(game_dir: &Path) -> eyre::Result<PathBuf> {
let canonical = std::fs::canonicalize(game_dir).map_err(|error| {
eyre::eyre!(
"failed to canonicalize game directory {}: {error}",
game_dir.display()
)
})?;
let metadata = std::fs::symlink_metadata(&canonical).map_err(|error| {
eyre::eyre!(
"failed to inspect canonical game directory {}: {error}",
canonical.display()
)
})?;
if !metadata.is_dir() {
eyre::bail!(
"configured game directory is not a directory: {}",
canonical.display()
);
}
Ok(canonical)
}
#[cfg(test)]
mod configured_game_dir_tests {
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use tokio::sync::{RwLock, mpsc};
use tokio_util::sync::CancellationToken;
use super::{
PeerEvent,
PeerIdentityDurability,
PeerStartOptions,
UnpackFuture,
Unpacker,
canonicalize_game_dir,
identity_durability,
load_peer_identity,
start_peer_with_options,
};
use crate::{
NoopStreamInstallProvider,
PeerIdentityPersistence,
PeerIdentityPersistenceFailure,
PeerIdentityPersistenceFailureKind,
identity::load_or_generate_peer_identity_with_write_failure,
install::intent::{InstallIntent, InstallIntentState, intent_path, write_intent},
peer_db::PeerGameDB,
state_paths::peer_identity_path,
test_support::{TempDir, empty_catalog_bundle},
};
struct NoopUnpacker;
#[test]
fn peer_start_options_default_to_local_network_sharing() {
let options = PeerStartOptions::default();
assert!(options.local_network_sharing);
assert!(format!("{options:?}").contains("local_network_sharing: true"));
}
#[test]
fn identity_durability_redacts_persistence_details() {
for persistence in [
PeerIdentityPersistence::Loaded,
PeerIdentityPersistence::Created,
PeerIdentityPersistence::ReplacedCorrupt {
quarantine_path: PathBuf::from("/private/quarantine-path"),
},
] {
assert_eq!(
identity_durability(&persistence),
PeerIdentityDurability::Persistent
);
}
let persistence = PeerIdentityPersistence::Ephemeral(PeerIdentityPersistenceFailure {
kind: PeerIdentityPersistenceFailureKind::Write,
path: PathBuf::from("/private/identity-path"),
message: "private backend detail".to_owned(),
});
let durability = identity_durability(&persistence);
assert_eq!(durability, PeerIdentityDurability::Ephemeral);
assert_eq!(
serde_json::to_string(&durability).expect("durability should serialize"),
"\"Ephemeral\""
);
}
impl Unpacker for NoopUnpacker {
fn unpack<'a>(
&'a self,
_archive: &'a Path,
_dest: &'a Path,
_cancel_token: CancellationToken,
) -> UnpackFuture<'a> {
Box::pin(async { Ok(()) })
}
}
#[tokio::test(flavor = "multi_thread")]
async fn startup_canonicalizes_lexical_root_alias_before_storage() {
let target = TempDir::new("lanspread-startup-game-root-target");
let state = TempDir::new("lanspread-startup-state-root");
let alias = target.path().join(".");
let expected = std::fs::canonicalize(target.path()).expect("target should canonicalize");
let (events, _event_rx) = mpsc::unbounded_channel();
let mut handle = start_peer_with_options(
alias,
events,
Arc::new(RwLock::new(PeerGameDB::new())),
Arc::new(NoopUnpacker),
empty_catalog_bundle(),
PeerStartOptions {
state_dir: Some(state.path().to_path_buf()),
..PeerStartOptions::default()
},
)
.expect("peer startup should accept a lexical root alias");
assert_eq!(handle.accepted_game_dir(), expected);
assert_eq!(
handle.identity_durability(),
PeerIdentityDurability::Persistent
);
assert_eq!(
handle.peer_id(),
load_peer_identity(&peer_identity_path(state.path()))
.expect("persisted startup identity should load")
.peer_id()
);
handle.shutdown();
handle.wait_stopped().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn ephemeral_write_failed_identity_participates_as_local_peer_ready() {
let games = TempDir::new("lanspread-startup-ephemeral-write-games");
let state = TempDir::new("lanspread-startup-ephemeral-write-state");
let loaded = load_or_generate_peer_identity_with_write_failure(state.path())
.expect("injected persistence failure should retain one identity");
assert!(matches!(
&loaded.persistence,
PeerIdentityPersistence::Ephemeral(failure)
if failure.kind == PeerIdentityPersistenceFailureKind::Write
));
let durability = identity_durability(&loaded.persistence);
let identity = Arc::new(loaded.identity);
let expected_peer_id = identity.peer_id();
let (tx_control, rx_control) = mpsc::unbounded_channel();
let (tx_events, mut rx_events) = mpsc::unbounded_channel();
let mut handle = crate::startup::spawn_peer_runtime(
tx_control,
rx_control,
tx_events,
Arc::new(RwLock::new(PeerGameDB::new())),
Arc::clone(&identity),
durability,
std::fs::canonicalize(games.path()).expect("games root should canonicalize"),
state.path().to_path_buf(),
Arc::new(NoopUnpacker),
empty_catalog_bundle(),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(NoopStreamInstallProvider),
true,
)
.expect("runtime should start with the ephemeral identity");
assert!(Arc::ptr_eq(&handle.identity(), &identity));
assert_eq!(
handle.identity_durability(),
PeerIdentityDurability::Ephemeral
);
let ready_peer_id = tokio::time::timeout(Duration::from_secs(5), async {
loop {
match rx_events.recv().await {
Some(PeerEvent::LocalPeerReady { peer_id, .. }) => break peer_id,
Some(PeerEvent::RuntimeFailed { component, error }) => {
panic!("runtime component {component:?} failed before readiness: {error}");
}
Some(_) => {}
None => panic!("runtime event channel closed before readiness"),
}
}
})
.await
.expect("sharing-enabled runtime should publish listener readiness");
assert_eq!(ready_peer_id, expected_peer_id.to_string());
handle.shutdown();
handle.wait_stopped().await;
}
#[cfg(unix)]
#[test]
fn startup_canonicalizes_symlink_root_alias_before_storage() {
use std::os::unix::fs::symlink;
let target = TempDir::new("lanspread-startup-game-root-target");
let aliases = TempDir::new("lanspread-startup-game-root-alias");
let alias = aliases.path().join("games");
symlink(target.path(), &alias).expect("configured-root alias should be created");
assert_eq!(
canonicalize_game_dir(&alias).expect("configured-root alias should be accepted"),
std::fs::canonicalize(target.path()).expect("target should canonicalize")
);
}
#[test]
fn startup_rejects_a_non_directory_root() {
let target = TempDir::new("lanspread-startup-game-root-file");
let file = target.path().join("games");
std::fs::write(&file, b"not a directory").expect("test file should be written");
let error = canonicalize_game_dir(&file).expect_err("file root should be rejected");
assert!(error.to_string().contains("is not a directory"));
}
#[tokio::test(flavor = "multi_thread")]
async fn startup_rejects_foreign_intent_for_game_absent_from_candidate_root() {
let old_games = TempDir::new("lanspread-startup-foreign-intent-old-root");
let candidate = TempDir::new("lanspread-startup-foreign-intent-candidate");
let state = TempDir::new("lanspread-startup-foreign-intent-state");
let intent = InstallIntent::new(
&old_games.path().join("orphan"),
"orphan",
InstallIntentState::Updating,
None,
)
.expect("old-root intent should be valid");
write_intent(state.path(), "orphan", &intent).expect("old-root intent should be persisted");
let (events, _event_rx) = mpsc::unbounded_channel();
let result = start_peer_with_options(
candidate.path().to_path_buf(),
events,
Arc::new(RwLock::new(PeerGameDB::new())),
Arc::new(NoopUnpacker),
empty_catalog_bundle(),
PeerStartOptions {
state_dir: Some(state.path().to_path_buf()),
..PeerStartOptions::default()
},
);
let error = match result {
Ok(mut handle) => {
handle.shutdown();
handle.wait_stopped().await;
panic!("foreign install intent should reject peer startup");
}
Err(error) => error,
};
assert!(error.to_string().contains("different configured games"));
assert!(!candidate.path().join("orphan").exists());
assert!(intent_path(state.path(), "orphan").is_file());
assert!(!peer_identity_path(state.path()).exists());
}
}