//! 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, 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, deadline: i64, }, Respond { ready_at: Option, }, 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, } /// 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, } impl From for call_to_play::CallToPlayLocalIntent { fn from(intent: CallToPlayLocalIntent) -> Self { Self { call_id: intent.call_id, action: intent.action.into(), } } } impl From 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 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 for CallToPlayView { fn from(view: call_to_play::CallToPlayView) -> Self { Self { events: view.events.into_iter().map(Into::into).collect(), } } } impl From 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, }, /// 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, expected: u32, }, /// The local library contents changed after a scan. LocalLibraryChanged { games: Vec }, /// The number of active outbound transfers changed. OutboundTransferCountChanged(OutboundTransferChange), /// The set of in-progress local operations changed. ActiveOperationsChanged { active_operations: Vec, }, /// 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>, }, /// Enable or disable all Local network sharing services and await drainage. SetLocalNetworkSharing { enabled: bool, reply: oneshot::Sender>, }, /// 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>, }, /// Update the locally authored display name, including without a new event. SetCallToPlayDisplayName { display_name: String, reply: oneshot::Sender>, }, /// Request the complete in-memory Call-to-Play projection. GetCallToPlayView { reply: Option>>, }, } /// 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, /// Explicit installation identity. When absent, the state directory owns it. pub identity: Option>, pub active_outbound_transfers: Option, /// Provider used to stream archive entries for low-disk streamed installs. pub stream_install_provider: Option>, /// 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, tx_notify_ui: UnboundedSender, peer_game_db: Arc>, unpacker: Arc, catalog: Arc, ) -> eyre::Result { 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, tx_notify_ui: UnboundedSender, peer_game_db: Arc>, unpacker: Arc, catalog: Arc, options: PeerStartOptions, ) -> eyre::Result { 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, tx_notify_ui: UnboundedSender, peer_game_db: Arc>, peer_identity: Arc, game_dir: PathBuf, state_dir: PathBuf, unpacker: Arc, shutdown: CancellationToken, task_tracker: TaskTracker, catalog: Arc, active_outbound_transfers: crate::context::OutboundTransfers, stream_install_provider: Arc, 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, rx_control: &mut UnboundedReceiver, ) -> 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, intent: CallToPlayLocalIntent, display_name: String, reply: oneshot::Sender>, ) { 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, display_name: String, reply: oneshot::Sender>, ) { 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, reply: Option>>, ) { 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 { 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()); } }