diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1e597dae..5eb64689b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -259,6 +259,15 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Verify native deps were downloaded + if: runner.os == 'Linux' + run: | + echo "Checking apps/.deps directory:" + ls -la apps/.deps/ || echo "Directory doesn't exist" + echo "Checking apps/.deps/lib directory:" + ls -la apps/.deps/lib/ || echo "lib/ subdirectory doesn't exist - creating empty dir" + mkdir -p apps/.deps/lib + - name: Build working-directory: apps/tauri run: | diff --git a/TODO b/TODO index c7dcb526b..f3c7cae94 100644 --- a/TODO +++ b/TODO @@ -32,7 +32,7 @@ Journey to v2.0.0-pre.1: ✔ Fix volume reactivity @done(26-01-20 14:59) ☐ Merge ephemeral results with indexed results to enable showing hidden files on demand ☐ Mobile explorer - ☐ search + ✔ search @done(26-01-22 12:21) ☐ inspector ☐ context menu ☐ view settings diff --git a/apps/cli/src/domains/events/mod.rs b/apps/cli/src/domains/events/mod.rs index 9b4cf2dc0..5fc699f96 100644 --- a/apps/cli/src/domains/events/mod.rs +++ b/apps/cli/src/domains/events/mod.rs @@ -441,6 +441,26 @@ fn summarize_event(event: &Event) -> String { format!("Sync error: {}", message) } + // Proxy pairing events + Event::ProxyPairingConfirmationRequired { + vouchee_device_name, + voucher_device_name, + .. + } => { + format!( + "Proxy pairing confirmation required: {} vouched by {}", + vouchee_device_name, voucher_device_name + ) + } + Event::ProxyPairingVouchingReady { + vouchee_device_id, .. + } => { + format!("Proxy pairing vouching ready for device {}", vouchee_device_id) + } + + // Config events + Event::ConfigChanged { .. } => "Configuration changed".to_string(), + // Custom events Event::Custom { event_type, data } => { format!("Custom event: {} - {:?}", event_type, data) @@ -452,3 +472,4 @@ fn summarize_event(event: &Event) -> String { } } } +} diff --git a/apps/cli/src/domains/sync/mod.rs b/apps/cli/src/domains/sync/mod.rs index 1d104d074..2a0748e2e 100644 --- a/apps/cli/src/domains/sync/mod.rs +++ b/apps/cli/src/domains/sync/mod.rs @@ -24,10 +24,14 @@ pub enum SyncCmd { /// Export sync event log Events(SyncEventsArgs), + + /// Show computed sync partners for this library + Partners, } pub async fn run(ctx: &Context, cmd: SyncCmd) -> Result<()> { match cmd { + SyncCmd::Partners => show_partners(ctx).await?, SyncCmd::Events(args) => export_events(ctx, args).await?, SyncCmd::Metrics(args) => { // Parse time filters @@ -590,3 +594,95 @@ fn format_events_markdown( output } + +async fn show_partners(ctx: &Context) -> Result<()> { + let library_id = ctx.library_id.ok_or_else(|| { + anyhow::anyhow!("No library selected. Use 'sd library switch' to select a library first.") + })?; + + // Create input for the operation + let input = sd_core::ops::sync::get_sync_partners::GetSyncPartnersInput {}; + + let json_response = ctx.core.query(&input, Some(library_id)).await?; + let output: sd_core::ops::sync::get_sync_partners::GetSyncPartnersOutput = + serde_json::from_value(json_response)?; + + println!("\n{}", "Sync Partners for Current Library".bold()); + println!("{}", "═".repeat(60)); + println!(); + + if output.partners.is_empty() { + println!(" {} No connected sync partners found", "●".red()); + println!(); + println!(" Possible reasons:"); + println!(" - No other devices paired with this device"); + println!(" - Paired devices are not in this library's devices table"); + println!(" - Paired devices do not have sync_enabled=true"); + println!(); + } else { + println!(" {} {} sync partner(s) available", "●".green(), output.partners.len()); + println!(); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(Row::from(vec![ + Cell::new("Device UUID").add_attribute(Attribute::Bold), + Cell::new("Name").add_attribute(Attribute::Bold), + Cell::new("Status").add_attribute(Attribute::Bold), + ])); + + for partner in &output.partners { + table.add_row(vec![ + partner.device_uuid.to_string(), + partner.device_name.clone(), + if partner.is_paired { + "✓ Paired".green().to_string() + } else { + "○ Not Paired".dark_grey().to_string() + }, + ]); + } + + println!("{}", table); + println!(); + } + + // Show debug info + println!("{}", "Library Membership Debug".dark_grey().bold()); + println!("{}", "─".repeat(60).dark_grey()); + println!(); + println!(" Total devices in library: {}", output.debug_info.total_devices); + println!(" Devices with sync_enabled: {}", output.debug_info.sync_enabled_devices); + println!(" Devices with NodeId mapping: {}", output.debug_info.paired_devices); + println!(" Final sync partners: {}", output.debug_info.final_sync_partners); + println!(); + + if !output.debug_info.device_details.is_empty() { + let mut debug_table = Table::new(); + debug_table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(Row::from(vec![ + Cell::new("Device").add_attribute(Attribute::Bold).fg(Color::DarkGrey), + Cell::new("Sync Enabled").add_attribute(Attribute::Bold).fg(Color::DarkGrey), + Cell::new("Has NodeId").add_attribute(Attribute::Bold).fg(Color::DarkGrey), + Cell::new("NodeId").add_attribute(Attribute::Bold).fg(Color::DarkGrey), + ])); + + for device in &output.debug_info.device_details { + debug_table.add_row(vec![ + Cell::new(&device.name).fg(Color::DarkGrey), + Cell::new(if device.sync_enabled { "✓" } else { "✗" }).fg(Color::DarkGrey), + Cell::new(if device.has_node_id { "✓" } else { "✗" }).fg(Color::DarkGrey), + Cell::new(device.node_id.as_deref().unwrap_or("-")).fg(Color::DarkGrey), + ]); + } + + println!("{}", debug_table); + println!(); + } + + Ok(()) +} diff --git a/apps/mobile/src/screens/overview/components/DevicePanel.tsx b/apps/mobile/src/screens/overview/components/DevicePanel.tsx index 70f52eaae..54b142844 100644 --- a/apps/mobile/src/screens/overview/components/DevicePanel.tsx +++ b/apps/mobile/src/screens/overview/components/DevicePanel.tsx @@ -20,7 +20,7 @@ import { useVolumeIndexingStore } from "../../../stores"; // Temporary type extension type DeviceWithConnection = Device & { - connection_method?: "Direct" | "Relay" | "Mixed" | null; + connection_method?: "LocalNetwork" | "DirectInternet" | "RelayProxy" | null; }; function formatBytes(bytes: number): string { @@ -194,14 +194,14 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) { } interface ConnectionBadgeProps { - method: "Direct" | "Relay" | "Mixed"; + method: "LocalNetwork" | "DirectInternet" | "RelayProxy"; } function ConnectionBadge({ method }: ConnectionBadgeProps) { const labels = { - Direct: "Local", - Relay: "Relay", - Mixed: "Mixed", + LocalNetwork: "Local", + DirectInternet: "Direct", + RelayProxy: "Relay", }; return ( diff --git a/core/examples/library_demo.rs b/core/examples/library_demo.rs index 3be9554bc..324386dfb 100644 --- a/core/examples/library_demo.rs +++ b/core/examples/library_demo.rs @@ -97,7 +97,6 @@ async fn main() -> Result<(), Box> { created_at: Set(device.created_at), updated_at: Set(device.updated_at), sync_enabled: Set(false), - last_sync_at: Set(None), }; let inserted_device = device_model.insert(db.conn()).await?; println!(" ✓ Device registered"); diff --git a/core/src/bin/daemon.rs b/core/src/bin/daemon.rs index 58b9ca7d8..49783cf89 100644 --- a/core/src/bin/daemon.rs +++ b/core/src/bin/daemon.rs @@ -32,7 +32,7 @@ struct Args { } #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<(), Box> { let args = Args::parse(); // Resolve base data directory diff --git a/core/src/config/app_config.rs b/core/src/config/app_config.rs index f48ca63c6..c6d7ed4b1 100644 --- a/core/src/config/app_config.rs +++ b/core/src/config/app_config.rs @@ -37,6 +37,10 @@ pub struct AppConfig { /// Daemon logging configuration with multi-stream support #[serde(default)] pub logging: LoggingConfig, + + /// Proxy pairing configuration + #[serde(default)] + pub proxy_pairing: ProxyPairingConfig, } /// Configuration for core services @@ -134,6 +138,33 @@ pub struct LoggingConfig { pub streams: Vec, } +/// Proxy pairing configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyPairingConfig { + /// Automatically accept vouches from trusted devices + pub auto_accept_vouched: bool, + /// Automatically vouch new devices to all paired devices + pub auto_vouch_to_all: bool, + /// Maximum age of vouch signatures in seconds + pub vouch_signature_max_age: u64, + /// Timeout for proxy confirmation in seconds + pub vouch_response_timeout: u64, + /// Maximum retries for queued vouches + pub vouch_queue_retry_limit: u32, +} + +impl Default for ProxyPairingConfig { + fn default() -> Self { + Self { + auto_accept_vouched: true, + auto_vouch_to_all: false, + vouch_signature_max_age: 300, + vouch_response_timeout: 60, + vouch_queue_retry_limit: 5, + } + } +} + impl Default for LoggingConfig { fn default() -> Self { Self { @@ -210,6 +241,7 @@ impl AppConfig { job_logging: JobLoggingConfig::default(), services: ServiceConfig::default(), logging: LoggingConfig::default(), + proxy_pairing: ProxyPairingConfig::default(), } } @@ -273,7 +305,7 @@ impl Migrate for AppConfig { } fn target_version() -> u32 { - 4 // Updated schema version for multi-stream logging + 5 // Added proxy pairing configuration } fn migrate(&mut self) -> Result<()> { @@ -301,7 +333,13 @@ impl Migrate for AppConfig { self.version = 4; Ok(()) } - 4 => Ok(()), // Already at target version + 4 => { + // Migration from v4 to v5: Add proxy pairing configuration + self.proxy_pairing = ProxyPairingConfig::default(); + self.version = 5; + Ok(()) + } + 5 => Ok(()), // Already at target version v => Err(anyhow!("Unknown config version: {}", v)), } } diff --git a/core/src/device/manager.rs b/core/src/device/manager.rs index 5ad8778e4..f96dfcf16 100644 --- a/core/src/device/manager.rs +++ b/core/src/device/manager.rs @@ -345,7 +345,6 @@ impl DeviceManager { is_online: true, last_seen_at: chrono::Utc::now(), sync_enabled: true, - last_sync_at: None, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), // Ephemeral fields diff --git a/core/src/domain/device.rs b/core/src/domain/device.rs index 5825dfca5..e31657022 100644 --- a/core/src/domain/device.rs +++ b/core/src/domain/device.rs @@ -85,9 +85,6 @@ pub struct Device { /// Whether sync is enabled for this device pub sync_enabled: bool, - /// Last time this device synced - pub last_sync_at: Option>, - /// When this device was first added pub created_at: DateTime, @@ -116,27 +113,68 @@ pub struct Device { /// Network connection method for a device #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Type)] pub enum ConnectionMethod { - /// Direct peer-to-peer connection (mDNS/local network) - Direct, - /// Connection via relay server - Relay, - /// Mixed connection (both direct and relay) - Mixed, + /// Direct connection on local network (mDNS/same subnet) + /// Fastest option - wire speed, no internet required + LocalNetwork, + /// Direct UDP connection over internet (NAT traversal) + /// Fast, but requires internet. Uses no relay bandwidth. + DirectInternet, + /// Connection proxied through relay server + /// Reliable fallback. Relay hosts the bandwidth. + RelayProxy, } impl ConnectionMethod { /// Convert from Iroh's ConnectionType + /// + /// For Mixed connections (UDP + relay simultaneously), we report the + /// Direct path since that's what Iroh is attempting to use primarily. pub fn from_iroh_connection_type(conn_type: iroh::endpoint::ConnectionType) -> Option { use iroh::endpoint::ConnectionType; match conn_type { - ConnectionType::Direct(_) => Some(Self::Direct), - ConnectionType::Relay(_) => Some(Self::Relay), - ConnectionType::Mixed(_, _) => Some(Self::Mixed), + ConnectionType::Direct(addr) => { + if is_local_address(&addr) { + Some(Self::LocalNetwork) + } else { + Some(Self::DirectInternet) + } + } + ConnectionType::Relay(_) => Some(Self::RelayProxy), + // Mixed means both UDP and relay are active, but UDP is preferred + // Report the UDP path since that's what Iroh will use when confirmed + ConnectionType::Mixed(addr, _relay) => { + if is_local_address(&addr) { + Some(Self::LocalNetwork) + } else { + Some(Self::DirectInternet) + } + } ConnectionType::None => None, } } } +/// Check if a socket address is a local/private network address +fn is_local_address(addr: &std::net::SocketAddr) -> bool { + match addr.ip() { + std::net::IpAddr::V4(ipv4) => { + ipv4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + || ipv4.is_loopback() // 127.0.0.0/8 + || ipv4.is_link_local() // 169.254.0.0/16 + } + std::net::IpAddr::V6(ipv6) => { + ipv6.is_loopback() // ::1 + || ipv6.is_unicast_link_local() // fe80::/10 + || is_ipv6_unique_local(&ipv6) // fc00::/7 + } + } +} + +/// Check if IPv6 address is in unique local range (fc00::/7) +fn is_ipv6_unique_local(ipv6: &std::net::Ipv6Addr) -> bool { + matches!(ipv6.segments()[0] & 0xfe00, 0xfc00) +} + /// Operating system types #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Type)] pub enum OperatingSystem { @@ -206,7 +244,6 @@ impl Device { is_online: true, last_seen_at: now, sync_enabled: true, - last_sync_at: None, created_at: now, updated_at: now, // Ephemeral fields @@ -316,7 +353,6 @@ impl Device { is_online: is_connected, last_seen_at: info.last_seen, sync_enabled: true, - last_sync_at: None, created_at: info.last_seen, updated_at: info.last_seen, // Ephemeral fields @@ -1143,7 +1179,6 @@ impl From for entities::device::ActiveModel { capabilities: Set(device.capabilities), created_at: Set(device.created_at), sync_enabled: Set(device.sync_enabled), - last_sync_at: Set(device.last_sync_at), updated_at: Set(device.updated_at), } } @@ -1183,7 +1218,6 @@ impl TryFrom for Device { is_online: model.is_online, last_seen_at: model.last_seen_at, sync_enabled: model.sync_enabled, - last_sync_at: model.last_sync_at, created_at: model.created_at, updated_at: model.updated_at, // Ephemeral fields - set by caller based on context diff --git a/core/src/infra/daemon/bootstrap.rs b/core/src/infra/daemon/bootstrap.rs index e4e960855..13d1c1be9 100644 --- a/core/src/infra/daemon/bootstrap.rs +++ b/core/src/infra/daemon/bootstrap.rs @@ -10,7 +10,7 @@ pub async fn start_default_server( socket_addr: String, data_dir: PathBuf, enable_networking: bool, -) -> Result<(), Box> { +) -> Result<(), Box> { // Initialize basic tracing with file logging first initialize_tracing_with_file_logging(&data_dir)?; @@ -59,7 +59,7 @@ pub async fn start_default_server( /// Supports multi-stream logging with per-stream filters fn initialize_tracing_with_file_logging( data_dir: &PathBuf, -) -> Result<(), Box> { +) -> Result<(), Box> { use crate::config::AppConfig; use crate::infra::event::log_emitter::LogEventLayer; use std::sync::Once; @@ -69,7 +69,7 @@ fn initialize_tracing_with_file_logging( }; static INIT: Once = Once::new(); - let mut result: Result<(), Box> = Ok(()); + let mut result: Result<(), Box> = Ok(()); INIT.call_once(|| { // Ensure logs directory exists diff --git a/core/src/infra/daemon/rpc.rs b/core/src/infra/daemon/rpc.rs index c3b9a89bc..8e3aa781b 100644 --- a/core/src/infra/daemon/rpc.rs +++ b/core/src/infra/daemon/rpc.rs @@ -54,7 +54,7 @@ impl RpcServer { } } - pub async fn start(&mut self) -> Result<(), Box> { + pub async fn start(&mut self) -> Result<(), Box> { tracing::info!("Starting RPC server..."); let listener = TcpListener::bind(&self.socket_addr).await?; tracing::info!("RPC server bound to: {}", self.socket_addr); @@ -132,7 +132,7 @@ impl RpcServer { } /// Start the event broadcaster that forwards core events to subscribed connections - async fn start_event_broadcaster(&self) -> Result<(), Box> { + async fn start_event_broadcaster(&self) -> Result<(), Box> { let core = self.core.clone(); // Make the core's LogBus globally available to the LogEventLayer diff --git a/core/src/infra/db/entities/device.rs b/core/src/infra/db/entities/device.rs index 732b396c8..0d7fde916 100644 --- a/core/src/infra/db/entities/device.rs +++ b/core/src/infra/db/entities/device.rs @@ -39,10 +39,7 @@ pub struct Model { #[serde(default)] pub updated_at: DateTimeUtc, - // Sync coordination fields (added in m20251009_000001_add_sync_to_devices) - // Watermarks moved to sync.db per-resource tracking (m20251115_000001) pub sync_enabled: bool, - pub last_sync_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -68,8 +65,8 @@ impl crate::infra::sync::Syncable for Model { } fn version(&self) -> i64 { - // Device sync is state-based, version not needed - 1 + // Use updated_at timestamp as version for conflict resolution + self.updated_at.timestamp() } fn exclude_fields() -> Option<&'static [&'static str]> { @@ -127,9 +124,10 @@ impl crate::infra::sync::Syncable for Model { Ok(records.into_iter().map(|r| (r.id, r.uuid)).collect()) } - /// Query devices for sync backfill + /// Query devices for sync backfill (shared resources) + /// Returns ALL devices in library, not filtered by device_id async fn query_for_sync( - device_id: Option, + _device_id: Option, since: Option>, _cursor: Option<(chrono::DateTime, Uuid)>, batch_size: usize, @@ -139,12 +137,7 @@ impl crate::infra::sync::Syncable for Model { let mut query = Entity::find(); - // Filter by device UUID if specified - if let Some(dev_id) = device_id { - query = query.filter(Column::Uuid.eq(dev_id)); - } - - // Filter by timestamp if specified + // Filter by timestamp if specified (for incremental sync) if let Some(since_time) = since { query = query.filter(Column::UpdatedAt.gte(since_time)); } @@ -167,137 +160,327 @@ impl crate::infra::sync::Syncable for Model { .collect()) } - /// Apply device state change (idempotent upsert) - async fn apply_state_change( - data: serde_json::Value, + /// Apply shared change with HLC-based conflict resolution + /// Slug changes propagate to all devices, with collision avoidance only on initial insert + async fn apply_shared_change( + entry: crate::infra::sync::SharedChangeEntry, db: &DatabaseConnection, ) -> Result<(), sea_orm::DbErr> { - tracing::debug!("[DEVICE_SYNC] apply_state_change called"); - - // Deserialize incoming data - let device: Model = serde_json::from_value(data) - .map_err(|e| sea_orm::DbErr::Custom(format!("Device deserialization failed: {}", e)))?; - - tracing::debug!( - "[DEVICE_SYNC] Processing device: uuid={}, slug={}", - device.uuid, - device.slug - ); - - // Check if this device already exists (by UUID) + use crate::infra::sync::ChangeType; use sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set}; - let existing_device = Entity::find() - .filter(Column::Uuid.eq(device.uuid)) - .one(db) - .await?; - - // Determine the slug to use - let slug_to_use = if let Some(existing) = existing_device { - // Device exists - keep its existing slug to avoid breaking references - tracing::debug!( - "[DEVICE_SYNC] Device exists, keeping existing slug: {}", - existing.slug - ); - existing.slug - } else { - // New device - check for slug collisions - tracing::debug!("[DEVICE_SYNC] New device, checking for slug collisions"); - let existing_slugs: Vec = Entity::find() - .all(db) - .await? - .iter() - .map(|d| d.slug.clone()) - .collect(); - - tracing::debug!( - "[DEVICE_SYNC] Existing slugs in database: {:?}", - existing_slugs - ); - - let unique_slug = - crate::library::Library::ensure_unique_slug(&device.slug, &existing_slugs); - - if unique_slug != device.slug { + match entry.change_type { + ChangeType::Insert | ChangeType::Update => { tracing::debug!( - "[DEVICE_SYNC] Slug collision! Using '{}' instead of '{}'", - unique_slug, - device.slug + "[DEVICE_SYNC] Applying shared change: type={:?}, uuid={}", + entry.change_type, + entry.record_uuid ); - } else { - tracing::debug!("[DEVICE_SYNC] No collision, using slug: {}", unique_slug); + + // Extract fields from JSON + let data = entry.data.as_object().ok_or_else(|| { + sea_orm::DbErr::Custom("Device data is not an object".to_string()) + })?; + + let uuid: Uuid = serde_json::from_value( + data.get("uuid") + .ok_or_else(|| sea_orm::DbErr::Custom("Missing uuid".to_string()))? + .clone(), + ) + .map_err(|e| sea_orm::DbErr::Custom(format!("Invalid uuid: {}", e)))?; + + // Check if device already exists + let existing_device = Entity::find().filter(Column::Uuid.eq(uuid)).one(db).await?; + + // Determine slug to use: collision avoidance only on INSERT + let slug_from_data: String = serde_json::from_value( + data.get("slug") + .cloned() + .unwrap_or(serde_json::Value::String("unknown".to_string())), + ) + .unwrap_or_else(|_| "unknown".to_string()); + + let slug_to_use = if let Some(existing) = &existing_device { + // Device exists - use incoming slug (allow slug changes to propagate) + tracing::debug!( + "[DEVICE_SYNC] Updating existing device, accepting slug change: {} -> {}", + existing.slug, + slug_from_data + ); + slug_from_data + } else { + // New device - check for slug collisions + tracing::debug!("[DEVICE_SYNC] New device, checking for slug collisions"); + let existing_slugs: Vec = Entity::find() + .all(db) + .await? + .iter() + .map(|d| d.slug.clone()) + .collect(); + + let unique_slug = crate::library::Library::ensure_unique_slug( + &slug_from_data, + &existing_slugs, + ); + + if unique_slug != slug_from_data { + tracing::debug!( + "[DEVICE_SYNC] Slug collision on insert! Using '{}' instead of '{}'", + unique_slug, + slug_from_data + ); + } + + unique_slug + }; + + // Build ActiveModel for upsert + let active = ActiveModel { + id: NotSet, + uuid: Set(uuid), + name: Set(serde_json::from_value( + data.get("name") + .cloned() + .unwrap_or(serde_json::Value::String("Unknown".to_string())), + ) + .unwrap_or_else(|_| "Unknown".to_string())), + slug: Set(slug_to_use), + os: Set(serde_json::from_value( + data.get("os") + .cloned() + .unwrap_or(serde_json::Value::String("Unknown".to_string())), + ) + .unwrap_or_else(|_| "Unknown".to_string())), + os_version: Set( + data.get("os_version") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid os_version: {}", e)) + }) + }) + .transpose()?, + ), + hardware_model: Set( + data.get("hardware_model") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid hardware_model: {}", e)) + }) + }) + .transpose()?, + ), + cpu_model: Set( + data.get("cpu_model") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid cpu_model: {}", e)) + }) + }) + .transpose()?, + ), + cpu_architecture: Set( + data.get("cpu_architecture") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid cpu_architecture: {}", e)) + }) + }) + .transpose()?, + ), + cpu_cores_physical: Set( + data.get("cpu_cores_physical") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid cpu_cores_physical: {}", e)) + }) + }) + .transpose()?, + ), + cpu_cores_logical: Set( + data.get("cpu_cores_logical") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid cpu_cores_logical: {}", e)) + }) + }) + .transpose()?, + ), + cpu_frequency_mhz: Set( + data.get("cpu_frequency_mhz") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid cpu_frequency_mhz: {}", e)) + }) + }) + .transpose()?, + ), + memory_total_bytes: Set( + data.get("memory_total_bytes") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid memory_total_bytes: {}", e)) + }) + }) + .transpose()?, + ), + form_factor: Set( + data.get("form_factor") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid form_factor: {}", e)) + }) + }) + .transpose()?, + ), + manufacturer: Set( + data.get("manufacturer") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid manufacturer: {}", e)) + }) + }) + .transpose()?, + ), + gpu_models: Set( + data.get("gpu_models") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid gpu_models: {}", e)) + }) + }) + .transpose()?, + ), + boot_disk_type: Set( + data.get("boot_disk_type") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid boot_disk_type: {}", e)) + }) + }) + .transpose()?, + ), + boot_disk_capacity_bytes: Set( + data.get("boot_disk_capacity_bytes") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid boot_disk_capacity_bytes: {}", e)) + }) + }) + .transpose()?, + ), + swap_total_bytes: Set( + data.get("swap_total_bytes") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()).map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid swap_total_bytes: {}", e)) + }) + }) + .transpose()?, + ), + network_addresses: Set( + serde_json::from_value( + data.get("network_addresses") + .cloned() + .unwrap_or(serde_json::json!([])), + ) + .map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid network_addresses: {}", e)) + })?, + ), + is_online: Set(serde_json::from_value( + data.get("is_online") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + ) + .unwrap_or(false)), + last_seen_at: Set(serde_json::from_value( + data.get("last_seen_at") + .cloned() + .unwrap_or_else(|| serde_json::json!(chrono::Utc::now())), + ) + .unwrap_or_else(|_| chrono::Utc::now().into())), + capabilities: Set( + serde_json::from_value( + data.get("capabilities") + .cloned() + .unwrap_or(serde_json::json!({})), + ) + .map_err(|e| { + sea_orm::DbErr::Custom(format!("Invalid capabilities: {}", e)) + })?, + ), + created_at: Set(chrono::Utc::now().into()), + updated_at: Set(chrono::Utc::now().into()), + sync_enabled: Set(serde_json::from_value( + data.get("sync_enabled") + .cloned() + .unwrap_or(serde_json::Value::Bool(true)), + ) + .unwrap_or(true)), + }; + + // Idempotent upsert: insert or update based on UUID + Entity::insert(active) + .on_conflict( + sea_orm::sea_query::OnConflict::column(Column::Uuid) + .update_columns([ + Column::Name, + Column::Slug, // Now updated on conflict to allow slug changes + Column::Os, + Column::OsVersion, + Column::HardwareModel, + Column::CpuModel, + Column::CpuArchitecture, + Column::CpuCoresPhysical, + Column::CpuCoresLogical, + Column::CpuFrequencyMhz, + Column::MemoryTotalBytes, + Column::FormFactor, + Column::Manufacturer, + Column::GpuModels, + Column::BootDiskType, + Column::BootDiskCapacityBytes, + Column::SwapTotalBytes, + Column::NetworkAddresses, + Column::IsOnline, + Column::LastSeenAt, + Column::Capabilities, + Column::UpdatedAt, + Column::SyncEnabled, + ]) + .to_owned(), + ) + .exec(db) + .await?; } - unique_slug - }; - - // Build ActiveModel for upsert - let active = ActiveModel { - id: NotSet, - uuid: Set(device.uuid), - name: Set(device.name), - slug: Set(slug_to_use), - os: Set(device.os), - os_version: Set(device.os_version), - hardware_model: Set(device.hardware_model), - cpu_model: Set(device.cpu_model), - cpu_architecture: Set(device.cpu_architecture), - cpu_cores_physical: Set(device.cpu_cores_physical), - cpu_cores_logical: Set(device.cpu_cores_logical), - cpu_frequency_mhz: Set(device.cpu_frequency_mhz), - memory_total_bytes: Set(device.memory_total_bytes), - form_factor: Set(device.form_factor), - manufacturer: Set(device.manufacturer), - gpu_models: Set(device.gpu_models), - boot_disk_type: Set(device.boot_disk_type), - boot_disk_capacity_bytes: Set(device.boot_disk_capacity_bytes), - swap_total_bytes: Set(device.swap_total_bytes), - network_addresses: Set(device.network_addresses), - is_online: Set(device.is_online), - last_seen_at: Set(device.last_seen_at), - capabilities: Set(device.capabilities), - created_at: Set(chrono::Utc::now().into()), - updated_at: Set(chrono::Utc::now().into()), - sync_enabled: Set(true), - last_sync_at: Set(None), - }; - - // Idempotent upsert by UUID - Entity::insert(active) - .on_conflict( - sea_orm::sea_query::OnConflict::column(Column::Uuid) - .update_columns([ - Column::Name, - // Note: slug is NOT updated on conflict to preserve local slug overrides - Column::Os, - Column::OsVersion, - Column::HardwareModel, - Column::CpuModel, - Column::CpuArchitecture, - Column::CpuCoresPhysical, - Column::CpuCoresLogical, - Column::CpuFrequencyMhz, - Column::MemoryTotalBytes, - Column::FormFactor, - Column::Manufacturer, - Column::GpuModels, - Column::BootDiskType, - Column::BootDiskCapacityBytes, - Column::SwapTotalBytes, - Column::NetworkAddresses, - Column::IsOnline, - Column::LastSeenAt, - Column::Capabilities, - Column::UpdatedAt, - ]) - .to_owned(), - ) - .exec(db) - .await?; + ChangeType::Delete => { + // Delete by UUID + tracing::debug!("[DEVICE_SYNC] Deleting device: uuid={}", entry.record_uuid); + Entity::delete_many() + .filter(Column::Uuid.eq(entry.record_uuid)) + .exec(db) + .await?; + } + } Ok(()) } } -// Register with sync system via inventory -crate::register_syncable_device_owned!(Model, "device", "devices"); +// Register with sync system via inventory as shared resource +crate::register_syncable_shared!(Model, "device", "devices"); diff --git a/core/src/infra/db/migration/m20260123_000001_remove_legacy_sync_columns.rs b/core/src/infra/db/migration/m20260123_000001_remove_legacy_sync_columns.rs new file mode 100644 index 000000000..3c0691c0a --- /dev/null +++ b/core/src/infra/db/migration/m20260123_000001_remove_legacy_sync_columns.rs @@ -0,0 +1,60 @@ +//! Remove legacy sync columns from devices table +//! +//! The columns last_sync_at, last_state_watermark, and last_shared_watermark +//! were added in m20251009_000001 but are now superseded by per-resource +//! watermark tracking in sync.db (device_resource_watermarks table). +//! +//! These columns were either never used (last_state_watermark, last_shared_watermark) +//! or used incorrectly as global sync timestamps instead of per-peer tracking (last_sync_at). +//! +//! See docs/core/LEGACY_SYNC_COLUMNS_MIGRATION.md for full context. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // SQLite 3.35.0+ supports ALTER TABLE DROP COLUMN directly. + // This avoids the table recreation pattern which has FK constraint issues. + let db = manager.get_connection(); + + // Drop last_sync_at column + db.execute_unprepared("ALTER TABLE devices DROP COLUMN last_sync_at") + .await?; + + // Drop last_state_watermark column (was never used) + db.execute_unprepared("ALTER TABLE devices DROP COLUMN last_state_watermark") + .await?; + + // Drop last_shared_watermark column (was never used) + db.execute_unprepared("ALTER TABLE devices DROP COLUMN last_shared_watermark") + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Restore columns for rollback + let db = manager.get_connection(); + + db.execute_unprepared( + "ALTER TABLE devices ADD COLUMN last_sync_at TEXT DEFAULT NULL", + ) + .await?; + + db.execute_unprepared( + "ALTER TABLE devices ADD COLUMN last_state_watermark TEXT DEFAULT NULL", + ) + .await?; + + db.execute_unprepared( + "ALTER TABLE devices ADD COLUMN last_shared_watermark TEXT DEFAULT NULL", + ) + .await?; + + Ok(()) + } +} diff --git a/core/src/infra/db/migration/mod.rs b/core/src/infra/db/migration/mod.rs index 29cdaf83a..1a301e8d9 100644 --- a/core/src/infra/db/migration/mod.rs +++ b/core/src/infra/db/migration/mod.rs @@ -37,6 +37,7 @@ mod m20251226_000001_add_device_id_to_entries; mod m20260104_000001_replace_device_id_with_volume_id; mod m20260105_000001_add_volume_id_to_locations; mod m20260114_000001_fix_search_index_include_directories; +mod m20260123_000001_remove_legacy_sync_columns; pub struct Migrator; @@ -79,6 +80,7 @@ impl MigratorTrait for Migrator { Box::new(m20260104_000001_replace_device_id_with_volume_id::Migration), Box::new(m20260105_000001_add_volume_id_to_locations::Migration), Box::new(m20260114_000001_fix_search_index_include_directories::Migration), + Box::new(m20260123_000001_remove_legacy_sync_columns::Migration), ] } } diff --git a/core/src/infra/event/mod.rs b/core/src/infra/event/mod.rs index 37de5ca63..fc45b69de 100644 --- a/core/src/infra/event/mod.rs +++ b/core/src/infra/event/mod.rs @@ -132,6 +132,20 @@ pub enum Event { /// Emitted after major data recalculations (e.g., volume unique_bytes refresh) Refresh, + // Pairing events + ProxyPairingConfirmationRequired { + session_id: Uuid, + vouchee_device_name: String, + vouchee_device_os: String, + voucher_device_name: String, + voucher_device_id: Uuid, + expires_at: String, + }, + ProxyPairingVouchingReady { + session_id: Uuid, + vouchee_device_id: Uuid, + }, + // Entry events (file/directory operations) // DEPRECATED: Use ResourceChanged instead EntryCreated { diff --git a/core/src/lib.rs b/core/src/lib.rs index a99969a51..fed5f4071 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -80,7 +80,7 @@ pub struct Core { impl Core { /// Initialize a new Core instance with custom data directory - pub async fn new(data_dir: PathBuf) -> Result> { + pub async fn new(data_dir: PathBuf) -> Result> { Self::new_with_config(data_dir, None, None).await } @@ -90,7 +90,7 @@ impl Core { data_dir: PathBuf, config: Option, system_device_name: Option, - ) -> Result> { + ) -> Result> { info!("Initializing Spacedrive at {:?}", data_dir); // Load or create app config @@ -330,7 +330,9 @@ impl Core { // Set event bus for device registry to emit ResourceChanged events networking.set_event_bus(context.events.clone()).await; // Set library manager for device registry to query complete device data - networking.set_library_manager(Arc::downgrade(&context.libraries().await)).await; + networking + .set_library_manager(Arc::downgrade(&context.libraries().await)) + .await; info!("Networking service registered in context"); // Initialize sync service on already-loaded libraries @@ -479,7 +481,7 @@ impl Core { } /// Initialize networking using master key - pub async fn init_networking(&mut self) -> Result<(), Box> { + pub async fn init_networking(&mut self) -> Result<(), Box> { self.init_networking_with_logger(Arc::new(service::network::SilentLogger)) .await } @@ -488,7 +490,7 @@ impl Core { pub async fn init_networking_with_logger( &mut self, logger: Arc, - ) -> Result<(), Box> { + ) -> Result<(), Box> { logger.info("Initializing networking...").await; // Check if networking is already initialized @@ -517,16 +519,27 @@ impl Core { if let Some(networking_service) = self.services.networking() { // Register default protocol handlers only if networking was just initialized // (if networking was already initialized during Core::new(), protocols are already registered) - if !already_initialized { - logger.info("Registering protocol handlers...").await; - self.register_default_protocols(&networking_service).await?; - } else { - logger - .info("Protocol handlers already registered during initialization") - .await; - } + if !already_initialized { + logger.info("Registering protocol handlers...").await; + self.register_default_protocols(&networking_service).await?; + } else { + logger + .info("Protocol handlers already registered during initialization") + .await; - // Set up event bridge to integrate with core event system (only if not already done) + // Reload protocol configs even when networking is already initialized + // This allows tests and runtime config changes to take effect + logger.info("Reloading protocol configs from disk...").await; + if let Err(e) = reload_protocol_configs(&networking_service, &self.config.read().await.data_dir).await { + logger + .warn(&format!("Failed to reload some protocol configs: {}", e)) + .await; + } else { + logger.info("Protocol configs reloaded successfully").await; + } + } + + // Set up event bridge to integrate with core event system (only if not already done) if !already_initialized { let event_bridge = NetworkEventBridge::new( networking_service.subscribe_events(), @@ -543,7 +556,9 @@ impl Core { // Set event bus for device registry to emit ResourceChanged events networking_service.set_event_bus(self.events.clone()).await; // Set library manager for device registry to query complete device data - networking_service.set_library_manager(Arc::downgrade(&self.context.libraries().await)).await; + networking_service + .set_library_manager(Arc::downgrade(&self.context.libraries().await)) + .await; } logger.info("Networking initialized successfully").await; @@ -554,7 +569,7 @@ impl Core { async fn register_default_protocols( &self, networking: &service::network::NetworkingService, - ) -> Result<(), Box> { + ) -> Result<(), Box> { let data_dir = self.config.read().await.data_dir.clone(); register_default_protocol_handlers(networking, data_dir, self.context.clone()).await } @@ -573,7 +588,7 @@ impl Core { } /// Shutdown the core gracefully - pub async fn shutdown(&self) -> Result<(), Box> { + pub async fn shutdown(&self) -> Result<(), Box> { info!("Shutting down Spacedrive Core..."); // Networking service is stopped by services.stop_all() @@ -609,7 +624,7 @@ async fn register_default_protocol_handlers( networking: &service::network::NetworkingService, data_dir: PathBuf, context: Arc, -) -> Result<(), Box> { +) -> Result<(), Box> { let logger = std::sync::Arc::new(service::network::utils::logging::ConsoleLogger); // Get command sender for the pairing handler's state machine @@ -630,6 +645,23 @@ async fn register_default_protocol_handlers( ), ); + // Inject event bus for proxy pairing events + pairing_handler.set_event_bus(context.events.clone()).await; + + // Load proxy pairing config from app config + if let Ok(app_config) = crate::config::AppConfig::load_from(&context.data_dir) { + pairing_handler + .set_proxy_config(app_config.proxy_pairing) + .await; + } + + // Initialize vouching queue for proxy pairing + if let Err(e) = pairing_handler.init_vouching_queue(data_dir.clone()).await { + logger + .warn(&format!("Failed to initialize vouching queue: {}", e)) + .await; + } + // Try to load persisted sessions, but don't fail if there's an error if let Err(e) = pairing_handler.load_persisted_sessions().await { logger @@ -648,6 +680,11 @@ async fn register_default_protocol_handlers( // Start cleanup task for expired sessions service::network::protocol::PairingProtocolHandler::start_cleanup_task(pairing_handler.clone()); + // Start vouching queue processor for proxy pairing + service::network::protocol::PairingProtocolHandler::start_vouching_queue_task( + pairing_handler.clone(), + ); + let mut messaging_handler = service::network::protocol::MessagingProtocolHandler::new( networking.device_registry(), networking.endpoint().cloned(), @@ -708,6 +745,39 @@ async fn register_default_protocol_handlers( Ok(()) } +/// Reload configuration for all protocol handlers +/// This is called when networking is already initialized but config has changed +async fn reload_protocol_configs( + networking: &service::network::NetworkingService, + data_dir: &std::path::Path, +) -> Result<(), Box> { + let app_config = crate::config::AppConfig::load_from(&data_dir.to_path_buf())?; + let registry = networking.protocol_registry(); + let guard = registry.read().await; + + // Reload proxy pairing config + if let Some(handler) = guard.get_handler("pairing") { + if let Some(pairing_handler) = handler + .as_any() + .downcast_ref::() + { + pairing_handler + .set_proxy_config(app_config.proxy_pairing) + .await; + } + } + + // Future: Add config reloading for other protocol handlers here + // Example: + // if let Some(handler) = guard.get_handler("file_transfer") { + // if let Some(ft_handler) = handler.as_any().downcast_ref::() { + // ft_handler.set_config(app_config.file_transfer).await; + // } + // } + + Ok(()) +} + /// Set up log event emitter to forward tracing events to the event bus fn setup_log_event_emitter(event_bus: Arc) { use crate::infra::event::log_emitter::LogEventLayer; diff --git a/core/src/library/manager.rs b/core/src/library/manager.rs index b5d159c61..dc699a7dd 100644 --- a/core/src/library/manager.rs +++ b/core/src/library/manager.rs @@ -230,6 +230,21 @@ impl LibraryManager { initial_device_id: Uuid, initial_device_name: String, initial_device_slug: String, + initial_device_os: String, + initial_device_os_version: Option, + initial_device_hardware_model: Option, + initial_device_cpu_model: Option, + initial_device_cpu_architecture: Option, + initial_device_cpu_cores_physical: Option, + initial_device_cpu_cores_logical: Option, + initial_device_cpu_frequency_mhz: Option, + initial_device_memory_total_bytes: Option, + initial_device_form_factor: Option, + initial_device_manufacturer: Option, + initial_device_gpu_models: Option>, + initial_device_boot_disk_type: Option, + initial_device_boot_disk_capacity_bytes: Option, + initial_device_swap_total_bytes: Option, context: Arc, ) -> Result> { let name = name.into(); @@ -293,22 +308,21 @@ impl LibraryManager { uuid: Set(initial_device_id), name: Set(initial_device_name), slug: Set(initial_device_slug), - os: Set("Desktop".to_string()), - os_version: Set(None), - hardware_model: Set(None), - // Hardware specs - not available for pre-registered devices - cpu_model: Set(None), - cpu_architecture: Set(None), - cpu_cores_physical: Set(None), - cpu_cores_logical: Set(None), - cpu_frequency_mhz: Set(None), - memory_total_bytes: Set(None), - form_factor: Set(None), - manufacturer: Set(None), - gpu_models: Set(None), - boot_disk_type: Set(None), - boot_disk_capacity_bytes: Set(None), - swap_total_bytes: Set(None), + os: Set(initial_device_os), + os_version: Set(initial_device_os_version), + hardware_model: Set(initial_device_hardware_model), + cpu_model: Set(initial_device_cpu_model), + cpu_architecture: Set(initial_device_cpu_architecture), + cpu_cores_physical: Set(initial_device_cpu_cores_physical), + cpu_cores_logical: Set(initial_device_cpu_cores_logical), + cpu_frequency_mhz: Set(initial_device_cpu_frequency_mhz), + memory_total_bytes: Set(initial_device_memory_total_bytes), + form_factor: Set(initial_device_form_factor), + manufacturer: Set(initial_device_manufacturer), + gpu_models: Set(initial_device_gpu_models.map(|g| serde_json::json!(g))), + boot_disk_type: Set(initial_device_boot_disk_type), + boot_disk_capacity_bytes: Set(initial_device_boot_disk_capacity_bytes), + swap_total_bytes: Set(initial_device_swap_total_bytes), network_addresses: Set(serde_json::json!([])), is_online: Set(false), last_seen_at: Set(Utc::now()), @@ -320,7 +334,6 @@ impl LibraryManager { created_at: Set(Utc::now()), updated_at: Set(Utc::now()), sync_enabled: Set(true), - last_sync_at: Set(None), }; initial_device_model @@ -1018,13 +1031,25 @@ impl LibraryManager { use sea_orm::ActiveValue::Set; if let Some(existing_device) = existing { - // Update existing device to pick up any changes (e.g., renamed device) + // Update existing device to pick up any changes (e.g., renamed device, hardware upgrades) let mut device_model: entities::device::ActiveModel = existing_device.into(); - // Update fields that may have changed + // Update all fields including hardware specs (self-healing for NULL stubs) device_model.name = Set(device.name.clone()); device_model.os_version = Set(device.os_version); device_model.hardware_model = Set(device.hardware_model); + device_model.cpu_model = Set(device.cpu_model); + device_model.cpu_architecture = Set(device.cpu_architecture); + device_model.cpu_cores_physical = Set(device.cpu_cores_physical); + device_model.cpu_cores_logical = Set(device.cpu_cores_logical); + device_model.cpu_frequency_mhz = Set(device.cpu_frequency_mhz); + device_model.memory_total_bytes = Set(device.memory_total_bytes); + device_model.form_factor = Set(device.form_factor.map(|f| f.to_string())); + device_model.manufacturer = Set(device.manufacturer); + device_model.gpu_models = Set(device.gpu_models.map(|g| serde_json::json!(g))); + device_model.boot_disk_type = Set(device.boot_disk_type); + device_model.boot_disk_capacity_bytes = Set(device.boot_disk_capacity_bytes); + device_model.swap_total_bytes = Set(device.swap_total_bytes); device_model.is_online = Set(true); device_model.last_seen_at = Set(Utc::now()); device_model.updated_at = Set(Utc::now()); @@ -1121,7 +1146,6 @@ impl LibraryManager { })), created_at: Set(device.created_at), sync_enabled: Set(true), // Enable sync by default for this device - last_sync_at: Set(None), updated_at: Set(Utc::now()), }; diff --git a/core/src/library/mod.rs b/core/src/library/mod.rs index d57dd4907..1221f8050 100644 --- a/core/src/library/mod.rs +++ b/core/src/library/mod.rs @@ -192,6 +192,43 @@ impl Library { .map_err(|e| LibraryError::Other(format!("Failed to start sync service: {}", e)))?; } + // Ensure current device is synced as shared resource (one-time migration) + // This handles the transition from device-owned to shared sync for existing devices + self.ensure_device_synced_as_shared(device_id).await?; + + Ok(()) + } + + /// Ensure the current device is synced as a shared resource + /// This is called once during sync initialization to handle migration from device-owned to shared sync + async fn ensure_device_synced_as_shared(&self, device_id: Uuid) -> Result<()> { + use crate::infra::db::entities; + use crate::infra::sync::ChangeType; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + // Find the current device in the database + let device = entities::device::Entity::find() + .filter(entities::device::Column::Uuid.eq(device_id)) + .one(self.db().conn()) + .await + .map_err(|e| LibraryError::Other(format!("Failed to query device: {}", e)))?; + + if let Some(device_model) = device { + // Sync the device record as a shared resource + // This ensures it will propagate to all other devices in the library + self.sync_model(&device_model, ChangeType::Insert) + .await + .map_err(|e| { + LibraryError::Other(format!("Failed to sync device as shared resource: {}", e)) + })?; + + info!( + "Synced device {} as shared resource for library {}", + device_id, + self.id() + ); + } + Ok(()) } diff --git a/core/src/ops/config/app/get.rs b/core/src/ops/config/app/get.rs index 1ebd645f5..fee4b0d75 100644 --- a/core/src/ops/config/app/get.rs +++ b/core/src/ops/config/app/get.rs @@ -1,13 +1,15 @@ //! Get app configuration query +use std::{path::PathBuf, sync::Arc}; + +use serde::{Deserialize, Serialize}; +use specta::Type; + use crate::{ config::{AppConfig, JobLoggingConfig, LoggingConfig, Preferences, ServiceConfig}, context::CoreContext, infra::query::{CoreQuery, QueryError, QueryResult}, }; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::{path::PathBuf, sync::Arc}; /// Input for getting app configuration #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -39,6 +41,9 @@ pub struct AppConfigOutput { /// Daemon logging configuration pub logging: LoggingConfigOutput, + + /// Proxy pairing configuration + pub proxy_pairing: ProxyPairingConfigOutput, } /// User preferences output @@ -73,6 +78,16 @@ pub struct LoggingConfigOutput { pub main_filter: String, } +/// Proxy pairing configuration output +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct ProxyPairingConfigOutput { + pub auto_accept_vouched: bool, + pub auto_vouch_to_all: bool, + pub vouch_signature_max_age: u64, + pub vouch_response_timeout: u64, + pub vouch_queue_retry_limit: u32, +} + impl From<&AppConfig> for AppConfigOutput { fn from(config: &AppConfig) -> Self { Self { @@ -100,6 +115,13 @@ impl From<&AppConfig> for AppConfigOutput { logging: LoggingConfigOutput { main_filter: config.logging.main_filter.clone(), }, + proxy_pairing: ProxyPairingConfigOutput { + auto_accept_vouched: config.proxy_pairing.auto_accept_vouched, + auto_vouch_to_all: config.proxy_pairing.auto_vouch_to_all, + vouch_signature_max_age: config.proxy_pairing.vouch_signature_max_age, + vouch_response_timeout: config.proxy_pairing.vouch_response_timeout, + vouch_queue_retry_limit: config.proxy_pairing.vouch_queue_retry_limit, + }, } } } diff --git a/core/src/ops/config/app/update.rs b/core/src/ops/config/app/update.rs index 09a31a01c..d0665478f 100644 --- a/core/src/ops/config/app/update.rs +++ b/core/src/ops/config/app/update.rs @@ -1,14 +1,16 @@ //! Update app configuration action +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use tracing::info; + use crate::{ config::AppConfig, context::CoreContext, infra::action::{error::ActionError, CoreAction, ValidationResult}, }; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::sync::Arc; -use tracing::info; /// Input for updating app configuration /// All fields are optional for partial updates @@ -53,6 +55,26 @@ pub struct UpdateAppConfigInput { /// Whether to include debug logs in job logs #[serde(skip_serializing_if = "Option::is_none")] pub job_logging_include_debug: Option, + + /// Automatically accept vouches from trusted devices + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_pairing_auto_accept_vouched: Option, + + /// Automatically vouch new devices to all paired devices + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_pairing_auto_vouch_to_all: Option, + + /// Maximum age of vouch signatures in seconds + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_pairing_vouch_signature_max_age: Option, + + /// Timeout for proxy confirmation in seconds + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_pairing_vouch_response_timeout: Option, + + /// Maximum retries for queued vouches + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_pairing_vouch_queue_retry_limit: Option, } /// Output for update app configuration action @@ -81,10 +103,7 @@ impl CoreAction for UpdateAppConfigAction { Ok(Self { input }) } - async fn validate( - &self, - _context: Arc, - ) -> Result { + async fn validate(&self, _context: Arc) -> Result { // Validate log level if let Some(ref level) = self.input.log_level { let valid_levels = ["trace", "debug", "info", "warn", "error"]; @@ -102,7 +121,9 @@ impl CoreAction for UpdateAppConfigAction { // Validate theme if let Some(ref theme) = self.input.theme { - let valid_themes = ["system", "light", "dark", "midnight", "noir", "slate", "nord", "mocha"]; + let valid_themes = [ + "system", "light", "dark", "midnight", "noir", "slate", "nord", "mocha", + ]; if !valid_themes.contains(&theme.to_lowercase().as_str()) { return Err(ActionError::Validation { field: "theme".to_string(), @@ -120,7 +141,35 @@ impl CoreAction for UpdateAppConfigAction { if lang.len() != 2 || !lang.chars().all(|c| c.is_ascii_lowercase()) { return Err(ActionError::Validation { field: "language".to_string(), - message: "Language must be a 2-letter ISO 639-1 code (e.g., 'en', 'de')".to_string(), + message: "Language must be a 2-letter ISO 639-1 code (e.g., 'en', 'de')" + .to_string(), + }); + } + } + + if let Some(max_age) = self.input.proxy_pairing_vouch_signature_max_age { + if max_age == 0 { + return Err(ActionError::Validation { + field: "proxy_pairing_vouch_signature_max_age".to_string(), + message: "Signature max age must be greater than 0".to_string(), + }); + } + } + + if let Some(timeout) = self.input.proxy_pairing_vouch_response_timeout { + if timeout == 0 { + return Err(ActionError::Validation { + field: "proxy_pairing_vouch_response_timeout".to_string(), + message: "Response timeout must be greater than 0".to_string(), + }); + } + } + + if let Some(retry_limit) = self.input.proxy_pairing_vouch_queue_retry_limit { + if retry_limit == 0 { + return Err(ActionError::Validation { + field: "proxy_pairing_vouch_queue_retry_limit".to_string(), + message: "Retry limit must be greater than 0".to_string(), }); } } @@ -211,6 +260,41 @@ impl CoreAction for UpdateAppConfigAction { } } + if let Some(auto_accept_vouched) = self.input.proxy_pairing_auto_accept_vouched { + if config.proxy_pairing.auto_accept_vouched != auto_accept_vouched { + config.proxy_pairing.auto_accept_vouched = auto_accept_vouched; + changes.push("proxy_pairing_auto_accept_vouched"); + } + } + + if let Some(auto_vouch_to_all) = self.input.proxy_pairing_auto_vouch_to_all { + if config.proxy_pairing.auto_vouch_to_all != auto_vouch_to_all { + config.proxy_pairing.auto_vouch_to_all = auto_vouch_to_all; + changes.push("proxy_pairing_auto_vouch_to_all"); + } + } + + if let Some(max_age) = self.input.proxy_pairing_vouch_signature_max_age { + if config.proxy_pairing.vouch_signature_max_age != max_age { + config.proxy_pairing.vouch_signature_max_age = max_age; + changes.push("proxy_pairing_vouch_signature_max_age"); + } + } + + if let Some(timeout) = self.input.proxy_pairing_vouch_response_timeout { + if config.proxy_pairing.vouch_response_timeout != timeout { + config.proxy_pairing.vouch_response_timeout = timeout; + changes.push("proxy_pairing_vouch_response_timeout"); + } + } + + if let Some(retry_limit) = self.input.proxy_pairing_vouch_queue_retry_limit { + if config.proxy_pairing.vouch_queue_retry_limit != retry_limit { + config.proxy_pairing.vouch_queue_retry_limit = retry_limit; + changes.push("proxy_pairing_vouch_queue_retry_limit"); + } + } + if changes.is_empty() { return Ok(UpdateAppConfigOutput { success: true, @@ -223,11 +307,26 @@ impl CoreAction for UpdateAppConfigAction { .save() .map_err(|e| ActionError::Internal(format!("Failed to save config: {}", e)))?; + if let Some(networking) = context.get_networking().await { + let registry = networking.protocol_registry(); + let guard = registry.read().await; + if let Some(handler) = guard.get_handler("pairing") { + if let Some(pairing) = handler + .as_any() + .downcast_ref::( + ) { + pairing.set_proxy_config(config.proxy_pairing.clone()).await; + } + } + } + // Emit config change events for each changed field for field in &changes { - context.events.emit(crate::infra::event::Event::ConfigChanged { - field: field.to_string(), - }); + context + .events + .emit(crate::infra::event::Event::ConfigChanged { + field: field.to_string(), + }); } info!( diff --git a/core/src/ops/network/pair/confirm_proxy/action.rs b/core/src/ops/network/pair/confirm_proxy/action.rs new file mode 100644 index 000000000..843098409 --- /dev/null +++ b/core/src/ops/network/pair/confirm_proxy/action.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; + +use super::{input::PairConfirmProxyInput, output::PairConfirmProxyOutput}; +use crate::infra::action::{error::ActionError, CoreAction}; + +pub struct PairConfirmProxyAction { + pub session_id: uuid::Uuid, + pub accepted: bool, +} + +impl CoreAction for PairConfirmProxyAction { + type Output = PairConfirmProxyOutput; + type Input = PairConfirmProxyInput; + + fn from_input(input: Self::Input) -> std::result::Result { + Ok(Self { + session_id: input.session_id, + accepted: input.accepted, + }) + } + + async fn execute( + self, + context: Arc, + ) -> std::result::Result { + let net = context + .get_networking() + .await + .ok_or_else(|| ActionError::Internal("Networking not initialized".to_string()))?; + + let registry = net.protocol_registry(); + let guard = registry.read().await; + if let Some(handler) = guard.get_handler("pairing") { + if let Some(pairing) = handler + .as_any() + .downcast_ref::( + ) { + let result = pairing + .confirm_proxy_pairing(self.session_id, self.accepted) + .await; + + match result { + Ok(_) => { + return Ok(PairConfirmProxyOutput { + success: true, + error: None, + }); + } + Err(e) => { + return Ok(PairConfirmProxyOutput { + success: false, + error: Some(e.to_string()), + }); + } + } + } + } + + Ok(PairConfirmProxyOutput { + success: false, + error: Some("Pairing handler not available".to_string()), + }) + } + + fn action_kind(&self) -> &'static str { + "network.pair.confirmProxy" + } +} + +crate::register_core_action!(PairConfirmProxyAction, "network.pair.confirmProxy"); diff --git a/core/src/ops/network/pair/confirm_proxy/input.rs b/core/src/ops/network/pair/confirm_proxy/input.rs new file mode 100644 index 000000000..b1d52c5fa --- /dev/null +++ b/core/src/ops/network/pair/confirm_proxy/input.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct PairConfirmProxyInput { + pub session_id: Uuid, + pub accepted: bool, +} diff --git a/core/src/ops/network/pair/confirm_proxy/mod.rs b/core/src/ops/network/pair/confirm_proxy/mod.rs new file mode 100644 index 000000000..f499d439e --- /dev/null +++ b/core/src/ops/network/pair/confirm_proxy/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::PairConfirmProxyAction; +pub use input::PairConfirmProxyInput; +pub use output::PairConfirmProxyOutput; diff --git a/core/src/ops/network/pair/confirm_proxy/output.rs b/core/src/ops/network/pair/confirm_proxy/output.rs new file mode 100644 index 000000000..578b5e606 --- /dev/null +++ b/core/src/ops/network/pair/confirm_proxy/output.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct PairConfirmProxyOutput { + pub success: bool, + pub error: Option, +} diff --git a/core/src/ops/network/pair/mod.rs b/core/src/ops/network/pair/mod.rs index cf28a375b..838825386 100644 --- a/core/src/ops/network/pair/mod.rs +++ b/core/src/ops/network/pair/mod.rs @@ -1,9 +1,15 @@ pub mod cancel; +pub mod confirm_proxy; pub mod generate; pub mod join; pub mod status; +pub mod vouch; +pub mod vouching_session; pub use cancel::*; +pub use confirm_proxy::*; pub use generate::*; pub use join::*; pub use status::*; +pub use vouch::*; +pub use vouching_session::*; diff --git a/core/src/ops/network/pair/vouch/action.rs b/core/src/ops/network/pair/vouch/action.rs new file mode 100644 index 000000000..ebe912e3a --- /dev/null +++ b/core/src/ops/network/pair/vouch/action.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use super::{input::PairVouchInput, output::PairVouchOutput}; +use crate::infra::action::{error::ActionError, CoreAction}; + +pub struct PairVouchAction { + pub session_id: uuid::Uuid, + pub target_device_ids: Vec, +} + +impl CoreAction for PairVouchAction { + type Output = PairVouchOutput; + type Input = PairVouchInput; + + fn from_input(input: Self::Input) -> std::result::Result { + Ok(Self { + session_id: input.session_id, + target_device_ids: input.target_device_ids, + }) + } + + async fn execute( + self, + context: Arc, + ) -> std::result::Result { + let net = context + .get_networking() + .await + .ok_or_else(|| ActionError::Internal("Networking not initialized".to_string()))?; + + let registry = net.protocol_registry(); + let guard = registry.read().await; + if let Some(handler) = guard.get_handler("pairing") { + if let Some(pairing) = handler + .as_any() + .downcast_ref::( + ) { + let session = pairing + .start_proxy_vouching(self.session_id, self.target_device_ids) + .await + .map_err(|e| ActionError::Internal(e.to_string()))?; + + let pending_count = session + .vouches + .iter() + .filter(|v| { + matches!( + v.status, + crate::service::network::protocol::pairing::VouchStatus::Queued + | crate::service::network::protocol::pairing::VouchStatus::Waiting + | crate::service::network::protocol::pairing::VouchStatus::Selected + ) + }) + .count() as u32; + + return Ok(PairVouchOutput { + success: true, + pending_count, + }); + } + } + + Err(ActionError::Internal( + "Pairing handler not available".to_string(), + )) + } + + fn action_kind(&self) -> &'static str { + "network.pair.vouch" + } +} + +crate::register_core_action!(PairVouchAction, "network.pair.vouch"); diff --git a/core/src/ops/network/pair/vouch/input.rs b/core/src/ops/network/pair/vouch/input.rs new file mode 100644 index 000000000..c04694614 --- /dev/null +++ b/core/src/ops/network/pair/vouch/input.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct PairVouchInput { + pub session_id: Uuid, + pub target_device_ids: Vec, +} diff --git a/core/src/ops/network/pair/vouch/mod.rs b/core/src/ops/network/pair/vouch/mod.rs new file mode 100644 index 000000000..e7200d4e7 --- /dev/null +++ b/core/src/ops/network/pair/vouch/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::PairVouchAction; +pub use input::PairVouchInput; +pub use output::PairVouchOutput; diff --git a/core/src/ops/network/pair/vouch/output.rs b/core/src/ops/network/pair/vouch/output.rs new file mode 100644 index 000000000..ddca2a11a --- /dev/null +++ b/core/src/ops/network/pair/vouch/output.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct PairVouchOutput { + pub success: bool, + pub pending_count: u32, +} diff --git a/core/src/ops/network/pair/vouching_session/input.rs b/core/src/ops/network/pair/vouching_session/input.rs new file mode 100644 index 000000000..977db198b --- /dev/null +++ b/core/src/ops/network/pair/vouching_session/input.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VouchingSessionInput { + pub session_id: Uuid, +} diff --git a/core/src/ops/network/pair/vouching_session/mod.rs b/core/src/ops/network/pair/vouching_session/mod.rs new file mode 100644 index 000000000..ee4a9ae68 --- /dev/null +++ b/core/src/ops/network/pair/vouching_session/mod.rs @@ -0,0 +1,7 @@ +pub mod input; +pub mod output; +pub mod query; + +pub use input::VouchingSessionInput; +pub use output::VouchingSessionOutput; +pub use query::VouchingSessionQuery; diff --git a/core/src/ops/network/pair/vouching_session/output.rs b/core/src/ops/network/pair/vouching_session/output.rs new file mode 100644 index 000000000..699aed3ca --- /dev/null +++ b/core/src/ops/network/pair/vouching_session/output.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::service::network::protocol::pairing::VouchingSession; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VouchingSessionOutput { + pub session: Option, +} diff --git a/core/src/ops/network/pair/vouching_session/query.rs b/core/src/ops/network/pair/vouching_session/query.rs new file mode 100644 index 000000000..13fc71bb8 --- /dev/null +++ b/core/src/ops/network/pair/vouching_session/query.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use specta::Type; + +use super::{input::VouchingSessionInput, output::VouchingSessionOutput}; +use crate::infra::query::{CoreQuery, QueryError, QueryResult}; +use crate::{context::CoreContext, service::network::protocol::PairingProtocolHandler}; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VouchingSessionQuery { + session_id: uuid::Uuid, +} + +impl CoreQuery for VouchingSessionQuery { + type Input = VouchingSessionInput; + type Output = VouchingSessionOutput; + + fn from_input(input: Self::Input) -> QueryResult { + Ok(Self { + session_id: input.session_id, + }) + } + + async fn execute( + self, + context: Arc, + _session: crate::infra::api::SessionContext, + ) -> QueryResult { + let net = context + .get_networking() + .await + .ok_or_else(|| QueryError::Internal("Networking not initialized".to_string()))?; + + let registry = net.protocol_registry(); + let guard = registry.read().await; + if let Some(handler) = guard.get_handler("pairing") { + if let Some(pairing) = handler.as_any().downcast_ref::() { + let session = pairing.get_vouching_session(self.session_id).await; + return Ok(VouchingSessionOutput { session }); + } + } + + Ok(VouchingSessionOutput { session: None }) + } +} + +crate::register_core_query!(VouchingSessionQuery, "network.pair.vouching_session"); diff --git a/core/src/ops/network/sync_setup/action.rs b/core/src/ops/network/sync_setup/action.rs index 83bf611f8..a9b0de244 100644 --- a/core/src/ops/network/sync_setup/action.rs +++ b/core/src/ops/network/sync_setup/action.rs @@ -205,7 +205,6 @@ impl LibrarySyncSetupAction { created_at: Set(Utc::now()), updated_at: Set(Utc::now()), sync_enabled: Set(true), - last_sync_at: Set(None), }; device_model @@ -219,6 +218,29 @@ impl LibrarySyncSetupAction { local_library.id(), remote_device_slug ); + + // Sync the device record so it propagates to all devices in library + let inserted_device = entities::device::Entity::find() + .filter(entities::device::Column::Uuid.eq(remote_device_id)) + .one(db.conn()) + .await + .map_err(|e| ActionError::Internal(format!("Failed to query device: {}", e)))? + .ok_or_else(|| { + ActionError::Internal("Device not found after insert".to_string()) + })?; + + use crate::infra::sync::ChangeType; + local_library + .sync_model(&inserted_device, ChangeType::Insert) + .await + .map_err(|e| { + ActionError::Internal(format!("Failed to sync device record: {}", e)) + })?; + + info!( + "Synced device record for {} to all library members", + remote_device_id + ); } Ok(crate::infra::action::ValidationResult::Success { metadata: None }) @@ -249,10 +271,11 @@ impl LibrarySyncSetupAction { // Send CreateSharedLibraryRequest to remote device use crate::service::network::protocol::library_messages::LibraryMessage; - let local_device_config = context + // Get full device information including hardware specs + let local_device = context .device_manager - .config() - .map_err(|e| ActionError::Internal(format!("Failed to get device config: {}", e)))?; + .to_device() + .map_err(|e| ActionError::Internal(format!("Failed to get device info: {}", e)))?; // Get library-specific slug for this device let local_device_slug = context @@ -266,8 +289,23 @@ impl LibrarySyncSetupAction { library_name: library_name.clone(), description: config.description.clone(), requesting_device_id: self.input.local_device_id, - requesting_device_name: local_device_config.name.clone(), + requesting_device_name: local_device.name, requesting_device_slug: local_device_slug, + requesting_device_os: local_device.os.to_string(), + requesting_device_os_version: local_device.os_version, + requesting_device_hardware_model: local_device.hardware_model, + requesting_device_cpu_model: local_device.cpu_model, + requesting_device_cpu_architecture: local_device.cpu_architecture, + requesting_device_cpu_cores_physical: local_device.cpu_cores_physical, + requesting_device_cpu_cores_logical: local_device.cpu_cores_logical, + requesting_device_cpu_frequency_mhz: local_device.cpu_frequency_mhz, + requesting_device_memory_total_bytes: local_device.memory_total_bytes, + requesting_device_form_factor: local_device.form_factor.map(|f| f.to_string()), + requesting_device_manufacturer: local_device.manufacturer, + requesting_device_gpu_models: local_device.gpu_models, + requesting_device_boot_disk_type: local_device.boot_disk_type, + requesting_device_boot_disk_capacity_bytes: local_device.boot_disk_capacity_bytes, + requesting_device_swap_total_bytes: local_device.swap_total_bytes, }; info!( @@ -307,22 +345,17 @@ impl LibrarySyncSetupAction { remote_slug ); - // Register remote device in local library with its resolved slug - self.register_remote_device_in_library( - &context, - local_library, - self.input.remote_device_id, - remote_slug, - ) - .await?; - - // Send request to remote device to register local device + // Send RegisterDeviceRequest to remote device + // Remote will register us, then send RegisterDeviceRequest back to register themselves + // This bidirectional exchange ensures both devices have full hardware specs let networking = context .get_networking() .await .ok_or_else(|| ActionError::Internal("Networking not available".to_string()))?; - let local_device_config = context.device_manager.config().map_err(|e| { - ActionError::Internal(format!("Failed to get device config: {}", e)) + + // Get full device information including hardware specs + let local_device = context.device_manager.to_device().map_err(|e| { + ActionError::Internal(format!("Failed to get device info: {}", e)) })?; // Get library-specific slug (uses override if set, otherwise global slug) @@ -337,11 +370,23 @@ impl LibrarySyncSetupAction { request_id: Uuid::new_v4(), library_id: Some(library_id), device_id: self.input.local_device_id, - device_name: local_device_config.name.clone(), + device_name: local_device.name, device_slug: local_device_slug, - os_name: local_device_config.os.to_string(), - os_version: None, - hardware_model: local_device_config.hardware_model.clone(), + os_name: local_device.os.to_string(), + os_version: local_device.os_version, + hardware_model: local_device.hardware_model, + cpu_model: local_device.cpu_model, + cpu_architecture: local_device.cpu_architecture, + cpu_cores_physical: local_device.cpu_cores_physical, + cpu_cores_logical: local_device.cpu_cores_logical, + cpu_frequency_mhz: local_device.cpu_frequency_mhz, + memory_total_bytes: local_device.memory_total_bytes, + form_factor: local_device.form_factor.map(|f| f.to_string()), + manufacturer: local_device.manufacturer, + gpu_models: local_device.gpu_models, + boot_disk_type: local_device.boot_disk_type, + boot_disk_capacity_bytes: local_device.boot_disk_capacity_bytes, + swap_total_bytes: local_device.swap_total_bytes, }; match networking @@ -450,20 +495,15 @@ impl LibrarySyncSetupAction { }; // Register remote device in the newly created local library - self.register_remote_device_in_library( - &context, - &local_library, - self.input.remote_device_id, - remote_device_slug, - ) - .await?; + // Send RegisterDeviceRequest to remote device + // Remote will register us, then send RegisterDeviceRequest back to register themselves + // This bidirectional exchange ensures both devices have full hardware specs - // Send request to remote device to register local device - - let local_device_config = context + // Get full device information including hardware specs + let local_device = context .device_manager - .config() - .map_err(|e| ActionError::Internal(format!("Failed to get device config: {}", e)))?; + .to_device() + .map_err(|e| ActionError::Internal(format!("Failed to get device info: {}", e)))?; // Get library-specific slug (uses override if set, otherwise global slug) let local_device_slug = context @@ -477,11 +517,23 @@ impl LibrarySyncSetupAction { request_id: Uuid::new_v4(), library_id: Some(remote_library_id), device_id: self.input.local_device_id, - device_name: local_device_config.name.clone(), + device_name: local_device.name, device_slug: local_device_slug, - os_name: local_device_config.os.to_string(), - os_version: None, - hardware_model: local_device_config.hardware_model.clone(), + os_name: local_device.os.to_string(), + os_version: local_device.os_version, + hardware_model: local_device.hardware_model, + cpu_model: local_device.cpu_model, + cpu_architecture: local_device.cpu_architecture, + cpu_cores_physical: local_device.cpu_cores_physical, + cpu_cores_logical: local_device.cpu_cores_logical, + cpu_frequency_mhz: local_device.cpu_frequency_mhz, + memory_total_bytes: local_device.memory_total_bytes, + form_factor: local_device.form_factor.map(|f| f.to_string()), + manufacturer: local_device.manufacturer, + gpu_models: local_device.gpu_models, + boot_disk_type: local_device.boot_disk_type, + boot_disk_capacity_bytes: local_device.boot_disk_capacity_bytes, + swap_total_bytes: local_device.swap_total_bytes, }; match networking diff --git a/core/src/ops/sync/get_sync_partners/action.rs b/core/src/ops/sync/get_sync_partners/action.rs new file mode 100644 index 000000000..f565f8dd7 --- /dev/null +++ b/core/src/ops/sync/get_sync_partners/action.rs @@ -0,0 +1,126 @@ +//! Get sync partners action + +use crate::context::CoreContext; +use crate::infra::query::{LibraryQuery, QueryError, QueryResult}; +use crate::infra::sync::NetworkTransport; +use std::sync::Arc; + +use super::{GetSyncPartnersInput, GetSyncPartnersOutput}; +use super::output::{DeviceDebugInfo, SyncPartnerInfo, SyncPartnersDebugInfo}; + +/// Get computed sync partners for the current library +pub struct GetSyncPartners { + pub input: GetSyncPartnersInput, +} + +impl LibraryQuery for GetSyncPartners { + type Input = GetSyncPartnersInput; + type Output = GetSyncPartnersOutput; + + fn from_input(input: Self::Input) -> QueryResult { + Ok(Self { input }) + } + + async fn execute( + self, + context: Arc, + session: crate::infra::api::SessionContext, + ) -> QueryResult { + use crate::infra::db::entities; + use sea_orm::{EntityTrait}; + + // Get library from session + let library_id = session + .current_library_id + .ok_or_else(|| QueryError::Internal("No library in session".to_string()))?; + let library = context + .libraries() + .await + .get_library(library_id) + .await + .ok_or_else(|| QueryError::LibraryNotFound(library_id))?; + + let db = library.db().conn(); + + // Get the sync service + let sync_service = library + .sync_service() + .ok_or_else(|| QueryError::Internal("Sync service not initialized".to_string()))?; + + // Get all library devices first for debug info + let all_devices = entities::device::Entity::find() + .all(db) + .await + .map_err(|e| QueryError::Database(e.to_string()))?; + + // Get the NetworkTransport from sync service + let network = sync_service.peer_sync().network(); + + // Call get_connected_sync_partners (the same method the Ready state uses) + let partner_uuids = network + .get_connected_sync_partners(library_id, db) + .await + .map_err(|e| QueryError::Internal(format!("Failed to get sync partners: {}", e)))?; + + // Get the device registry to check NodeId mappings + let device_registry = context + .get_networking() + .await + .map(|networking| networking.device_registry()); + + // Build partner info list + let mut partners = Vec::new(); + for device_uuid in &partner_uuids { + if let Some(device) = all_devices.iter().find(|d| &d.uuid == device_uuid) { + partners.push(SyncPartnerInfo { + device_uuid: device.uuid, + device_name: device.name.clone(), + is_paired: true, // If it's in the list, it must be paired + }); + } + } + + // Build debug info + let sync_enabled_count = all_devices.iter().filter(|d| d.sync_enabled).count(); + + let mut paired_count = 0; + let mut device_details = Vec::new(); + + if let Some(registry_arc) = device_registry { + let registry = registry_arc.read().await; + + for device in &all_devices { + let node_id = registry.get_node_id_for_device(device.uuid); + let has_node_id = node_id.is_some(); + + if has_node_id { + paired_count += 1; + } + + device_details.push(DeviceDebugInfo { + uuid: device.uuid, + name: device.name.clone(), + sync_enabled: device.sync_enabled, + has_node_id, + node_id: node_id.map(|id| id.to_string()), + }); + } + } + + let debug_info = SyncPartnersDebugInfo { + total_devices: all_devices.len(), + sync_enabled_devices: sync_enabled_count, + paired_devices: paired_count, + final_sync_partners: partner_uuids.len(), + device_details, + }; + + Ok(GetSyncPartnersOutput { + partners, + debug_info, + }) + } +} + +// Register the query +crate::register_library_query!(GetSyncPartners, "sync.partners"); diff --git a/core/src/ops/sync/get_sync_partners/input.rs b/core/src/ops/sync/get_sync_partners/input.rs new file mode 100644 index 000000000..d1b5c645c --- /dev/null +++ b/core/src/ops/sync/get_sync_partners/input.rs @@ -0,0 +1,7 @@ +//! Input for get sync partners operation + +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct GetSyncPartnersInput {} diff --git a/core/src/ops/sync/get_sync_partners/mod.rs b/core/src/ops/sync/get_sync_partners/mod.rs new file mode 100644 index 000000000..78b7c2d6b --- /dev/null +++ b/core/src/ops/sync/get_sync_partners/mod.rs @@ -0,0 +1,9 @@ +//! Get sync partners operation + +pub mod action; +pub mod input; +pub mod output; + +pub use action::GetSyncPartners; +pub use input::GetSyncPartnersInput; +pub use output::GetSyncPartnersOutput; diff --git a/core/src/ops/sync/get_sync_partners/output.rs b/core/src/ops/sync/get_sync_partners/output.rs new file mode 100644 index 000000000..1c8c2300a --- /dev/null +++ b/core/src/ops/sync/get_sync_partners/output.rs @@ -0,0 +1,36 @@ +//! Output for get sync partners operation + +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct GetSyncPartnersOutput { + pub partners: Vec, + pub debug_info: SyncPartnersDebugInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SyncPartnerInfo { + pub device_uuid: Uuid, + pub device_name: String, + pub is_paired: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SyncPartnersDebugInfo { + pub total_devices: usize, + pub sync_enabled_devices: usize, + pub paired_devices: usize, + pub final_sync_partners: usize, + pub device_details: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct DeviceDebugInfo { + pub uuid: Uuid, + pub name: String, + pub sync_enabled: bool, + pub has_node_id: bool, + pub node_id: Option, +} diff --git a/core/src/ops/sync/mod.rs b/core/src/ops/sync/mod.rs index d2b0cd616..adab55193 100644 --- a/core/src/ops/sync/mod.rs +++ b/core/src/ops/sync/mod.rs @@ -3,3 +3,4 @@ pub mod get_activity; pub mod get_event_log; pub mod get_metrics; +pub mod get_sync_partners; diff --git a/core/src/service/network/device/mod.rs b/core/src/service/network/device/mod.rs index 332e8ffac..5336377d1 100644 --- a/core/src/service/network/device/mod.rs +++ b/core/src/service/network/device/mod.rs @@ -18,7 +18,7 @@ pub struct ConnectionInfo { pub rx_bytes: u64, pub tx_bytes: u64, } -pub use persistence::{DevicePersistence, PersistedPairedDevice, TrustLevel}; +pub use persistence::{DevicePersistence, PairingType, PersistedPairedDevice, TrustLevel}; pub use registry::DeviceRegistry; /// Information about a device on the network @@ -136,7 +136,7 @@ impl SessionKeys { send_key: send_key.to_vec(), receive_key: receive_key.to_vec(), created_at: Utc::now(), - expires_at: Some(Utc::now() + chrono::Duration::hours(24)), // 24 hour expiry + expires_at: None, // Disabled: paired devices don't expire (can re-enable for key rotation) } } diff --git a/core/src/service/network/device/persistence.rs b/core/src/service/network/device/persistence.rs index 14b37647b..be191556e 100644 --- a/core/src/service/network/device/persistence.rs +++ b/core/src/service/network/device/persistence.rs @@ -7,8 +7,21 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; -use uuid::Uuid; use tracing::info; +use uuid::Uuid; + +/// Pairing type for a device relationship +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PairingType { + Direct, + Proxied, +} + +impl Default for PairingType { + fn default() -> Self { + Self::Direct + } +} /// Persisted paired device data (plain data structure) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -22,6 +35,12 @@ pub struct PersistedPairedDevice { /// Cached relay URL for reconnection optimization (discovered via pkarr or connection) #[serde(default)] pub relay_url: Option, + #[serde(default)] + pub pairing_type: PairingType, + #[serde(default)] + pub vouched_by: Option, + #[serde(default)] + pub vouched_at: Option>, } /// Trust level for persistent connections @@ -42,6 +61,7 @@ impl Default for TrustLevel { } /// Device persistence manager +#[derive(Clone)] pub struct DevicePersistence { key_manager: Arc, } @@ -164,6 +184,9 @@ impl DevicePersistence { device_info: DeviceInfo, session_keys: SessionKeys, relay_url: Option, + pairing_type: PairingType, + vouched_by: Option, + vouched_at: Option>, ) -> Result<()> { let mut devices = self.load_paired_devices().await?; @@ -175,6 +198,9 @@ impl DevicePersistence { connection_attempts: 0, trust_level: TrustLevel::Trusted, relay_url, + pairing_type, + vouched_by, + vouched_at, }; devices.insert(device_id, paired_device); @@ -216,6 +242,15 @@ impl DevicePersistence { Ok(()) } + /// Get a single paired device by ID + pub async fn get_paired_device( + &self, + device_id: Uuid, + ) -> Result> { + let mut devices = self.load_paired_devices().await?; + Ok(devices.remove(&device_id)) + } + /// Remove a paired device pub async fn remove_paired_device(&self, device_id: Uuid) -> Result { tracing::debug!( @@ -383,7 +418,15 @@ mod tests { // Add paired device persistence - .add_paired_device(device_id, device_info.clone(), session_keys.clone(), None) + .add_paired_device( + device_id, + device_info.clone(), + session_keys.clone(), + None, + PairingType::Direct, + None, + None, + ) .await .unwrap(); @@ -407,7 +450,15 @@ mod tests { let session_keys = SessionKeys::from_shared_secret(vec![1, 2, 3, 4]); persistence - .add_paired_device(device_id, device_info, session_keys, None) + .add_paired_device( + device_id, + device_info, + session_keys, + None, + PairingType::Direct, + None, + None, + ) .await .unwrap(); @@ -425,7 +476,15 @@ mod tests { let session_keys = SessionKeys::from_shared_secret(vec![1, 2, 3, 4]); persistence - .add_paired_device(device_id, device_info, session_keys, None) + .add_paired_device( + device_id, + device_info, + session_keys, + None, + PairingType::Direct, + None, + None, + ) .await .unwrap(); @@ -450,7 +509,15 @@ mod tests { // Add device (this will encrypt and save) persistence - .add_paired_device(device_id, device_info.clone(), session_keys.clone(), None) + .add_paired_device( + device_id, + device_info.clone(), + session_keys.clone(), + None, + PairingType::Direct, + None, + None, + ) .await .unwrap(); diff --git a/core/src/service/network/device/registry.rs b/core/src/service/network/device/registry.rs index 51fc8e190..62190a8bb 100644 --- a/core/src/service/network/device/registry.rs +++ b/core/src/service/network/device/registry.rs @@ -68,10 +68,18 @@ impl DeviceRegistry { } /// Set the library manager for querying device data - pub fn set_library_manager(&mut self, library_manager: std::sync::Weak) { + pub fn set_library_manager( + &mut self, + library_manager: std::sync::Weak, + ) { self.library_manager = Some(library_manager); } + /// Clone the persistence manager for async usage without locking + pub fn persistence(&self) -> DevicePersistence { + self.persistence.clone() + } + /// Update device online status in library database async fn update_device_online_status(&self, device_id: Uuid, is_online: bool) { let Some(library_manager_weak) = &self.library_manager else { @@ -92,12 +100,16 @@ impl DeviceRegistry { use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; match crate::infra::db::entities::device::Entity::find() - .filter(crate::infra::db::entities::device::Column::Uuid.eq(device_id.as_bytes().to_vec())) + .filter( + crate::infra::db::entities::device::Column::Uuid + .eq(device_id.as_bytes().to_vec()), + ) .one(db) .await { Ok(Some(model)) => { - let mut active_model: crate::infra::db::entities::device::ActiveModel = model.into(); + let mut active_model: crate::infra::db::entities::device::ActiveModel = + model.into(); active_model.is_online = Set(is_online); active_model.last_seen_at = Set(chrono::Utc::now()); @@ -150,7 +162,10 @@ impl DeviceRegistry { // Query device from database by UUID use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; if let Ok(Some(model)) = crate::infra::db::entities::device::Entity::find() - .filter(crate::infra::db::entities::device::Column::Uuid.eq(device_id.as_bytes().to_vec())) + .filter( + crate::infra::db::entities::device::Column::Uuid + .eq(device_id.as_bytes().to_vec()), + ) .one(db) .await { @@ -298,6 +313,9 @@ impl DeviceRegistry { info: DeviceInfo, session_keys: SessionKeys, relay_url: Option, + pairing_type: super::PairingType, + vouched_by: Option, + vouched_at: Option>, ) -> Result<()> { // Parse node ID from network fingerprint let node_id = info @@ -348,7 +366,15 @@ impl DeviceRegistry { // Persist the paired device for future reconnection (with relay_url for optimization) if let Err(e) = self .persistence - .add_paired_device(device_id, info.clone(), session_keys.clone(), relay_url) + .add_paired_device( + device_id, + info.clone(), + session_keys.clone(), + relay_url, + pairing_type, + vouched_by, + vouched_at, + ) .await { self.logger @@ -581,6 +607,14 @@ impl DeviceRegistry { self.persistence.remove_paired_device(device_id).await } + /// Get persisted paired device info + pub async fn get_persisted_device( + &self, + device_id: Uuid, + ) -> Result> { + self.persistence.get_paired_device(device_id).await + } + /// Get peer ID for a device pub fn get_node_by_device(&self, device_id: Uuid) -> Option { // Look through node_to_device map in reverse diff --git a/core/src/service/network/protocol/library_messages.rs b/core/src/service/network/protocol/library_messages.rs index bfead66b6..96589be43 100644 --- a/core/src/service/network/protocol/library_messages.rs +++ b/core/src/service/network/protocol/library_messages.rs @@ -27,6 +27,19 @@ pub enum LibraryMessage { os_name: String, os_version: Option, hardware_model: Option, + // Hardware specifications + cpu_model: Option, + cpu_architecture: Option, + cpu_cores_physical: Option, + cpu_cores_logical: Option, + cpu_frequency_mhz: Option, + memory_total_bytes: Option, + form_factor: Option, + manufacturer: Option, + gpu_models: Option>, + boot_disk_type: Option, + boot_disk_capacity_bytes: Option, + swap_total_bytes: Option, }, /// Response to device registration @@ -46,6 +59,22 @@ pub enum LibraryMessage { requesting_device_id: Uuid, requesting_device_name: String, requesting_device_slug: String, + requesting_device_os: String, + requesting_device_os_version: Option, + requesting_device_hardware_model: Option, + // Requesting device hardware specifications + requesting_device_cpu_model: Option, + requesting_device_cpu_architecture: Option, + requesting_device_cpu_cores_physical: Option, + requesting_device_cpu_cores_logical: Option, + requesting_device_cpu_frequency_mhz: Option, + requesting_device_memory_total_bytes: Option, + requesting_device_form_factor: Option, + requesting_device_manufacturer: Option, + requesting_device_gpu_models: Option>, + requesting_device_boot_disk_type: Option, + requesting_device_boot_disk_capacity_bytes: Option, + requesting_device_swap_total_bytes: Option, }, /// Response to library creation request diff --git a/core/src/service/network/protocol/messaging.rs b/core/src/service/network/protocol/messaging.rs index 785f0cd4e..d8059ae62 100644 --- a/core/src/service/network/protocol/messaging.rs +++ b/core/src/service/network/protocol/messaging.rs @@ -230,6 +230,18 @@ impl MessagingProtocolHandler { os_name, os_version, hardware_model, + cpu_model, + cpu_architecture, + cpu_cores_physical, + cpu_cores_logical, + cpu_frequency_mhz, + memory_total_bytes, + form_factor, + manufacturer, + gpu_models, + boot_disk_type, + boot_disk_capacity_bytes, + swap_total_bytes, } => { // Get context let context = self.context.as_ref().ok_or_else(|| { @@ -269,9 +281,43 @@ impl MessagingProtocolHandler { .await; match existing { - Ok(Some(_)) => { - // Already registered, skip - continue; + Ok(Some(existing_device)) => { + // Device exists (from pre-registration) - update with full hardware + let mut device_model: entities::device::ActiveModel = existing_device.into(); + + // Update all fields with data from message + device_model.name = Set(device_name.clone()); + device_model.slug = Set(device_slug.clone()); + device_model.os = Set(os_name.clone()); + device_model.os_version = Set(os_version.clone()); + device_model.hardware_model = Set(hardware_model.clone()); + device_model.cpu_model = Set(cpu_model.clone()); + device_model.cpu_architecture = Set(cpu_architecture.clone()); + device_model.cpu_cores_physical = Set(cpu_cores_physical); + device_model.cpu_cores_logical = Set(cpu_cores_logical); + device_model.cpu_frequency_mhz = Set(cpu_frequency_mhz); + device_model.memory_total_bytes = Set(memory_total_bytes); + device_model.form_factor = Set(form_factor.clone()); + device_model.manufacturer = Set(manufacturer.clone()); + device_model.gpu_models = Set(gpu_models.clone().map(|g| serde_json::json!(g))); + device_model.boot_disk_type = Set(boot_disk_type.clone()); + device_model.boot_disk_capacity_bytes = Set(boot_disk_capacity_bytes); + device_model.swap_total_bytes = Set(swap_total_bytes); + device_model.is_online = Set(false); + device_model.last_seen_at = Set(Utc::now()); + device_model.updated_at = Set(Utc::now()); + + if let Err(e) = device_model.update(db.conn()).await { + success = false; + error_msg = Some(format!("Failed to update device: {}", e)); + break; + } + + tracing::info!( + "Updated existing device {} in library {} with full hardware specs", + device_id, + library.id() + ); } Ok(None) => { // Get existing slugs for collision detection @@ -300,7 +346,7 @@ impl MessagingProtocolHandler { ); } - // Register remote device + // Register remote device with full hardware specs let device_model = entities::device::ActiveModel { id: sea_orm::ActiveValue::NotSet, uuid: Set(device_id), @@ -309,19 +355,18 @@ impl MessagingProtocolHandler { os: Set(os_name.clone()), os_version: Set(os_version.clone()), hardware_model: Set(hardware_model.clone()), - // Hardware specs - not available for remote devices - cpu_model: Set(None), - cpu_architecture: Set(None), - cpu_cores_physical: Set(None), - cpu_cores_logical: Set(None), - cpu_frequency_mhz: Set(None), - memory_total_bytes: Set(None), - form_factor: Set(None), - manufacturer: Set(None), - gpu_models: Set(None), - boot_disk_type: Set(None), - boot_disk_capacity_bytes: Set(None), - swap_total_bytes: Set(None), + cpu_model: Set(cpu_model.clone()), + cpu_architecture: Set(cpu_architecture.clone()), + cpu_cores_physical: Set(cpu_cores_physical), + cpu_cores_logical: Set(cpu_cores_logical), + cpu_frequency_mhz: Set(cpu_frequency_mhz), + memory_total_bytes: Set(memory_total_bytes), + form_factor: Set(form_factor.clone()), + manufacturer: Set(manufacturer.clone()), + gpu_models: Set(gpu_models.clone().map(|g| serde_json::json!(g))), + boot_disk_type: Set(boot_disk_type.clone()), + boot_disk_capacity_bytes: Set(boot_disk_capacity_bytes), + swap_total_bytes: Set(swap_total_bytes), network_addresses: Set(serde_json::json!([])), is_online: Set(false), last_seen_at: Set(Utc::now()), @@ -331,8 +376,7 @@ impl MessagingProtocolHandler { "volume_detection": true })), created_at: Set(Utc::now()), - sync_enabled: Set(true), // Enable sync for registered devices - last_sync_at: Set(None), + sync_enabled: Set(true), updated_at: Set(Utc::now()), }; @@ -341,6 +385,67 @@ impl MessagingProtocolHandler { error_msg = Some(format!("Failed to register device: {}", e)); break; } + + tracing::info!( + "Registered device {} in library {} with full hardware specs", + device_id, + library.id() + ); + + // Send RegisterDeviceRequest back to register ourselves with the new device + let context_clone = context.clone(); + let sender_device_id = device_id; + tokio::spawn(async move { + // Get our device info + if let Ok(our_device) = context_clone.device_manager.to_device() { + // Get our slug for this library + if let Some(lib_id) = library_id { + if let Ok(our_slug) = context_clone.device_manager.slug_for_library(lib_id) { + // Get networking + if let Some(networking) = context_clone.get_networking().await { + let our_register_request = LibraryMessage::RegisterDeviceRequest { + request_id: Uuid::new_v4(), + library_id, + device_id: our_device.id, + device_name: our_device.name, + device_slug: our_slug, + os_name: our_device.os.to_string(), + os_version: our_device.os_version, + hardware_model: our_device.hardware_model, + cpu_model: our_device.cpu_model, + cpu_architecture: our_device.cpu_architecture, + cpu_cores_physical: our_device.cpu_cores_physical, + cpu_cores_logical: our_device.cpu_cores_logical, + cpu_frequency_mhz: our_device.cpu_frequency_mhz, + memory_total_bytes: our_device.memory_total_bytes, + form_factor: our_device.form_factor.map(|f| f.to_string()), + manufacturer: our_device.manufacturer, + gpu_models: our_device.gpu_models, + boot_disk_type: our_device.boot_disk_type, + boot_disk_capacity_bytes: our_device.boot_disk_capacity_bytes, + swap_total_bytes: our_device.swap_total_bytes, + }; + + // Send to the device that just registered with us + if let Err(e) = networking + .send_library_request(sender_device_id, our_register_request) + .await + { + tracing::warn!( + "Failed to send bidirectional RegisterDeviceRequest: {}", + e + ); + } else { + tracing::info!( + "Sent RegisterDeviceRequest back to {} for bidirectional registration", + sender_device_id + ); + } + } + } + } + } + }); } Err(e) => { success = false; @@ -350,10 +455,11 @@ impl MessagingProtocolHandler { } } + // Send response confirming registration let response = Message::Library(LibraryMessage::RegisterDeviceResponse { request_id, success, - message: error_msg, + message: error_msg.clone(), }); serde_json::to_vec(&response).map_err(|e| NetworkingError::Serialization(e)) @@ -373,6 +479,21 @@ impl MessagingProtocolHandler { requesting_device_id, requesting_device_name, requesting_device_slug, + requesting_device_os, + requesting_device_os_version, + requesting_device_hardware_model, + requesting_device_cpu_model, + requesting_device_cpu_architecture, + requesting_device_cpu_cores_physical, + requesting_device_cpu_cores_logical, + requesting_device_cpu_frequency_mhz, + requesting_device_memory_total_bytes, + requesting_device_form_factor, + requesting_device_manufacturer, + requesting_device_gpu_models, + requesting_device_boot_disk_type, + requesting_device_boot_disk_capacity_bytes, + requesting_device_swap_total_bytes, } => { tracing::info!( "Received CreateSharedLibraryRequest: {} ({}) from device {} (slug: {})", @@ -406,8 +527,7 @@ impl MessagingProtocolHandler { } // Create library with specific UUID - // Note: We pass the requesting device info so it can be pre-registered - // before ensure_device_registered runs for the current device + // Pre-register the requesting device with full hardware specs match library_manager .create_library_with_id_and_initial_device( library_id, @@ -416,6 +536,21 @@ impl MessagingProtocolHandler { requesting_device_id, requesting_device_name, requesting_device_slug, + requesting_device_os, + requesting_device_os_version, + requesting_device_hardware_model, + requesting_device_cpu_model, + requesting_device_cpu_architecture, + requesting_device_cpu_cores_physical, + requesting_device_cpu_cores_logical, + requesting_device_cpu_frequency_mhz, + requesting_device_memory_total_bytes, + requesting_device_form_factor, + requesting_device_manufacturer, + requesting_device_gpu_models, + requesting_device_boot_disk_type, + requesting_device_boot_disk_capacity_bytes, + requesting_device_swap_total_bytes, context.clone(), ) .await diff --git a/core/src/service/network/protocol/pairing/initiator.rs b/core/src/service/network/protocol/pairing/initiator.rs index 085796a39..09349d3c3 100644 --- a/core/src/service/network/protocol/pairing/initiator.rs +++ b/core/src/service/network/protocol/pairing/initiator.rs @@ -192,25 +192,39 @@ impl PairingProtocolHandler { .map_err(|e| NetworkingError::Serialization(e)); } - self.log_info(&format!( - "Signature verified successfully for session {} from device {}", - session_id, from_device - )) - .await; + self.log_info(&format!( + "Signature verified successfully for session {} from device {}", + session_id, from_device + )) + .await; - // Signature is valid - complete pairing on Initiator's side - let shared_secret = self.generate_shared_secret(session_id).await?; - let session_keys = SessionKeys::from_shared_secret(shared_secret.clone()); + // Update session with the final device_info from Response (has correct node_id) + // This ensures vouching uses the joiner's authoritative device info + { + let mut sessions = self.active_sessions.write().await; + if let Some(session) = sessions.get_mut(&session_id) { + session.remote_device_info = Some(device_info.clone()); + self.log_debug(&format!( + "Updated session {} with joiner's device info (node_id: {})", + session_id, device_info.network_fingerprint.node_id + )) + .await; + } + } - let actual_device_id = device_info.device_id; - let node_id = match device_info.network_fingerprint.node_id.parse::() { - Ok(id) => id, - Err(_) => { - self.log_warn("Failed to parse node ID from device info, using fallback") - .await; - NodeId::from_bytes(&[0u8; 32]).unwrap() - } - }; + // Signature is valid - complete pairing on Initiator's side + let shared_secret = self.generate_shared_secret(session_id).await?; + let session_keys = SessionKeys::from_shared_secret(shared_secret.clone()); + + let actual_device_id = device_info.device_id; + let node_id = match device_info.network_fingerprint.node_id.parse::() { + Ok(id) => id, + Err(_) => { + self.log_warn("Failed to parse node ID from device info, using fallback") + .await; + NodeId::from_bytes(&[0u8; 32]).unwrap() + } + }; // Register joiner's device in Pairing state { @@ -245,6 +259,9 @@ impl PairingProtocolHandler { device_info.clone(), session_keys, relay_url, + crate::service::network::device::PairingType::Direct, + None, + None, ) .await?; } @@ -291,6 +308,24 @@ impl PairingProtocolHandler { } } + // Initialize proxy pairing session for vouching UI (best-effort, don't break pairing if it fails) + match self.create_vouching_session(session_id, &device_info).await { + Ok(()) => { + self.log_info(&format!( + "Created vouching session for pairing {}", + session_id + )) + .await; + } + Err(e) => { + self.log_warn(&format!( + "Failed to create vouching session for {}: {} (continuing with pairing)", + session_id, e + )) + .await; + } + } + // Send success Complete message to joiner // If this fails to serialize, the error propagates and joiner never receives confirmation let success_response = PairingMessage::Complete { diff --git a/core/src/service/network/protocol/pairing/joiner.rs b/core/src/service/network/protocol/pairing/joiner.rs index f75ddd96c..5019ac569 100644 --- a/core/src/service/network/protocol/pairing/joiner.rs +++ b/core/src/service/network/protocol/pairing/joiner.rs @@ -197,6 +197,9 @@ impl PairingProtocolHandler { initiator_device_info.clone(), session_keys, relay_url, + crate::service::network::device::PairingType::Direct, + None, + None, ) .await?; } diff --git a/core/src/service/network/protocol/pairing/messages.rs b/core/src/service/network/protocol/pairing/messages.rs index 73b144d8a..bc472dd8e 100644 --- a/core/src/service/network/protocol/pairing/messages.rs +++ b/core/src/service/network/protocol/pairing/messages.rs @@ -1,9 +1,11 @@ //! Pairing protocol message definitions -use crate::service::network::device::DeviceInfo; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use super::proxy::{AcceptedDevice, RejectedDevice}; +use crate::service::network::device::{DeviceInfo, SessionKeys}; + /// Messages exchanged during the pairing protocol #[derive(Debug, Clone, Serialize, Deserialize)] pub enum PairingMessage { @@ -31,4 +33,28 @@ pub enum PairingMessage { success: bool, reason: Option, }, + // Voucher -> Other device: "Trust this new device" + ProxyPairingRequest { + session_id: Uuid, + vouchee_device_info: DeviceInfo, + vouchee_public_key: Vec, + voucher_device_id: Uuid, + voucher_signature: Vec, + timestamp: chrono::DateTime, + proxied_session_keys: SessionKeys, + }, + // Other device -> Voucher: "I accept or reject this vouch" + ProxyPairingResponse { + session_id: Uuid, + accepting_device_id: Uuid, + accepted: bool, + reason: Option, + }, + // Voucher -> Vouchee: "These devices accepted you" + ProxyPairingComplete { + session_id: Uuid, + voucher_device_id: Uuid, + accepted_by: Vec, + rejected_by: Vec, + }, } diff --git a/core/src/service/network/protocol/pairing/mod.rs b/core/src/service/network/protocol/pairing/mod.rs index 5888bd44d..234ec4f93 100644 --- a/core/src/service/network/protocol/pairing/mod.rs +++ b/core/src/service/network/protocol/pairing/mod.rs @@ -4,8 +4,10 @@ pub mod initiator; pub mod joiner; pub mod messages; pub mod persistence; +pub mod proxy; pub mod security; pub mod types; +pub mod vouching_queue; /// Maximum message size for pairing protocol (1MB) /// Prevents DoS attacks via oversized message claims @@ -13,25 +15,38 @@ const MAX_MESSAGE_SIZE: usize = 1024 * 1024; // Re-export main types pub use messages::PairingMessage; +pub use proxy::{ + AcceptedDevice, RejectedDevice, VouchPayload, VouchState, VouchStatus, VouchingSession, + VouchingSessionState, +}; pub use types::{PairingAdvertisement, PairingCode, PairingRole, PairingSession, PairingState}; -use super::{ProtocolEvent, ProtocolHandler}; -use crate::service::network::{ - device::{DeviceInfo, DeviceRegistry, SessionKeys}, - utils::{self, identity::NetworkFingerprint, logging::NetworkLogger, NetworkIdentity}, - NetworkingError, Result, -}; -use async_trait::async_trait; -use blake3; -use iroh::{endpoint::Connection, Endpoint, NodeAddr, NodeId, Watcher}; -use persistence::PairingPersistence; -use security::PairingSecurity; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; + +use async_trait::async_trait; +use blake3; +use iroh::{endpoint::Connection, Endpoint, NodeAddr, NodeId, Watcher}; use tokio::sync::RwLock; use uuid::Uuid; +use super::{ProtocolEvent, ProtocolHandler}; +use crate::{ + config::app_config::ProxyPairingConfig, + infra::event::{Event, EventBus, ResourceMetadata}, + service::network::{ + device::{DeviceInfo, DeviceRegistry, SessionKeys}, + utils::{self, identity::NetworkFingerprint, logging::NetworkLogger, NetworkIdentity}, + NetworkingError, Result, + }, +}; +use bincode::config::standard; +use bincode::serde::encode_to_vec; +use persistence::PairingPersistence; +use security::PairingSecurity; +use vouching_queue::{VouchQueueStatus, VouchingQueue, VouchingQueueEntry}; + /// Pairing protocol handler pub struct PairingProtocolHandler { /// Network identity for signing @@ -65,6 +80,35 @@ pub struct PairingProtocolHandler { /// Cached connections to remote nodes (keyed by NodeId and ALPN) connections: Arc), Connection>>>, + + /// Event bus for emitting pairing events + event_bus: Arc>>>, + + /// Proxy pairing configuration + proxy_config: Arc>, + + /// Active proxy vouching sessions + vouching_sessions: Arc>>, + + /// Pending proxy confirmations awaiting user action + pending_proxy_confirmations: Arc>>, + + /// Persistent queue for offline vouches + vouching_queue: Arc>>>, + + /// Cached vouchee session keys for proxy pairing completion + vouching_keys: Arc>>, +} + +#[derive(Debug, Clone)] +struct PendingProxyConfirmation { + session_id: Uuid, + voucher_device_id: Uuid, + voucher_device_name: String, + vouchee_device_info: DeviceInfo, + vouchee_public_key: Vec, + proxied_session_keys: SessionKeys, + created_at: chrono::DateTime, } impl PairingProtocolHandler { @@ -90,6 +134,12 @@ impl PairingProtocolHandler { persistence: None, endpoint, connections: active_connections, + event_bus: Arc::new(RwLock::new(None)), + proxy_config: Arc::new(RwLock::new(ProxyPairingConfig::default())), + vouching_sessions: Arc::new(RwLock::new(HashMap::new())), + pending_proxy_confirmations: Arc::new(RwLock::new(HashMap::new())), + vouching_queue: Arc::new(RwLock::new(None)), + vouching_keys: Arc::new(RwLock::new(HashMap::new())), } } @@ -117,6 +167,12 @@ impl PairingProtocolHandler { persistence: Some(persistence), endpoint, connections: active_connections, + event_bus: Arc::new(RwLock::new(None)), + proxy_config: Arc::new(RwLock::new(ProxyPairingConfig::default())), + vouching_sessions: Arc::new(RwLock::new(HashMap::new())), + pending_proxy_confirmations: Arc::new(RwLock::new(HashMap::new())), + vouching_queue: Arc::new(RwLock::new(None)), + vouching_keys: Arc::new(RwLock::new(HashMap::new())), } } @@ -138,6 +194,37 @@ impl PairingProtocolHandler { } } + pub async fn set_event_bus(&self, event_bus: Arc) { + let mut guard = self.event_bus.write().await; + *guard = Some(event_bus); + } + + pub async fn set_proxy_config(&self, config: ProxyPairingConfig) { + let mut guard = self.proxy_config.write().await; + *guard = config; + } + + pub async fn init_vouching_queue(&self, data_dir: PathBuf) -> Result<()> { + let queue = VouchingQueue::open(data_dir).await?; + let mut guard = self.vouching_queue.write().await; + *guard = Some(Arc::new(queue)); + Ok(()) + } + + pub fn start_vouching_queue_task(handler: Arc) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(10)); + loop { + interval.tick().await; + if let Err(e) = handler.process_vouching_queue().await { + handler + .log_error(&format!("Vouching queue error: {}", e)) + .await; + } + } + }); + } + /// Save current sessions to persistence async fn save_sessions_to_persistence(&self) -> Result<()> { if let Some(persistence) = &self.persistence { @@ -528,6 +615,1177 @@ impl PairingProtocolHandler { Ok(pairing_code.secret().to_vec()) } + fn build_vouch_payload( + &self, + session_id: Uuid, + vouchee_device_info: &DeviceInfo, + vouchee_public_key: &[u8], + timestamp: chrono::DateTime, + ) -> VouchPayload { + VouchPayload { + vouchee_device_id: vouchee_device_info.device_id, + vouchee_public_key: vouchee_public_key.to_vec(), + vouchee_device_info: vouchee_device_info.clone(), + timestamp, + session_id, + } + } + + fn sign_vouch_payload(&self, payload: &VouchPayload) -> Result> { + let serialized = encode_to_vec(payload, standard()).map_err(|e| { + NetworkingError::Protocol(format!("Failed to serialize vouch payload: {}", e)) + })?; + self.identity.sign(&serialized) + } + + fn verify_vouch_signature( + &self, + payload: &VouchPayload, + signature: &[u8], + public_key_bytes: &[u8], + ) -> Result { + PairingSecurity::validate_public_key(public_key_bytes)?; + PairingSecurity::validate_signature(signature)?; + let serialized = encode_to_vec(payload, standard()).map_err(|e| { + NetworkingError::Protocol(format!("Failed to serialize vouch payload: {}", e)) + })?; + + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + let verifying_key = + VerifyingKey::from_bytes(public_key_bytes.try_into().map_err(|_| { + NetworkingError::Protocol("Invalid voucher public key length".to_string()) + })?) + .map_err(|e| NetworkingError::Protocol(format!("Invalid voucher public key: {}", e)))?; + + let sig = Signature::from_slice(signature) + .map_err(|e| NetworkingError::Protocol(format!("Invalid signature: {}", e)))?; + + Ok(verifying_key.verify(&serialized, &sig).is_ok()) + } + + fn derive_proxy_shared_secret( + &self, + voucher_device_id: Uuid, + target_device_id: Uuid, + vouchee_device_id: Uuid, + vouchee_public_key: &[u8], + base_secret: &[u8], + ) -> Result> { + use hkdf::Hkdf; + use sha2::Sha256; + + let context = format!( + "spacedrive-proxy-pairing-{}:{}:{}:{}", + voucher_device_id, + target_device_id, + vouchee_device_id, + hex::encode(vouchee_public_key) + ); + + let hkdf = Hkdf::::new(None, base_secret); + let mut derived = [0u8; 32]; + hkdf.expand(context.as_bytes(), &mut derived).map_err(|e| { + NetworkingError::Protocol(format!("Failed to derive proxy shared secret: {}", e)) + })?; + + Ok(derived.to_vec()) + } + + fn derive_proxy_session_keys( + &self, + voucher_device_id: Uuid, + target_device_id: Uuid, + vouchee_device_id: Uuid, + vouchee_public_key: &[u8], + base_secret: &[u8], + ) -> Result<(SessionKeys, SessionKeys)> { + let shared_secret = self.derive_proxy_shared_secret( + voucher_device_id, + target_device_id, + vouchee_device_id, + vouchee_public_key, + base_secret, + )?; + let receiver_keys = SessionKeys::from_shared_secret(shared_secret); + let vouchee_keys = receiver_keys.clone().swap_keys(); + Ok((receiver_keys, vouchee_keys)) + } + + async fn emit_vouching_session(&self, session: &VouchingSession) -> Result<()> { + let event_bus = { self.event_bus.read().await.clone() }; + let Some(event_bus) = event_bus else { + return Ok(()); + }; + + let resource = + serde_json::to_value(session).map_err(|e| NetworkingError::Serialization(e))?; + + event_bus.emit(Event::ResourceChanged { + resource_type: "vouching_session".to_string(), + resource, + metadata: Some(ResourceMetadata { + no_merge_fields: vec!["vouches".to_string()], + alternate_ids: vec![], + affected_paths: vec![], + }), + }); + + Ok(()) + } + + async fn update_vouch_status( + &self, + session_id: Uuid, + device_id: Uuid, + status: VouchStatus, + reason: Option, + ) -> Result<()> { + if matches!(status, VouchStatus::Rejected | VouchStatus::Unreachable) { + let mut keys = self.vouching_keys.write().await; + keys.remove(&(session_id, device_id)); + } + + let mut should_finalize = false; + let session_snapshot = { + let mut sessions = self.vouching_sessions.write().await; + let session = sessions.get_mut(&session_id).ok_or_else(|| { + NetworkingError::Protocol(format!("Vouching session not found: {}", session_id)) + })?; + + if let Some(entry) = session + .vouches + .iter_mut() + .find(|v| v.device_id == device_id) + { + entry.status = status; + entry.reason = reason; + entry.updated_at = chrono::Utc::now(); + } else { + session.vouches.push(VouchState { + device_id, + device_name: "Unknown device".to_string(), + status, + updated_at: chrono::Utc::now(), + reason, + }); + } + + let all_terminal = session.vouches.iter().all(|v| { + matches!( + v.status, + VouchStatus::Accepted | VouchStatus::Rejected | VouchStatus::Unreachable + ) + }); + + if all_terminal && !matches!(session.state, VouchingSessionState::Completed) { + session.state = VouchingSessionState::Completed; + should_finalize = true; + } + + session.clone() + }; + + self.emit_vouching_session(&session_snapshot).await?; + + if should_finalize { + self.finalize_vouching_session(session_id).await?; + } + + Ok(()) + } + + async fn finalize_vouching_session(&self, session_id: Uuid) -> Result<()> { + let session = { + let sessions = self.vouching_sessions.read().await; + sessions.get(&session_id).cloned().ok_or_else(|| { + NetworkingError::Protocol(format!("Vouching session not found: {}", session_id)) + })? + }; + + let mut accepted = Vec::new(); + let mut rejected = Vec::new(); + + for vouch in &session.vouches { + match vouch.status { + VouchStatus::Accepted => { + let device_info = { + let registry = self.device_registry.read().await; + match registry.get_device_state(vouch.device_id) { + Some(crate::service::network::device::DeviceState::Paired { + info, + .. + }) + | Some(crate::service::network::device::DeviceState::Connected { + info, + .. + }) + | Some(crate::service::network::device::DeviceState::Disconnected { + info, + .. + }) => Some(info.clone()), + _ => None, + } + }; + + let session_keys = { + let keys = self.vouching_keys.read().await; + keys.get(&(session_id, vouch.device_id)).cloned() + }; + + if let (Some(info), Some(keys)) = (device_info, session_keys) { + accepted.push(proxy::AcceptedDevice { + device_info: info, + session_keys: keys, + }); + } else { + self.log_warn(&format!( + "Missing device info or keys for accepted device {}", + vouch.device_id + )) + .await; + } + } + VouchStatus::Rejected | VouchStatus::Unreachable => { + let reason = vouch + .reason + .clone() + .unwrap_or_else(|| "Vouch rejected".to_string()); + rejected.push(proxy::RejectedDevice { + device_id: vouch.device_id, + device_name: vouch.device_name.clone(), + reason, + }); + } + _ => {} + } + } + + let vouchee_node_id = { + let registry = self.device_registry.read().await; + registry.get_node_id_for_device(session.vouchee_device_id) + }; + + if let Some(node_id) = vouchee_node_id { + let message = PairingMessage::ProxyPairingComplete { + session_id, + voucher_device_id: session.voucher_device_id, + accepted_by: accepted, + rejected_by: rejected, + }; + self.send_pairing_message_fire_and_forget(node_id, &message) + .await?; + } else { + self.log_warn(&format!( + "No node ID for vouchee device {}, cannot send completion", + session.vouchee_device_id + )) + .await; + } + + { + let mut keys = self.vouching_keys.write().await; + keys.retain(|(sid, _), _| *sid != session_id); + } + + self.schedule_vouching_cleanup(session_id).await; + + Ok(()) + } + + async fn schedule_vouching_cleanup(&self, session_id: Uuid) { + let vouching_sessions = self.vouching_sessions.clone(); + let event_bus = self.event_bus.clone(); + let vouching_keys = self.vouching_keys.clone(); + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; + { + let mut sessions = vouching_sessions.write().await; + sessions.remove(&session_id); + } + { + let mut keys = vouching_keys.write().await; + keys.retain(|(sid, _), _| *sid != session_id); + } + + let event_bus = { event_bus.read().await.clone() }; + if let Some(event_bus) = event_bus { + event_bus.emit(Event::ResourceDeleted { + resource_type: "vouching_session".to_string(), + resource_id: session_id, + }); + } + }); + } + + pub async fn get_vouching_session(&self, session_id: Uuid) -> Option { + let sessions = self.vouching_sessions.read().await; + sessions.get(&session_id).cloned() + } + + pub async fn create_vouching_session( + &self, + session_id: Uuid, + vouchee_device_info: &DeviceInfo, + ) -> Result<()> { + let voucher_device_id = self.get_device_info().await?.device_id; + let session = VouchingSession { + id: session_id, + vouchee_device_id: vouchee_device_info.device_id, + vouchee_device_name: vouchee_device_info.device_name.clone(), + voucher_device_id, + created_at: chrono::Utc::now(), + state: VouchingSessionState::Pending, + vouches: Vec::new(), + }; + + { + let mut sessions = self.vouching_sessions.write().await; + sessions.insert(session_id, session.clone()); + } + + self.emit_vouching_session(&session).await?; + + let event_bus = { self.event_bus.read().await.clone() }; + if let Some(event_bus) = event_bus { + event_bus.emit(Event::ProxyPairingVouchingReady { + session_id, + vouchee_device_id: vouchee_device_info.device_id, + }); + } + + let proxy_config: ProxyPairingConfig = { self.proxy_config.read().await.clone() }; + if proxy_config.auto_vouch_to_all { + let target_device_ids = { + let registry = self.device_registry.read().await; + registry + .get_paired_devices() + .into_iter() + .map(|device| device.device_id) + .filter(|device_id| { + *device_id != voucher_device_id + && *device_id != vouchee_device_info.device_id + }) + .collect::>() + }; + + if !target_device_ids.is_empty() { + if let Err(e) = self + .start_proxy_vouching(session_id, target_device_ids) + .await + { + self.log_warn(&format!( + "Failed to auto vouch session {}: {}", + session_id, e + )) + .await; + } + } + } + + Ok(()) + } + + pub async fn start_proxy_vouching( + &self, + session_id: Uuid, + target_device_ids: Vec, + ) -> Result { + let (vouchee_device_info, vouchee_public_key, shared_secret) = { + let sessions = self.active_sessions.read().await; + let session = sessions.get(&session_id).ok_or_else(|| { + NetworkingError::Protocol(format!("Pairing session not found: {}", session_id)) + })?; + + if !matches!(session.state, PairingState::Completed) { + return Err(NetworkingError::Protocol( + "Pairing session is not completed".to_string(), + )); + } + + let device_info = session.remote_device_info.clone().ok_or_else(|| { + NetworkingError::Protocol("Missing vouchee device info".to_string()) + })?; + let public_key = session.remote_public_key.clone().ok_or_else(|| { + NetworkingError::Protocol("Missing vouchee public key".to_string()) + })?; + let secret = session.shared_secret.clone(); + + self.log_debug(&format!( + "Vouching device {} with node_id: '{}'", + device_info.device_id, device_info.network_fingerprint.node_id + )) + .await; + + (device_info, public_key, secret) + }; + + let voucher_device_id = self.get_device_info().await?.device_id; + let base_secret = match shared_secret { + Some(secret) => secret, + None => self.generate_shared_secret(session_id).await?, + }; + + let now = chrono::Utc::now(); + let initial_vouches = { + let registry = self.device_registry.read().await; + target_device_ids + .iter() + .map(|device_id| { + let device_name = match registry.get_device_state(*device_id) { + Some(crate::service::network::device::DeviceState::Paired { + info, .. + }) + | Some(crate::service::network::device::DeviceState::Connected { + info, + .. + }) + | Some(crate::service::network::device::DeviceState::Disconnected { + info, + .. + }) => info.device_name.clone(), + _ => "Unknown device".to_string(), + }; + VouchState { + device_id: *device_id, + device_name, + status: VouchStatus::Selected, + updated_at: now, + reason: None, + } + }) + .collect::>() + }; + + let mut session_snapshot = { + let mut sessions = self.vouching_sessions.write().await; + let entry = sessions + .entry(session_id) + .or_insert_with(|| VouchingSession { + id: session_id, + vouchee_device_id: vouchee_device_info.device_id, + vouchee_device_name: vouchee_device_info.device_name.clone(), + voucher_device_id, + created_at: now, + state: VouchingSessionState::Pending, + vouches: Vec::new(), + }); + + entry.state = VouchingSessionState::InProgress; + entry.vouches = initial_vouches; + entry.clone() + }; + + self.emit_vouching_session(&session_snapshot).await?; + + if target_device_ids.is_empty() { + { + let mut sessions = self.vouching_sessions.write().await; + if let Some(session) = sessions.get_mut(&session_id) { + session.state = VouchingSessionState::Completed; + session_snapshot = session.clone(); + } + } + self.emit_vouching_session(&session_snapshot).await?; + self.finalize_vouching_session(session_id).await?; + return Ok(session_snapshot); + } + + for target_device_id in target_device_ids { + if target_device_id == voucher_device_id + || target_device_id == vouchee_device_info.device_id + { + self.update_vouch_status( + session_id, + target_device_id, + VouchStatus::Rejected, + Some("Invalid vouch target".to_string()), + ) + .await?; + continue; + } + + let target_device_info = { + let registry = self.device_registry.read().await; + match registry.get_device_state(target_device_id) { + Some(crate::service::network::device::DeviceState::Paired { info, .. }) + | Some(crate::service::network::device::DeviceState::Connected { + info, .. + }) + | Some(crate::service::network::device::DeviceState::Disconnected { + info, + .. + }) => Some(info.clone()), + _ => None, + } + }; + + let Some(target_device_info) = target_device_info else { + self.update_vouch_status( + session_id, + target_device_id, + VouchStatus::Rejected, + Some("Target device not paired".to_string()), + ) + .await?; + continue; + }; + + let timestamp = chrono::Utc::now(); + let payload = self.build_vouch_payload( + session_id, + &vouchee_device_info, + &vouchee_public_key, + timestamp, + ); + let signature = self.sign_vouch_payload(&payload)?; + let (receiver_keys, vouchee_keys) = self.derive_proxy_session_keys( + voucher_device_id, + target_device_id, + vouchee_device_info.device_id, + &vouchee_public_key, + &base_secret, + )?; + + { + let mut keys = self.vouching_keys.write().await; + keys.insert((session_id, target_device_id), vouchee_keys); + } + + let queue_entry = VouchingQueueEntry { + session_id, + target_device_id, + voucher_device_id, + vouchee_device_id: vouchee_device_info.device_id, + vouchee_device_info: vouchee_device_info.clone(), + vouchee_public_key: vouchee_public_key.clone(), + voucher_signature: signature.clone(), + proxied_session_keys: receiver_keys.clone(), + created_at: timestamp, + expires_at: timestamp + chrono::Duration::days(7), + status: VouchQueueStatus::Queued, + retry_count: 0, + last_attempt_at: None, + }; + + let queue = { self.vouching_queue.read().await.clone() }; + if let Some(queue) = queue { + queue.upsert_entry(&queue_entry).await?; + } + + let mut sent_now = false; + if let Some(endpoint) = &self.endpoint { + let registry = self.device_registry.read().await; + if registry.is_node_connected(endpoint, target_device_id) { + if let Some(node_id) = registry.get_node_id_for_device(target_device_id) { + let request = PairingMessage::ProxyPairingRequest { + session_id, + vouchee_device_info: vouchee_device_info.clone(), + vouchee_public_key: vouchee_public_key.clone(), + voucher_device_id, + voucher_signature: signature, + timestamp, + proxied_session_keys: receiver_keys, + }; + match self + .send_pairing_message_fire_and_forget(node_id, &request) + .await + { + Ok(_) => { + sent_now = true; + } + Err(e) => { + self.log_warn(&format!( + "Failed to send proxy pairing request to {}: {}", + target_device_id, e + )) + .await; + } + } + } + } + } + + if sent_now { + let queue = { self.vouching_queue.read().await.clone() }; + if let Some(queue) = queue { + queue + .update_status( + session_id, + target_device_id, + VouchQueueStatus::Waiting, + 1, + Some(chrono::Utc::now()), + ) + .await?; + } + self.update_vouch_status(session_id, target_device_id, VouchStatus::Waiting, None) + .await?; + } else { + self.update_vouch_status(session_id, target_device_id, VouchStatus::Queued, None) + .await?; + } + } + + let session = self + .get_vouching_session(session_id) + .await + .ok_or_else(|| NetworkingError::Protocol("Vouching session missing".to_string()))?; + session_snapshot = session.clone(); + + Ok(session_snapshot) + } + + pub async fn confirm_proxy_pairing(&self, session_id: Uuid, accepted: bool) -> Result<()> { + let pending = { + let mut pending = self.pending_proxy_confirmations.write().await; + pending.remove(&session_id) + }; + + let Some(pending) = pending else { + return Err(NetworkingError::Protocol( + "No pending proxy confirmation found".to_string(), + )); + }; + + let accepting_device_id = self.get_device_info().await?.device_id; + let voucher_node_id = { + let registry = self.device_registry.read().await; + registry.get_node_id_for_device(pending.voucher_device_id) + }; + + if accepted { + { + let mut registry = self.device_registry.write().await; + registry + .complete_pairing( + pending.vouchee_device_info.device_id, + pending.vouchee_device_info.clone(), + pending.proxied_session_keys.clone(), + None, + crate::service::network::device::PairingType::Proxied, + Some(pending.voucher_device_id), + Some(chrono::Utc::now()), + ) + .await?; + } + + if let Some(node_id) = voucher_node_id { + let response = PairingMessage::ProxyPairingResponse { + session_id, + accepting_device_id, + accepted: true, + reason: None, + }; + self.send_pairing_message_fire_and_forget(node_id, &response) + .await?; + } + } else if let Some(node_id) = voucher_node_id { + let response = PairingMessage::ProxyPairingResponse { + session_id, + accepting_device_id, + accepted: false, + reason: Some("User rejected proxy pairing".to_string()), + }; + self.send_pairing_message_fire_and_forget(node_id, &response) + .await?; + } + + Ok(()) + } + + async fn handle_proxy_pairing_request( + &self, + session_id: Uuid, + vouchee_device_info: DeviceInfo, + vouchee_public_key: Vec, + voucher_device_id: Uuid, + voucher_signature: Vec, + timestamp: chrono::DateTime, + proxied_session_keys: SessionKeys, + remote_node_id: NodeId, + ) -> Result<()> { + let proxy_config: ProxyPairingConfig = { self.proxy_config.read().await.clone() }; + + let (voucher_info, voucher_node_id) = { + let registry = self.device_registry.read().await; + let voucher_info = match registry.get_device_state(voucher_device_id) { + Some(crate::service::network::device::DeviceState::Paired { info, .. }) + | Some(crate::service::network::device::DeviceState::Connected { info, .. }) + | Some(crate::service::network::device::DeviceState::Disconnected { + info, .. + }) => Some(info.clone()), + _ => None, + }; + ( + voucher_info, + registry.get_node_id_for_device(voucher_device_id), + ) + }; + + if let Some(node_id) = voucher_node_id { + if node_id != remote_node_id { + self.send_proxy_pairing_rejection( + remote_node_id, + session_id, + "Voucher node mismatch".to_string(), + ) + .await?; + return Ok(()); + } + } + + let Some(voucher_info) = voucher_info else { + self.send_proxy_pairing_rejection( + remote_node_id, + session_id, + "Voucher not paired".to_string(), + ) + .await?; + return Ok(()); + }; + + let payload = self.build_vouch_payload( + session_id, + &vouchee_device_info, + &vouchee_public_key, + timestamp, + ); + + PairingSecurity::validate_public_key(&vouchee_public_key)?; + + if !self.verify_vouch_signature(&payload, &voucher_signature, remote_node_id.as_bytes())? { + self.send_proxy_pairing_rejection( + remote_node_id, + session_id, + "Invalid voucher signature".to_string(), + ) + .await?; + return Ok(()); + } + + let max_age = chrono::Duration::seconds(proxy_config.vouch_signature_max_age as i64); + if chrono::Utc::now().signed_duration_since(timestamp) > max_age { + self.send_proxy_pairing_rejection( + remote_node_id, + session_id, + "Vouch signature expired".to_string(), + ) + .await?; + return Ok(()); + } + + { + let registry = self.device_registry.read().await; + if registry + .get_device_state(vouchee_device_info.device_id) + .is_some() + { + self.send_proxy_pairing_rejection( + remote_node_id, + session_id, + "Device already paired".to_string(), + ) + .await?; + return Ok(()); + } + } + + let persistence = { + let registry = self.device_registry.read().await; + registry.persistence() + }; + let persisted_voucher = persistence.get_paired_device(voucher_device_id).await?; + + let Some(persisted_voucher) = persisted_voucher else { + self.send_proxy_pairing_rejection( + remote_node_id, + session_id, + "Voucher not in persistence".to_string(), + ) + .await?; + return Ok(()); + }; + + let voucher_is_trusted = matches!( + persisted_voucher.trust_level, + crate::service::network::device::TrustLevel::Trusted + ); + let voucher_is_direct = matches!( + persisted_voucher.pairing_type, + crate::service::network::device::PairingType::Direct + ); + + if !voucher_is_trusted || !voucher_is_direct { + self.send_proxy_pairing_rejection( + remote_node_id, + session_id, + "Voucher not trusted for proxy pairing".to_string(), + ) + .await?; + return Ok(()); + } + + if proxied_session_keys.send_key == proxied_session_keys.receive_key { + self.send_proxy_pairing_rejection( + remote_node_id, + session_id, + "Invalid session keys".to_string(), + ) + .await?; + return Ok(()); + } + + if proxy_config.auto_accept_vouched && voucher_is_trusted { + { + self.log_info(&format!( + "Auto-accepting proxy pairing for device {} with node_id: '{}'", + vouchee_device_info.device_id, vouchee_device_info.network_fingerprint.node_id + )) + .await; + + let mut registry = self.device_registry.write().await; + registry + .complete_pairing( + vouchee_device_info.device_id, + vouchee_device_info.clone(), + proxied_session_keys.clone(), + None, + crate::service::network::device::PairingType::Proxied, + Some(voucher_device_id), + Some(chrono::Utc::now()), + ) + .await?; + } + + let accepting_device_id = self.get_device_info().await?.device_id; + let response = PairingMessage::ProxyPairingResponse { + session_id, + accepting_device_id, + accepted: true, + reason: None, + }; + self.send_pairing_message_fire_and_forget(remote_node_id, &response) + .await?; + return Ok(()); + } + + let pending = PendingProxyConfirmation { + session_id, + voucher_device_id, + voucher_device_name: voucher_info.device_name.clone(), + vouchee_device_info: vouchee_device_info.clone(), + vouchee_public_key: vouchee_public_key.clone(), + proxied_session_keys, + created_at: chrono::Utc::now(), + }; + + { + let mut pending_map = self.pending_proxy_confirmations.write().await; + pending_map.insert(session_id, pending); + } + + let event_bus = { self.event_bus.read().await.clone() }; + if let Some(event_bus) = event_bus { + let expires_at = (chrono::Utc::now() + + chrono::Duration::seconds(proxy_config.vouch_response_timeout as i64)) + .to_rfc3339(); + event_bus.emit(Event::ProxyPairingConfirmationRequired { + session_id, + vouchee_device_name: vouchee_device_info.device_name.clone(), + vouchee_device_os: vouchee_device_info.os_version.clone(), + voucher_device_name: voucher_info.device_name, + voucher_device_id, + expires_at, + }); + } + + let pending_map = self.pending_proxy_confirmations.clone(); + let command_sender = self.command_sender.clone(); + let registry = self.device_registry.clone(); + let timeout = proxy_config.vouch_response_timeout; + let accepting_device_id = self.get_device_info().await?.device_id; + + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(timeout)).await; + let pending = { + let mut guard = pending_map.write().await; + guard.remove(&session_id) + }; + + if let Some(pending) = pending { + let node_id = { + let registry = registry.read().await; + registry.get_node_id_for_device(pending.voucher_device_id) + }; + if let Some(node_id) = node_id { + if let Ok(data) = serde_json::to_vec(&PairingMessage::ProxyPairingResponse { + session_id, + accepting_device_id, + accepted: false, + reason: Some("Proxy confirmation timed out".to_string()), + }) { + let _ = command_sender.send( + crate::service::network::core::event_loop::EventLoopCommand::SendMessageToNode { + node_id, + protocol: "pairing".to_string(), + data, + }, + ); + } + } + } + }); + + Ok(()) + } + + async fn send_proxy_pairing_rejection( + &self, + remote_node_id: NodeId, + session_id: Uuid, + reason: String, + ) -> Result<()> { + let accepting_device_id = self.get_device_info().await?.device_id; + let response = PairingMessage::ProxyPairingResponse { + session_id, + accepting_device_id, + accepted: false, + reason: Some(reason), + }; + self.send_pairing_message_fire_and_forget(remote_node_id, &response) + .await + } + + async fn handle_proxy_pairing_response( + &self, + session_id: Uuid, + accepting_device_id: Uuid, + accepted: bool, + reason: Option, + ) -> Result<()> { + if self.get_vouching_session(session_id).await.is_none() { + self.log_warn(&format!( + "Proxy pairing response for unknown session {}", + session_id + )) + .await; + return Ok(()); + } + + let status = if accepted { + VouchStatus::Accepted + } else { + VouchStatus::Rejected + }; + + if !accepted { + let mut keys = self.vouching_keys.write().await; + keys.remove(&(session_id, accepting_device_id)); + } + + let queue = { self.vouching_queue.read().await.clone() }; + if let Some(queue) = queue { + queue.remove_entry(session_id, accepting_device_id).await?; + } + + self.update_vouch_status(session_id, accepting_device_id, status, reason) + .await?; + + Ok(()) + } + + async fn handle_proxy_pairing_complete( + &self, + session_id: Uuid, + voucher_device_id: Uuid, + accepted_by: Vec, + rejected_by: Vec, + ) -> Result<()> { + for accepted in accepted_by { + let device_id = accepted.device_info.device_id; + let mut registry = self.device_registry.write().await; + registry + .complete_pairing( + device_id, + accepted.device_info.clone(), + accepted.session_keys.clone(), + None, + crate::service::network::device::PairingType::Proxied, + Some(voucher_device_id), + Some(chrono::Utc::now()), + ) + .await?; + } + + if !rejected_by.is_empty() { + self.log_info(&format!( + "Proxy pairing completed with {} rejections", + rejected_by.len() + )) + .await; + } + + self.log_info(&format!( + "Proxy pairing completion handled for session {}", + session_id + )) + .await; + + Ok(()) + } + + async fn process_vouching_queue(&self) -> Result<()> { + let queue = { self.vouching_queue.read().await.clone() }; + let Some(queue) = queue else { + return Ok(()); + }; + + let config: ProxyPairingConfig = { self.proxy_config.read().await.clone() }; + let entries = queue.list_entries().await?; + let now = chrono::Utc::now(); + + for entry in entries { + if self.get_vouching_session(entry.session_id).await.is_none() { + queue + .remove_entry(entry.session_id, entry.target_device_id) + .await?; + continue; + } + + if entry.expires_at <= now { + queue + .remove_entry(entry.session_id, entry.target_device_id) + .await?; + self.update_vouch_status( + entry.session_id, + entry.target_device_id, + VouchStatus::Unreachable, + Some("Vouch expired".to_string()), + ) + .await?; + continue; + } + + if entry.retry_count >= config.vouch_queue_retry_limit { + queue + .remove_entry(entry.session_id, entry.target_device_id) + .await?; + self.update_vouch_status( + entry.session_id, + entry.target_device_id, + VouchStatus::Unreachable, + Some("Vouch retry limit exceeded".to_string()), + ) + .await?; + continue; + } + + if matches!(entry.status, VouchQueueStatus::Waiting) { + if let Some(last_attempt_at) = entry.last_attempt_at { + let timeout = chrono::Duration::seconds(config.vouch_response_timeout as i64); + if now.signed_duration_since(last_attempt_at) > timeout { + queue + .remove_entry(entry.session_id, entry.target_device_id) + .await?; + self.update_vouch_status( + entry.session_id, + entry.target_device_id, + VouchStatus::Unreachable, + Some("Proxy response timeout".to_string()), + ) + .await?; + } + } + continue; + } + + if !matches!(entry.status, VouchQueueStatus::Queued) { + continue; + } + + let endpoint = match &self.endpoint { + Some(endpoint) => endpoint, + None => continue, + }; + + let (is_connected, node_id) = { + let registry = self.device_registry.read().await; + ( + registry.is_node_connected(endpoint, entry.target_device_id), + registry.get_node_id_for_device(entry.target_device_id), + ) + }; + + if !is_connected { + continue; + } + + let Some(node_id) = node_id else { + continue; + }; + + let timestamp = chrono::Utc::now(); + let payload = self.build_vouch_payload( + entry.session_id, + &entry.vouchee_device_info, + &entry.vouchee_public_key, + timestamp, + ); + let signature = self.sign_vouch_payload(&payload)?; + + let request = PairingMessage::ProxyPairingRequest { + session_id: entry.session_id, + vouchee_device_info: entry.vouchee_device_info.clone(), + vouchee_public_key: entry.vouchee_public_key.clone(), + voucher_device_id: entry.voucher_device_id, + voucher_signature: signature, + timestamp, + proxied_session_keys: entry.proxied_session_keys.clone(), + }; + + if let Err(e) = self + .send_pairing_message_fire_and_forget(node_id, &request) + .await + { + self.log_warn(&format!( + "Failed to send queued proxy pairing request to {}: {}", + entry.target_device_id, e + )) + .await; + queue + .update_status( + entry.session_id, + entry.target_device_id, + VouchQueueStatus::Queued, + entry.retry_count + 1, + Some(now), + ) + .await?; + continue; + } + + queue + .update_status( + entry.session_id, + entry.target_device_id, + VouchQueueStatus::Waiting, + entry.retry_count + 1, + Some(now), + ) + .await?; + + self.update_vouch_status( + entry.session_id, + entry.target_device_id, + VouchStatus::Waiting, + None, + ) + .await?; + } + + Ok(()) + } + /// Handle a pairing message received over stream async fn handle_pairing_message( &self, @@ -578,6 +1836,58 @@ impl PairingProtocolHandler { .await?; Ok(None) // No response needed } + PairingMessage::ProxyPairingRequest { + session_id, + vouchee_device_info, + vouchee_public_key, + voucher_device_id, + voucher_signature, + timestamp, + proxied_session_keys, + } => { + self.handle_proxy_pairing_request( + session_id, + vouchee_device_info, + vouchee_public_key, + voucher_device_id, + voucher_signature, + timestamp, + proxied_session_keys, + remote_node_id, + ) + .await?; + Ok(None) + } + PairingMessage::ProxyPairingResponse { + session_id, + accepting_device_id, + accepted, + reason, + } => { + self.handle_proxy_pairing_response( + session_id, + accepting_device_id, + accepted, + reason, + ) + .await?; + Ok(None) + } + PairingMessage::ProxyPairingComplete { + session_id, + voucher_device_id, + accepted_by, + rejected_by, + } => { + self.handle_proxy_pairing_complete( + session_id, + voucher_device_id, + accepted_by, + rejected_by, + ) + .await?; + Ok(None) + } } } @@ -634,8 +1944,15 @@ impl PairingProtocolHandler { .await .map_err(|e| NetworkingError::Transport(format!("Failed to write message: {}", e)))?; - send.finish() - .map_err(|e| NetworkingError::Transport(format!("Failed to finish stream: {}", e)))?; + // Flush to ensure message is sent, but DON'T call finish() to keep stream open for response + send.flush() + .await + .map_err(|e| NetworkingError::Transport(format!("Failed to flush stream: {}", e)))?; + + // For PairingRequest from joiner, handle the full handshake on this stream + if matches!(message, PairingMessage::PairingRequest { .. }) { + return self.handle_joiner_pairing_stream(send, recv, node_id).await; + } let mut len_buf = [0u8; 4]; match recv.read_exact(&mut len_buf).await { @@ -662,6 +1979,157 @@ impl PairingProtocolHandler { Err(_) => Ok(None), } } + + /// Handle the joiner's full pairing handshake on a single stream + async fn handle_joiner_pairing_stream( + &self, + mut send: impl tokio::io::AsyncWrite + Unpin, + mut recv: impl tokio::io::AsyncRead + Unpin, + initiator_node_id: NodeId, + ) -> Result> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Read Challenge from initiator + let mut len_buf = [0u8; 4]; + recv.read_exact(&mut len_buf).await.map_err(|e| { + NetworkingError::Transport(format!("Failed to read challenge length: {}", e)) + })?; + let challenge_len = u32::from_be_bytes(len_buf) as usize; + + if challenge_len > MAX_MESSAGE_SIZE { + return Err(NetworkingError::Protocol(format!( + "Challenge too large: {} bytes", + challenge_len + ))); + } + + let mut challenge_buf = vec![0u8; challenge_len]; + recv.read_exact(&mut challenge_buf) + .await + .map_err(|e| NetworkingError::Transport(format!("Failed to read challenge: {}", e)))?; + + let challenge_msg: PairingMessage = serde_json::from_slice(&challenge_buf) + .map_err(|e| NetworkingError::Serialization(e))?; + + // Process Challenge and generate Response + let (session_id, response_data) = match challenge_msg { + PairingMessage::Challenge { + session_id, + challenge, + device_info, + } => { + self.log_info(&format!( + "Received Challenge for session {} on stream", + session_id + )) + .await; + let response = self + .handle_pairing_challenge(session_id, challenge, device_info) + .await?; + (session_id, response) + } + _ => { + return Err(NetworkingError::Protocol( + "Expected Challenge message".to_string(), + )); + } + }; + + // Send Response back on same stream + let response_len = response_data.len() as u32; + send.write_all(&response_len.to_be_bytes()) + .await + .map_err(|e| { + NetworkingError::Transport(format!("Failed to write response length: {}", e)) + })?; + + send.write_all(&response_data) + .await + .map_err(|e| NetworkingError::Transport(format!("Failed to write response: {}", e)))?; + + send.flush() + .await + .map_err(|e| NetworkingError::Transport(format!("Failed to flush response: {}", e)))?; + + self.log_info(&format!( + "Sent Response for session {} on stream, waiting for Complete", + session_id + )) + .await; + + // Read Complete message + recv.read_exact(&mut len_buf).await.map_err(|e| { + NetworkingError::Transport(format!("Failed to read complete length: {}", e)) + })?; + let complete_len = u32::from_be_bytes(len_buf) as usize; + + if complete_len > MAX_MESSAGE_SIZE { + return Err(NetworkingError::Protocol(format!( + "Complete message too large: {} bytes", + complete_len + ))); + } + + let mut complete_buf = vec![0u8; complete_len]; + recv.read_exact(&mut complete_buf) + .await + .map_err(|e| NetworkingError::Transport(format!("Failed to read complete: {}", e)))?; + + let complete_msg: PairingMessage = + serde_json::from_slice(&complete_buf).map_err(|e| NetworkingError::Serialization(e))?; + + match complete_msg { + PairingMessage::Complete { + session_id: complete_session_id, + success, + reason, + } => { + self.log_info(&format!( + "Received Complete for session {} - success: {}", + complete_session_id, success + )) + .await; + + // Process completion + let from_device = self.get_device_id_for_node(initiator_node_id).await; + self.handle_completion( + complete_session_id, + success, + reason, + from_device, + initiator_node_id, + ) + .await?; + + Ok(Some(PairingMessage::Complete { + session_id: complete_session_id, + success, + reason: None, + })) + } + _ => Err(NetworkingError::Protocol( + "Expected Complete message".to_string(), + )), + } + } + + pub async fn send_pairing_message_fire_and_forget( + &self, + node_id: NodeId, + message: &PairingMessage, + ) -> Result<()> { + let data = serde_json::to_vec(message).map_err(NetworkingError::Serialization)?; + self.command_sender + .send( + crate::service::network::core::event_loop::EventLoopCommand::SendMessageToNode { + node_id, + protocol: "pairing".to_string(), + data, + }, + ) + .map_err(|_| NetworkingError::Protocol("Pairing command channel closed".to_string()))?; + Ok(()) + } } #[async_trait] @@ -741,6 +2209,9 @@ impl ProtocolHandler for PairingProtocolHandler { PairingMessage::Challenge { .. } => "Challenge", PairingMessage::Response { .. } => "Response", PairingMessage::Complete { .. } => "Complete", + PairingMessage::ProxyPairingRequest { .. } => "ProxyPairingRequest", + PairingMessage::ProxyPairingResponse { .. } => "ProxyPairingResponse", + PairingMessage::ProxyPairingComplete { .. } => "ProxyPairingComplete", }; self.logger .info(&format!( @@ -839,9 +2310,15 @@ impl ProtocolHandler for PairingProtocolHandler { self.handle_pairing_response(from_device, session_id, response, device_info) .await } - // These are handled by handle_response, not handle_request - PairingMessage::Challenge { .. } | PairingMessage::Complete { .. } => { - self.log_warn("Received Challenge or Complete in handle_request - this should be handled by handle_response").await; + PairingMessage::ProxyPairingRequest { .. } + | PairingMessage::ProxyPairingResponse { .. } + | PairingMessage::ProxyPairingComplete { .. } + | PairingMessage::Challenge { .. } + | PairingMessage::Complete { .. } => { + self.log_warn( + "Received message in handle_request - this should be handled by stream", + ) + .await; Ok(Vec::new()) } }; @@ -855,6 +2332,9 @@ impl ProtocolHandler for PairingProtocolHandler { PairingMessage::Challenge { session_id, .. } => Some(session_id), PairingMessage::Response { session_id, .. } => Some(session_id), PairingMessage::Complete { session_id, .. } => Some(session_id), + PairingMessage::ProxyPairingRequest { session_id, .. } => Some(session_id), + PairingMessage::ProxyPairingResponse { session_id, .. } => Some(session_id), + PairingMessage::ProxyPairingComplete { session_id, .. } => Some(session_id), }; if let Some(session_id) = session_id { @@ -1121,9 +2601,12 @@ impl ProtocolHandler for PairingProtocolHandler { self.handle_completion(session_id, success, reason, from_device, from_node) .await?; } - // These are handled by handle_request, not handle_response - PairingMessage::PairingRequest { .. } | PairingMessage::Response { .. } => { - self.log_warn("Received PairingRequest or Response in handle_response - this should be handled by handle_request").await; + PairingMessage::ProxyPairingRequest { .. } + | PairingMessage::ProxyPairingResponse { .. } + | PairingMessage::ProxyPairingComplete { .. } + | PairingMessage::PairingRequest { .. } + | PairingMessage::Response { .. } => { + self.log_warn("Received message in handle_response - this should be handled by handle_request or stream").await; } } diff --git a/core/src/service/network/protocol/pairing/proxy.rs b/core/src/service/network/protocol/pairing/proxy.rs new file mode 100644 index 000000000..7d0e311bb --- /dev/null +++ b/core/src/service/network/protocol/pairing/proxy.rs @@ -0,0 +1,65 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +use crate::service::network::device::{DeviceInfo, SessionKeys}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VouchPayload { + pub vouchee_device_id: Uuid, + pub vouchee_public_key: Vec, + pub vouchee_device_info: DeviceInfo, + pub timestamp: DateTime, + pub session_id: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcceptedDevice { + pub device_info: DeviceInfo, + pub session_keys: SessionKeys, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RejectedDevice { + pub device_id: Uuid, + pub device_name: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VouchingSession { + pub id: Uuid, + pub vouchee_device_id: Uuid, + pub vouchee_device_name: String, + pub voucher_device_id: Uuid, + pub created_at: DateTime, + pub state: VouchingSessionState, + pub vouches: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub enum VouchingSessionState { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VouchState { + pub device_id: Uuid, + pub device_name: String, + pub status: VouchStatus, + pub updated_at: DateTime, + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub enum VouchStatus { + Selected, + Queued, + Waiting, + Accepted, + Rejected, + Unreachable, +} diff --git a/core/src/service/network/protocol/pairing/vouching_queue.rs b/core/src/service/network/protocol/pairing/vouching_queue.rs new file mode 100644 index 000000000..e58d70be1 --- /dev/null +++ b/core/src/service/network/protocol/pairing/vouching_queue.rs @@ -0,0 +1,355 @@ +use std::path::Path; + +use chrono::{DateTime, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::service::network::{ + device::{DeviceInfo, SessionKeys}, + NetworkingError, Result, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum VouchQueueStatus { + Queued, + Waiting, +} + +impl VouchQueueStatus { + fn as_str(&self) -> &'static str { + match self { + Self::Queued => "queued", + Self::Waiting => "waiting", + } + } + + fn from_str(value: &str) -> Self { + match value { + "waiting" => Self::Waiting, + _ => Self::Queued, + } + } +} + +#[derive(Debug, Clone)] +pub struct VouchingQueueEntry { + pub session_id: Uuid, + pub target_device_id: Uuid, + pub voucher_device_id: Uuid, + pub vouchee_device_id: Uuid, + pub vouchee_device_info: DeviceInfo, + pub vouchee_public_key: Vec, + pub voucher_signature: Vec, + pub proxied_session_keys: SessionKeys, + pub created_at: DateTime, + pub expires_at: DateTime, + pub status: VouchQueueStatus, + pub retry_count: u32, + pub last_attempt_at: Option>, +} + +pub struct VouchingQueue { + conn: DatabaseConnection, +} + +impl VouchingQueue { + pub async fn open(data_dir: impl AsRef) -> Result { + let networking_dir = data_dir.as_ref().join("networking"); + // Ensure networking directory exists + tokio::fs::create_dir_all(&networking_dir).await.map_err(|e| { + NetworkingError::Protocol(format!("Failed to create networking directory: {}", e)) + })?; + // Ensure networking directory exists + std::fs::create_dir_all(&networking_dir).map_err(|e| { + NetworkingError::Protocol(format!("Failed to create networking directory: {}", e)) + })?; + + let db_path = networking_dir.join("vouching_queue.db"); + let database_url = format!("sqlite://{}?mode=rwc", db_path.display()); + let conn = Database::connect(&database_url).await.map_err(|e| { + NetworkingError::Protocol(format!("Failed to open vouching queue: {}", e)) + })?; + + Self::init_table(&conn).await?; + + Ok(Self { conn }) + } + + fn serialize(value: &T) -> Result { + serde_json::to_string(value).map_err(NetworkingError::Serialization) + } + + fn deserialize Deserialize<'de>>(value: &str) -> Result { + serde_json::from_str(value).map_err(NetworkingError::Serialization) + } + + async fn init_table(conn: &DatabaseConnection) -> Result<()> { + conn.execute(Statement::from_string( + DbBackend::Sqlite, + r#" + CREATE TABLE IF NOT EXISTS vouching_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + target_device_id TEXT NOT NULL, + voucher_device_id TEXT NOT NULL, + vouchee_device_id TEXT NOT NULL, + vouchee_device_info TEXT NOT NULL, + vouchee_public_key BLOB NOT NULL, + voucher_signature BLOB NOT NULL, + proxied_session_keys TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + status TEXT NOT NULL, + retry_count INTEGER DEFAULT 0, + last_attempt_at TEXT, + + UNIQUE(session_id, target_device_id) + ) + "# + .to_string(), + )) + .await + .map_err(|e| { + NetworkingError::Protocol(format!("Failed to create vouching queue: {}", e)) + })?; + + conn.execute(Statement::from_string( + DbBackend::Sqlite, + "CREATE INDEX IF NOT EXISTS idx_vouching_queue_target ON vouching_queue(target_device_id)" + .to_string(), + )) + .await + .map_err(|e| NetworkingError::Protocol(format!("Failed to index vouching queue: {}", e)))?; + + conn.execute(Statement::from_string( + DbBackend::Sqlite, + "CREATE INDEX IF NOT EXISTS idx_vouching_queue_expires ON vouching_queue(expires_at)" + .to_string(), + )) + .await + .map_err(|e| NetworkingError::Protocol(format!("Failed to index vouching queue: {}", e)))?; + + Ok(()) + } + + pub async fn upsert_entry(&self, entry: &VouchingQueueEntry) -> Result<()> { + self.conn + .execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + r#" + INSERT INTO vouching_queue ( + session_id, + target_device_id, + voucher_device_id, + vouchee_device_id, + vouchee_device_info, + vouchee_public_key, + voucher_signature, + proxied_session_keys, + created_at, + expires_at, + status, + retry_count, + last_attempt_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, target_device_id) DO UPDATE SET + voucher_device_id = excluded.voucher_device_id, + vouchee_device_id = excluded.vouchee_device_id, + vouchee_device_info = excluded.vouchee_device_info, + vouchee_public_key = excluded.vouchee_public_key, + voucher_signature = excluded.voucher_signature, + proxied_session_keys = excluded.proxied_session_keys, + created_at = excluded.created_at, + expires_at = excluded.expires_at, + status = excluded.status, + retry_count = excluded.retry_count, + last_attempt_at = excluded.last_attempt_at + "#, + vec![ + entry.session_id.to_string().into(), + entry.target_device_id.to_string().into(), + entry.voucher_device_id.to_string().into(), + entry.vouchee_device_id.to_string().into(), + Self::serialize(&entry.vouchee_device_info)?.into(), + entry.vouchee_public_key.clone().into(), + entry.voucher_signature.clone().into(), + Self::serialize(&entry.proxied_session_keys)?.into(), + entry.created_at.to_rfc3339().into(), + entry.expires_at.to_rfc3339().into(), + entry.status.as_str().into(), + (entry.retry_count as i64).into(), + entry + .last_attempt_at + .map(|ts| ts.to_rfc3339()) + .unwrap_or_default() + .into(), + ], + )) + .await + .map_err(|e| NetworkingError::Protocol(format!("Failed to upsert vouch: {}", e)))?; + + Ok(()) + } + + pub async fn list_entries(&self) -> Result> { + let rows = self + .conn + .query_all(Statement::from_string( + DbBackend::Sqlite, + r#" + SELECT session_id, target_device_id, voucher_device_id, vouchee_device_id, + vouchee_device_info, vouchee_public_key, voucher_signature, + proxied_session_keys, created_at, expires_at, status, + retry_count, last_attempt_at + FROM vouching_queue + "# + .to_string(), + )) + .await + .map_err(|e| NetworkingError::Protocol(format!("Failed to list vouches: {}", e)))?; + + let mut entries = Vec::new(); + for row in rows { + let session_id: String = row.try_get("", "session_id").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read session_id: {}", e)) + })?; + let target_device_id: String = row.try_get("", "target_device_id").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read target_device_id: {}", e)) + })?; + let voucher_device_id: String = row.try_get("", "voucher_device_id").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read voucher_device_id: {}", e)) + })?; + let vouchee_device_id: String = row.try_get("", "vouchee_device_id").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read vouchee_device_id: {}", e)) + })?; + let vouchee_device_info: String = + row.try_get("", "vouchee_device_info").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read vouchee_device_info: {}", e)) + })?; + let vouchee_public_key: Vec = + row.try_get("", "vouchee_public_key").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read vouchee_public_key: {}", e)) + })?; + let voucher_signature: Vec = row.try_get("", "voucher_signature").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read voucher_signature: {}", e)) + })?; + let proxied_session_keys: String = + row.try_get("", "proxied_session_keys").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read proxied_session_keys: {}", e)) + })?; + let created_at: String = row.try_get("", "created_at").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read created_at: {}", e)) + })?; + let expires_at: String = row.try_get("", "expires_at").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read expires_at: {}", e)) + })?; + let status: String = row + .try_get("", "status") + .map_err(|e| NetworkingError::Protocol(format!("Failed to read status: {}", e)))?; + let retry_count: i64 = row.try_get("", "retry_count").map_err(|e| { + NetworkingError::Protocol(format!("Failed to read retry_count: {}", e)) + })?; + let last_attempt_at: Option = row.try_get("", "last_attempt_at").ok(); + + let entry = VouchingQueueEntry { + session_id: Uuid::parse_str(&session_id) + .map_err(|e| NetworkingError::Protocol(format!("Invalid session_id: {}", e)))?, + target_device_id: Uuid::parse_str(&target_device_id).map_err(|e| { + NetworkingError::Protocol(format!("Invalid target_device_id: {}", e)) + })?, + voucher_device_id: Uuid::parse_str(&voucher_device_id).map_err(|e| { + NetworkingError::Protocol(format!("Invalid voucher_device_id: {}", e)) + })?, + vouchee_device_id: Uuid::parse_str(&vouchee_device_id).map_err(|e| { + NetworkingError::Protocol(format!("Invalid vouchee_device_id: {}", e)) + })?, + vouchee_device_info: Self::deserialize(&vouchee_device_info)?, + vouchee_public_key, + voucher_signature, + proxied_session_keys: Self::deserialize(&proxied_session_keys)?, + created_at: DateTime::parse_from_rfc3339(&created_at) + .map_err(|e| NetworkingError::Protocol(format!("Invalid created_at: {}", e)))? + .with_timezone(&Utc), + expires_at: DateTime::parse_from_rfc3339(&expires_at) + .map_err(|e| NetworkingError::Protocol(format!("Invalid expires_at: {}", e)))? + .with_timezone(&Utc), + status: VouchQueueStatus::from_str(&status), + retry_count: retry_count as u32, + last_attempt_at: last_attempt_at + .and_then(|ts| DateTime::parse_from_rfc3339(&ts).ok()) + .map(|ts| ts.with_timezone(&Utc)), + }; + + entries.push(entry); + } + + Ok(entries) + } + + pub async fn update_status( + &self, + session_id: Uuid, + target_device_id: Uuid, + status: VouchQueueStatus, + retry_count: u32, + last_attempt_at: Option>, + ) -> Result<()> { + self.conn + .execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + r#" + UPDATE vouching_queue + SET status = ?, retry_count = ?, last_attempt_at = ? + WHERE session_id = ? AND target_device_id = ? + "#, + vec![ + status.as_str().into(), + (retry_count as i64).into(), + last_attempt_at + .map(|ts| ts.to_rfc3339()) + .unwrap_or_default() + .into(), + session_id.to_string().into(), + target_device_id.to_string().into(), + ], + )) + .await + .map_err(|e| NetworkingError::Protocol(format!("Failed to update vouch: {}", e)))?; + + Ok(()) + } + + pub async fn remove_entry(&self, session_id: Uuid, target_device_id: Uuid) -> Result<()> { + self.conn + .execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM vouching_queue WHERE session_id = ? AND target_device_id = ?", + vec![ + session_id.to_string().into(), + target_device_id.to_string().into(), + ], + )) + .await + .map_err(|e| NetworkingError::Protocol(format!("Failed to delete vouch: {}", e)))?; + + Ok(()) + } + + pub async fn remove_expired(&self, now: DateTime) -> Result { + let result = self + .conn + .execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM vouching_queue WHERE expires_at <= ?", + vec![now.to_rfc3339().into()], + )) + .await + .map_err(|e| { + NetworkingError::Protocol(format!("Failed to delete expired vouches: {}", e)) + })?; + + Ok(result.rows_affected()) + } +} diff --git a/core/src/service/sync/mod.rs b/core/src/service/sync/mod.rs index 4cbe7b741..c05f418ff 100644 --- a/core/src/service/sync/mod.rs +++ b/core/src/service/sync/mod.rs @@ -392,48 +392,77 @@ impl SyncService { DeviceSyncState::Ready => { // Check for connected partners and catch up if watermarks are outdated + // FIX: Iterate ALL partners and check per-peer watermarks from sync.db match peer_sync.network().get_connected_sync_partners( peer_sync.library_id(), peer_sync.db(), ).await { Ok(partners) if !partners.is_empty() => { - // Check if we need to catch up - let our_device = match entities::device::Entity::find() - .filter(entities::device::Column::Uuid.eq(peer_sync.device_id())) - .one(peer_sync.db().as_ref()) - .await - { - Ok(Some(device)) => device, - Ok(None) => continue, - Err(e) => { - debug!("Failed to query device record: {}", e); - continue; - } - }; - // Check if real-time sync is active (lock mechanism) // If real-time broadcasts are happening, skip catch-up to prevent duplication let realtime_active = peer_sync.is_realtime_active().await; - - // Trigger catch-up if: - // - Real-time is NOT active (60+ seconds since last broadcast), AND - // - We haven't synced recently (fallback time check) - let should_catch_up = if realtime_active { + if realtime_active { debug!("Skipping catch-up - real-time sync is active (lock mechanism)"); - false - } else if let Some(last_sync) = our_device.last_sync_at { - let time_since_sync = chrono::Utc::now().signed_duration_since(last_sync); - time_since_sync.num_seconds() > 60 - } else { - true - }; + continue; + } + + // Iterate each partner individually (FIX: was only checking partners[0]) + for partner_id in partners { + // Query per-peer watermarks from sync.db + let peer_watermarks = peer_sync + .get_all_watermarks_for_peer(partner_id) + .await + .unwrap_or_default(); + + // Determine if we need to sync with this peer + let needs_sync = if peer_watermarks.is_empty() { + // NEW PEER - never synced with them before + info!(peer = %partner_id, "Detected new peer, no watermarks exist - initiating sync"); + true + } else { + // EXISTING PEER - check if watermarks are stale + let oldest_watermark = peer_watermarks.iter() + .map(|(_, ts)| *ts) + .min() + .unwrap(); + + let time_since_sync = chrono::Utc::now().signed_duration_since(oldest_watermark); + + if time_since_sync.num_seconds() > 60 { + debug!( + peer = %partner_id, + time_since_sync_secs = time_since_sync.num_seconds(), + "Peer watermarks stale, needs catch-up" + ); + true + } else { + debug!( + peer = %partner_id, + time_since_sync_secs = time_since_sync.num_seconds(), + "Peer watermarks up to date" + ); + false + } + }; + + if !needs_sync { + continue; // Skip to next partner + } + + // Check if we should retry based on exponential backoff + if !retry_state.should_retry() { + debug!( + peer = %partner_id, + "Skipping sync - in backoff period" + ); + continue; + } - // Check if we should retry based on exponential backoff - if should_catch_up && retry_state.should_retry() { // Check if we should escalate to full backfill after repeated failures if retry_state.should_escalate() { warn!( failures = retry_state.consecutive_failures, + peer = %partner_id, "Too many catch-up failures, escalating to full backfill" ); retry_state.record_success(); // Reset retry state @@ -449,21 +478,24 @@ impl SyncService { "Sync state transition" ); backfill_attempted = false; // Allow backfill to run again - continue; // Skip to next iteration + break; // Exit partner loop, will restart as Uninitialized } - // Get current watermarks from sync.db - let (state_watermark, shared_watermark) = peer_sync.get_watermarks().await; + // Get watermarks for this specific peer (oldest across resource types) + let state_watermark = peer_watermarks.iter() + .map(|(_, ts)| *ts) + .min(); + + // Get shared watermark from sync.db + let (_, shared_watermark) = peer_sync.get_watermarks().await; info!( - "Triggering incremental catch-up since watermarks: state={:?}, shared={:?}", - state_watermark, - shared_watermark + peer = %partner_id, + state_watermark = ?state_watermark, + shared_watermark = ?shared_watermark, + "Triggering incremental catch-up with peer" ); - // Pick first partner for catch-up - let catch_up_peer = partners[0]; - // Transition to CatchingUp state { let old_state = peer_sync.state().await; @@ -472,22 +504,22 @@ impl SyncService { info!( from_state = ?old_state, to_state = ?DeviceSyncState::CatchingUp { buffered_count: 0 }, + peer = %partner_id, reason = "incremental_catchup", "Sync state transition" ); } // Perform incremental catch-up using watermarks - // Convert HLC to string for API let shared_watermark_str = shared_watermark.map(|hlc| hlc.to_string()); match backfill_manager.catch_up_from_peer( - catch_up_peer, + partner_id, state_watermark, shared_watermark_str, ).await { Ok(()) => { - info!("Incremental catch-up completed"); + info!(peer = %partner_id, "Incremental catch-up completed"); retry_state.record_success(); // Transition back to Ready let old_state = peer_sync.state().await; @@ -496,12 +528,13 @@ impl SyncService { info!( from_state = ?old_state, to_state = ?DeviceSyncState::Ready, + peer = %partner_id, reason = "catchup_completed", "Sync state transition" ); } Err(e) => { - warn!("Incremental catch-up failed: {}", e); + warn!(peer = %partner_id, error = %e, "Incremental catch-up failed"); retry_state.record_failure(); // Transition back to Ready even on error let old_state = peer_sync.state().await; @@ -510,11 +543,16 @@ impl SyncService { info!( from_state = ?old_state, to_state = ?DeviceSyncState::Ready, + peer = %partner_id, reason = "catchup_failed_but_continuing", "Sync state transition" ); } } + + // Only process one peer per iteration to avoid overwhelming + // Other peers will be caught up in subsequent loop iterations + break; } } Ok(_) => {} diff --git a/core/src/service/sync/peer.rs b/core/src/service/sync/peer.rs index 9ce7661d3..57a037897 100644 --- a/core/src/service/sync/peer.rs +++ b/core/src/service/sync/peer.rs @@ -396,21 +396,19 @@ impl PeerSync { Ok(()) } - /// Mark backfill complete by updating last_sync_at and persisting shared watermark + /// Mark backfill complete by persisting shared watermark /// /// Note: Per-resource watermarks are now tracked automatically as data is received. - /// This method updates the last_sync_at timestamp and persists the shared watermark. + /// This method persists the shared watermark (HLC) to survive restarts. + /// The legacy last_sync_at column is no longer written - per-peer watermarks in + /// sync.db are the source of truth for sync progress. pub async fn set_initial_watermarks( &self, peer: Uuid, _final_state_checkpoint: Option, max_shared_hlc: Option, ) -> Result<()> { - use crate::infra::db::entities; use crate::infra::sync::PeerWatermarkStore; - use sea_orm::{ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter}; - - let now = chrono::Utc::now(); // Update shared watermark (HLC generator) if we received shared data if let Some(hlc) = max_shared_hlc { @@ -433,24 +431,13 @@ impl PeerSync { ); } - // Update last_sync_at to mark backfill complete - let device = entities::device::Entity::find() - .filter(entities::device::Column::Uuid.eq(self.device_id)) - .one(self.db.as_ref()) - .await - .map_err(|e| anyhow::anyhow!("Failed to query device: {}", e))? - .ok_or_else(|| anyhow::anyhow!("Device not found: {}", self.device_id))?; - - let mut device_active: entities::device::ActiveModel = device.into(); - device_active.last_sync_at = Set(Some(now)); - - device_active - .update(self.db.as_ref()) - .await - .map_err(|e| anyhow::anyhow!("Failed to update last_sync_at: {}", e))?; + // NOTE: last_sync_at is no longer written here. + // Per-peer watermarks in sync.db (device_resource_watermarks table) are the + // authoritative source of sync progress. This fixes the 3-device sync bug + // where a global last_sync_at timestamp couldn't detect NEW peers. info!( - last_sync_at = %now, + peer = %peer, "Backfill complete, per-resource watermarks tracked in sync.db" ); diff --git a/core/src/testing/integration_utils.rs b/core/src/testing/integration_utils.rs index 1b1a011cf..c6ec0244a 100644 --- a/core/src/testing/integration_utils.rs +++ b/core/src/testing/integration_utils.rs @@ -87,7 +87,7 @@ pub struct TestEnvironment { impl TestEnvironment { /// Create a new test environment with the given name - pub fn new(test_name: impl Into) -> Result> { + pub fn new(test_name: impl Into) -> Result> { let test_name = test_name.into(); let test_root = PathBuf::from("test_data"); let test_data_dir = test_root.join(&test_name); @@ -107,7 +107,7 @@ impl TestEnvironment { } /// Clean the test environment (remove all data) - pub fn clean(&self) -> Result<(), Box> { + pub fn clean(&self) -> Result<(), Box> { if self.test_data_dir.exists() { std::fs::remove_dir_all(&self.test_data_dir)?; info!("Cleaned test environment: {}", self.test_data_dir.display()); @@ -220,11 +220,12 @@ impl TestConfigBuilder { statistics_listener_enabled: self.statistics_listener_enabled, }, logging: crate::config::app_config::LoggingConfig::default(), + proxy_pairing: crate::config::app_config::ProxyPairingConfig::default(), } } /// Build and save the AppConfig to the data directory - pub async fn build_and_save(self) -> Result> { + pub async fn build_and_save(self) -> Result> { let config = self.build(); // Ensure the data directory exists @@ -259,9 +260,9 @@ impl TestConfigBuilder { pub fn initialize_test_tracing( test_env: &TestEnvironment, rust_log_override: Option<&str>, -) -> Result<(), Box> { +) -> Result<(), Box> { static INIT: Once = Once::new(); - let mut result: Result<(), Box> = Ok(()); + let mut result: Result<(), Box> = Ok(()); INIT.call_once(|| { // Set up environment filter with detailed logging for tests @@ -317,7 +318,7 @@ pub struct IntegrationTestSetup { impl IntegrationTestSetup { /// Create a new integration test setup with default configuration - pub async fn new(test_name: impl Into) -> Result> { + pub async fn new(test_name: impl Into) -> Result> { let environment = TestEnvironment::new(test_name)?; // Clean any existing data @@ -344,7 +345,7 @@ impl IntegrationTestSetup { pub async fn with_config( test_name: impl Into, config_builder: F, - ) -> Result> + ) -> Result> where F: FnOnce(TestConfigBuilder) -> TestConfigBuilder, { @@ -373,7 +374,7 @@ impl IntegrationTestSetup { pub async fn with_tracing( test_name: impl Into, rust_log_override: &str, - ) -> Result> { + ) -> Result> { let environment = TestEnvironment::new(test_name)?; // Clean any existing data @@ -410,7 +411,7 @@ impl IntegrationTestSetup { /// /// This method ensures that the custom AppConfig settings from the test setup /// are properly applied when initializing the Core. - pub async fn create_core(&self) -> Result> { + pub async fn create_core(&self) -> Result> { info!( "Creating Core with test configuration from: {}", self.data_dir().display() @@ -447,7 +448,7 @@ impl IntegrationTestSetup { } /// Clean up the test environment - pub fn cleanup(self) -> Result<(), Box> { + pub fn cleanup(self) -> Result<(), Box> { self.environment.clean() } } diff --git a/core/tests/file_sync_test.rs b/core/tests/file_sync_test.rs index 3cf2d39e7..2a5c06922 100644 --- a/core/tests/file_sync_test.rs +++ b/core/tests/file_sync_test.rs @@ -50,21 +50,22 @@ impl FileSyncTestSetup { let temp_dir = TempDir::new()?; - let config = sd_core::config::AppConfig { - version: 3, - data_dir: temp_dir.path().to_path_buf(), - log_level: "info".to_string(), - telemetry_enabled: false, - preferences: sd_core::config::Preferences::default(), - job_logging: sd_core::config::JobLoggingConfig::default(), - services: sd_core::config::ServiceConfig { - networking_enabled: false, - volume_monitoring_enabled: false, - fs_watcher_enabled: false, - statistics_listener_enabled: false, - }, - logging: sd_core::config::LoggingConfig::default(), - }; + let config = sd_core::config::AppConfig { + version: 3, + data_dir: temp_dir.path().to_path_buf(), + log_level: "info".to_string(), + telemetry_enabled: false, + preferences: sd_core::config::Preferences::default(), + job_logging: sd_core::config::JobLoggingConfig::default(), + services: sd_core::config::ServiceConfig { + networking_enabled: false, + volume_monitoring_enabled: false, + fs_watcher_enabled: false, + statistics_listener_enabled: false, + }, + logging: sd_core::config::LoggingConfig::default(), + proxy_pairing: sd_core::config::app_config::ProxyPairingConfig::default(), + }; config.save()?; let core = Core::new(temp_dir.path().to_path_buf()) diff --git a/core/tests/helpers/sync_harness.rs b/core/tests/helpers/sync_harness.rs index 20052e0f0..dd04e8ec1 100644 --- a/core/tests/helpers/sync_harness.rs +++ b/core/tests/helpers/sync_harness.rs @@ -71,6 +71,7 @@ impl TestConfigBuilder { fs_watcher_enabled: false, statistics_listener_enabled: false, }, + proxy_pairing: sd_core::config::app_config::ProxyPairingConfig::default(), }; config.save()?; @@ -150,7 +151,6 @@ pub async fn register_device( created_at: Set(Utc::now()), updated_at: Set(Utc::now()), sync_enabled: Set(true), - last_sync_at: Set(None), }; // Check if device already exists @@ -212,19 +212,22 @@ pub async fn create_test_volume( } /// Set all devices in a library to "synced" state (prevents auto-backfill) -pub async fn set_all_devices_synced(library: &Arc) -> anyhow::Result<()> { - use chrono::Utc; - use sea_orm::ActiveValue; - - for device in entities::device::Entity::find() - .all(library.db().conn()) - .await? - { - let mut active: entities::device::ActiveModel = device.into(); - active.last_sync_at = ActiveValue::Set(Some(Utc::now())); - active.update(library.db().conn()).await?; - } - +/// +/// NOTE: This function is now a no-op for the legacy last_sync_at column. +/// The Ready state logic now uses per-peer watermarks in sync.db instead. +/// For tests that need to prevent auto-backfill, set sync state directly to Ready +/// via `peer_sync.set_state_for_test(DeviceSyncState::Ready)` and create +/// watermark entries using the ResourceWatermarkStore. +#[deprecated( + note = "last_sync_at is no longer used for sync decisions. Use set_peer_watermarks_for_test instead." +)] +pub async fn set_all_devices_synced(_library: &Arc) -> anyhow::Result<()> { + // No-op: last_sync_at is no longer written or read. + // Per-peer watermarks in sync.db are now the source of truth. + // Tests should use set_peer_watermarks_for_test() to create watermarks. + tracing::warn!( + "set_all_devices_synced is deprecated - last_sync_at is no longer used for sync decisions" + ); Ok(()) } @@ -465,7 +468,9 @@ pub async fn add_and_index_location( path: &str, name: &str, ) -> anyhow::Result { - use sd_core::location::{create_location, manager::update_location_volume_id, IndexMode, LocationCreateArgs}; + use sd_core::location::{ + create_location, manager::update_location_volume_id, IndexMode, LocationCreateArgs, + }; tracing::info!(path = %path, name = %name, "Creating location and indexing"); @@ -533,13 +538,7 @@ pub async fn add_and_index_location( }; // Update location and root entry with volume_id - update_location_volume_id( - library.db().conn(), - location_db_id, - entry_id, - volume_id, - ) - .await?; + update_location_volume_id(library.db().conn(), location_db_id, entry_id, volume_id).await?; tracing::info!( location_uuid = %location_uuid, @@ -915,8 +914,11 @@ impl TwoDeviceHarnessBuilder { register_device(&library_alice, device_bob_id, "Bob").await?; register_device(&library_bob, device_alice_id, "Alice").await?; - // Set last_sync_at to prevent auto-backfill + // NOTE: set_all_devices_synced is deprecated - auto-backfill is now controlled + // by per-peer watermarks in sync.db, not last_sync_at + #[allow(deprecated)] set_all_devices_synced(&library_alice).await?; + #[allow(deprecated)] set_all_devices_synced(&library_bob).await?; tracing::info!( diff --git a/core/tests/location_export_import_test.rs b/core/tests/location_export_import_test.rs index 5b2e644ac..cae6f6085 100644 --- a/core/tests/location_export_import_test.rs +++ b/core/tests/location_export_import_test.rs @@ -111,7 +111,7 @@ async fn wait_for_indexing_stable( } #[tokio::test] -async fn test_location_export_import() -> Result<(), Box> { +async fn test_location_export_import() -> Result<(), Box> { // Setup test directories let temp_dir = TempDir::new()?; let core_dir = temp_dir.path().join("core"); @@ -372,7 +372,7 @@ async fn test_location_export_import() -> Result<(), Box> } #[tokio::test] -async fn test_export_nonexistent_location() -> Result<(), Box> { +async fn test_export_nonexistent_location() -> Result<(), Box> { let temp_dir = TempDir::new()?; let core_dir = temp_dir.path().join("core"); let export_file = temp_dir.path().join("export.sql"); @@ -425,7 +425,7 @@ async fn test_export_nonexistent_location() -> Result<(), Box Result<(), Box> { +async fn test_import_invalid_file() -> Result<(), Box> { let temp_dir = TempDir::new()?; let core_dir = temp_dir.path().join("core"); let invalid_file = temp_dir.path().join("invalid.sql"); @@ -475,7 +475,7 @@ async fn test_import_invalid_file() -> Result<(), Box> { } #[tokio::test] -async fn test_import_links_existing_content_identities() -> Result<(), Box> { +async fn test_import_links_existing_content_identities() -> Result<(), Box> { // This test verifies that when importing a location that has content matching // existing content_identities in the destination library, the entries link to // the existing content_identities rather than creating duplicates. diff --git a/core/tests/proxy_pairing_protocol_test.rs b/core/tests/proxy_pairing_protocol_test.rs new file mode 100644 index 000000000..8e172e6e3 --- /dev/null +++ b/core/tests/proxy_pairing_protocol_test.rs @@ -0,0 +1,312 @@ +//! Unit tests for proxy pairing protocol components +//! +//! These tests focus on the protocol-level functionality without requiring +//! full Core instances. + +use chrono::Utc; +use sd_core::service::network::device::{DeviceInfo, PairingType, SessionKeys}; +use sd_core::service::network::protocol::pairing::{ + VouchPayload, VouchState, VouchStatus, VouchingSession, VouchingSessionState, +}; +use uuid::Uuid; + +#[test] +fn test_vouching_session_creation() { + let session_id = Uuid::new_v4(); + let vouchee_device_id = Uuid::new_v4(); + let voucher_device_id = Uuid::new_v4(); + + let session = VouchingSession { + id: session_id, + vouchee_device_id, + vouchee_device_name: "Test Device".to_string(), + voucher_device_id, + created_at: Utc::now(), + state: VouchingSessionState::Pending, + vouches: vec![], + }; + + assert_eq!(session.id, session_id); + assert_eq!(session.vouchee_device_id, vouchee_device_id); + assert_eq!(session.voucher_device_id, voucher_device_id); + assert!(matches!(session.state, VouchingSessionState::Pending)); + assert_eq!(session.vouches.len(), 0); +} + +#[test] +fn test_vouch_state_lifecycle() { + let device_id = Uuid::new_v4(); + + // Start as Selected + let mut vouch = VouchState { + device_id, + device_name: "Target Device".to_string(), + status: VouchStatus::Selected, + updated_at: Utc::now(), + reason: None, + }; + + assert!(matches!(vouch.status, VouchStatus::Selected)); + + // Move to Queued (offline device) + vouch.status = VouchStatus::Queued; + vouch.updated_at = Utc::now(); + assert!(matches!(vouch.status, VouchStatus::Queued)); + + // Move to Waiting (vouch sent) + vouch.status = VouchStatus::Waiting; + vouch.updated_at = Utc::now(); + assert!(matches!(vouch.status, VouchStatus::Waiting)); + + // Final state: Accepted + vouch.status = VouchStatus::Accepted; + vouch.updated_at = Utc::now(); + assert!(matches!(vouch.status, VouchStatus::Accepted)); + assert_eq!(vouch.reason, None); +} + +#[test] +fn test_vouch_rejection_with_reason() { + let device_id = Uuid::new_v4(); + + let vouch = VouchState { + device_id, + device_name: "Target Device".to_string(), + status: VouchStatus::Rejected, + updated_at: Utc::now(), + reason: Some("User rejected proxy pairing".to_string()), + }; + + assert!(matches!(vouch.status, VouchStatus::Rejected)); + assert_eq!( + vouch.reason.as_ref().unwrap(), + "User rejected proxy pairing" + ); +} + +#[test] +fn test_vouching_session_with_multiple_vouches() { + let session_id = Uuid::new_v4(); + let vouchee_device_id = Uuid::new_v4(); + let voucher_device_id = Uuid::new_v4(); + + let device1 = Uuid::new_v4(); + let device2 = Uuid::new_v4(); + let device3 = Uuid::new_v4(); + + let mut session = VouchingSession { + id: session_id, + vouchee_device_id, + vouchee_device_name: "New Device".to_string(), + voucher_device_id, + created_at: Utc::now(), + state: VouchingSessionState::InProgress, + vouches: vec![ + VouchState { + device_id: device1, + device_name: "Device 1".to_string(), + status: VouchStatus::Accepted, + updated_at: Utc::now(), + reason: None, + }, + VouchState { + device_id: device2, + device_name: "Device 2".to_string(), + status: VouchStatus::Waiting, + updated_at: Utc::now(), + reason: None, + }, + VouchState { + device_id: device3, + device_name: "Device 3".to_string(), + status: VouchStatus::Queued, + updated_at: Utc::now(), + reason: None, + }, + ], + }; + + assert_eq!(session.vouches.len(), 3); + + // Count vouches by status + let accepted = session + .vouches + .iter() + .filter(|v| matches!(v.status, VouchStatus::Accepted)) + .count(); + let waiting = session + .vouches + .iter() + .filter(|v| matches!(v.status, VouchStatus::Waiting)) + .count(); + let queued = session + .vouches + .iter() + .filter(|v| matches!(v.status, VouchStatus::Queued)) + .count(); + + assert_eq!(accepted, 1); + assert_eq!(waiting, 1); + assert_eq!(queued, 1); + + // Mark session as completed when all vouches are in terminal state + session.vouches[1].status = VouchStatus::Accepted; + session.vouches[2].status = VouchStatus::Rejected; + session.vouches[2].reason = Some("Offline".to_string()); + + let all_terminal = session.vouches.iter().all(|v| { + matches!( + v.status, + VouchStatus::Accepted | VouchStatus::Rejected | VouchStatus::Unreachable + ) + }); + + assert!(all_terminal); + session.state = VouchingSessionState::Completed; + assert!(matches!( + session.state, + VouchingSessionState::Completed + )); +} + +#[test] +fn test_vouch_payload_structure() { + let session_id = Uuid::new_v4(); + let vouchee_device_id = Uuid::new_v4(); + let vouchee_public_key = vec![1, 2, 3, 4, 5]; + + let device_info = DeviceInfo { + device_id: vouchee_device_id, + device_name: "Test Device".to_string(), + device_slug: "test-device".to_string(), + os_version: "Test OS 1.0".to_string(), + app_version: "1.0.0".to_string(), + network_fingerprint: sd_core::service::network::utils::identity::NetworkFingerprint { + node_id: "test_node_id".to_string(), + public_key: vouchee_public_key.clone(), + }, + last_seen: Utc::now(), + }; + + let timestamp = Utc::now(); + + let payload = VouchPayload { + vouchee_device_id, + vouchee_public_key: vouchee_public_key.clone(), + vouchee_device_info: device_info.clone(), + timestamp, + session_id, + }; + + assert_eq!(payload.vouchee_device_id, vouchee_device_id); + assert_eq!(payload.vouchee_public_key, vouchee_public_key); + assert_eq!(payload.vouchee_device_info.device_id, vouchee_device_id); + assert_eq!(payload.session_id, session_id); +} + +#[test] +fn test_pairing_type_serialization() { + use serde_json; + + // Test Direct pairing type + let direct = PairingType::Direct; + let direct_json = serde_json::to_string(&direct).unwrap(); + let direct_deserialized: PairingType = serde_json::from_str(&direct_json).unwrap(); + assert!(matches!(direct_deserialized, PairingType::Direct)); + + // Test Proxied pairing type + let proxied = PairingType::Proxied; + let proxied_json = serde_json::to_string(&proxied).unwrap(); + let proxied_deserialized: PairingType = serde_json::from_str(&proxied_json).unwrap(); + assert!(matches!(proxied_deserialized, PairingType::Proxied)); +} + +#[test] +fn test_vouching_session_state_transitions() { + let mut session = VouchingSession { + id: Uuid::new_v4(), + vouchee_device_id: Uuid::new_v4(), + vouchee_device_name: "Test Device".to_string(), + voucher_device_id: Uuid::new_v4(), + created_at: Utc::now(), + state: VouchingSessionState::Pending, + vouches: vec![], + }; + + // Start as Pending + assert!(matches!( + session.state, + VouchingSessionState::Pending + )); + + // Transition to InProgress when vouching starts + session.state = VouchingSessionState::InProgress; + assert!(matches!( + session.state, + VouchingSessionState::InProgress + )); + + // Transition to Completed when all vouches are processed + session.state = VouchingSessionState::Completed; + assert!(matches!( + session.state, + VouchingSessionState::Completed + )); +} + +#[test] +fn test_session_keys_for_proxy_pairing() { + // Simulate session keys derived for proxy pairing + let shared_secret = vec![42u8; 32]; + let session_keys = SessionKeys::from_shared_secret(shared_secret); + + assert_eq!(session_keys.send_key.len(), 32); + assert_eq!(session_keys.receive_key.len(), 32); + assert_ne!(session_keys.send_key, session_keys.receive_key); +} + +#[test] +fn test_vouch_status_enum_values() { + // Ensure all VouchStatus variants are constructible + let _selected = VouchStatus::Selected; + let _queued = VouchStatus::Queued; + let _waiting = VouchStatus::Waiting; + let _accepted = VouchStatus::Accepted; + let _rejected = VouchStatus::Rejected; + let _unreachable = VouchStatus::Unreachable; + + // Test that we can match on terminal states + let terminal_statuses = vec![ + VouchStatus::Accepted, + VouchStatus::Rejected, + VouchStatus::Unreachable, + ]; + + for status in terminal_statuses { + assert!(matches!( + status, + VouchStatus::Accepted | VouchStatus::Rejected | VouchStatus::Unreachable + )); + } +} + +#[test] +fn test_vouching_session_cleanup_timing() { + let session = VouchingSession { + id: Uuid::new_v4(), + vouchee_device_id: Uuid::new_v4(), + vouchee_device_name: "Test Device".to_string(), + voucher_device_id: Uuid::new_v4(), + created_at: Utc::now(), + state: VouchingSessionState::Completed, + vouches: vec![], + }; + + // Sessions should be cleaned up 1 hour after completion + // This test just verifies the structure supports timing-based cleanup + let cleanup_delay = chrono::Duration::hours(1); + let cleanup_time = session.created_at + cleanup_delay; + + assert!(cleanup_time > session.created_at); + assert!(cleanup_time > Utc::now() || session.created_at < Utc::now() - cleanup_delay); +} diff --git a/core/tests/proxy_pairing_test.rs b/core/tests/proxy_pairing_test.rs new file mode 100644 index 000000000..33f9c6df9 --- /dev/null +++ b/core/tests/proxy_pairing_test.rs @@ -0,0 +1,620 @@ +//! Proxy pairing test using the cargo test subprocess framework +//! +//! This is a STRICT test that demonstrates proxy/vouching-based pairing: +//! 1. Alice pairs with Carol (direct pairing) - VERIFIED +//! 2. Alice pairs with Bob (direct pairing) - VERIFIED +//! 3. Alice auto-vouches Bob to Carol (proxy pairing) - VERIFIED +//! 4. Carol auto-accepts the vouch - VERIFIED +//! 5. Bob receives ProxyPairingComplete - VERIFIED +//! +//! The test FAILS if: +//! - Bob does not appear in Carol's paired devices list (Carol panics) +//! - Carol does not appear in Bob's paired devices list (Bob panics) +//! - Any device times out waiting for proxy pairing (30 second limit) +//! +//! Config: auto_vouch_to_all=true, auto_accept_vouched=true + +use sd_core::testing::CargoTestRunner; +use sd_core::Core; +use std::env; +use std::path::PathBuf; +use std::time::Duration; +use tokio::time::timeout; + +const TEST_DIR: &str = "/tmp/spacedrive-proxy-pairing-test"; + +/// Alice's scenario - pairs with Carol first, then Bob, then vouches Bob to Carol +#[tokio::test] +#[ignore] +async fn alice_proxy_pairing_scenario() { + if env::var("TEST_ROLE").unwrap_or_default() != "alice" { + return; + } + + env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR); + let data_dir = PathBuf::from(format!("{}/alice", TEST_DIR)); + let device_name = "Alice's Test Device"; + + println!("Alice: Starting proxy pairing test"); + println!("Alice: Data dir: {:?}", data_dir); + + // Initialize Core + println!("Alice: Initializing Core..."); + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + println!("Alice: Core initialized"); + + core.device.set_name(device_name.to_string()).unwrap(); + + // Enable auto-vouch for testing + println!("Alice: Enabling auto-vouch for testing..."); + { + let mut config = core.config.write().await; + config.proxy_pairing.auto_vouch_to_all = true; + config.save().unwrap(); + } + println!("Alice: Auto-vouch enabled"); + + // Initialize networking + println!("Alice: Initializing networking..."); + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + tokio::time::sleep(Duration::from_secs(3)).await; + println!("Alice: Networking initialized"); + + // Phase 1: Pair with Carol + println!("\n=== PHASE 1: Alice pairs with Carol ==="); + let (pairing_code_carol, _) = if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_initiator(false), + ) + .await + .unwrap() + .unwrap() + } else { + panic!("Networking not initialized"); + }; + + println!("Alice: Pairing code for Carol: {}", pairing_code_carol); + std::fs::write( + format!("{}/pairing_code_carol.txt", TEST_DIR), + &pairing_code_carol, + ) + .unwrap(); + println!("Alice: Waiting for Carol to pair..."); + + // Wait for Carol to pair + let mut attempts = 0; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let paired_devices = if let Some(networking) = core.networking() { + networking.device_registry().read().await.get_paired_devices() + } else { + vec![] + }; + if !paired_devices.is_empty() { + println!("Alice: Carol paired successfully!"); + for device in &paired_devices { + println!( + "Alice sees: {} (ID: {})", + device.device_name, device.device_id + ); + } + break; + } + attempts += 1; + if attempts >= 45 { + panic!("Alice: Timeout waiting for Carol"); + } + } + + // Signal Carol pairing complete + std::fs::write(format!("{}/alice_carol_paired.txt", TEST_DIR), "success").unwrap(); + + // Phase 2: Pair with Bob + println!("\n=== PHASE 2: Alice pairs with Bob ==="); + tokio::time::sleep(Duration::from_secs(2)).await; + + let (pairing_code_bob, _) = if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_initiator(false), + ) + .await + .unwrap() + .unwrap() + } else { + panic!("Networking not initialized"); + }; + + println!("Alice: Pairing code for Bob: {}", pairing_code_bob); + std::fs::write( + format!("{}/pairing_code_bob.txt", TEST_DIR), + &pairing_code_bob, + ) + .unwrap(); + println!("Alice: Waiting for Bob to pair..."); + + // Wait for Bob to pair + attempts = 0; + let mut bob_device_id = None; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let paired_devices = if let Some(networking) = core.networking() { + networking.device_registry().read().await.get_paired_devices() + } else { + vec![] + }; + if paired_devices.len() >= 2 { + println!("Alice: Bob paired successfully!"); + for device in &paired_devices { + println!( + "Alice sees: {} (ID: {})", + device.device_name, device.device_id + ); + if device.device_name == "Bob's Test Device" { + bob_device_id = Some(device.device_id); + } + } + break; + } + attempts += 1; + if attempts >= 45 { + panic!("Alice: Timeout waiting for Bob"); + } + } + + let bob_id = bob_device_id.expect("Bob's device ID not found"); + println!("Alice: Bob's device ID: {}", bob_id); + + // Signal Bob pairing complete + std::fs::write(format!("{}/alice_bob_paired.txt", TEST_DIR), "success").unwrap(); + + // Phase 3: Vouch Bob to Carol + println!("\n=== PHASE 3: Alice vouches Bob to Carol ==="); + tokio::time::sleep(Duration::from_secs(2)).await; + + // Get Carol's device ID + let paired_devices = if let Some(networking) = core.networking() { + networking.device_registry().read().await.get_paired_devices() + } else { + vec![] + }; + let carol_id = paired_devices + .iter() + .find(|d| d.device_name == "Carol's Test Device") + .map(|d| d.device_id) + .expect("Carol not found"); + + println!( + "Alice: Vouching Bob (ID: {}) to Carol (ID: {})", + bob_id, carol_id + ); + + // With auto_vouch_to_all enabled, Alice should automatically send + // ProxyPairingRequest to Carol after pairing with Bob + println!("Alice: Auto-vouch enabled - ProxyPairingRequest should be sent to Carol"); + println!("Alice: Carol should auto-accept and pair with Bob via proxy"); + + // Write marker that vouching is ready + std::fs::write(format!("{}/alice_vouching_ready.txt", TEST_DIR), "success").unwrap(); + + // Wait for vouching to complete + println!("Alice: Waiting for vouching to complete..."); + tokio::time::sleep(Duration::from_secs(15)).await; + + // Write success marker + std::fs::write(format!("{}/alice_success.txt", TEST_DIR), "success").unwrap(); + println!("Alice: Test completed successfully"); + + // Keep Alice alive for a bit longer + tokio::time::sleep(Duration::from_secs(5)).await; +} + +/// Carol's scenario - pairs with Alice first, then accepts vouch for Bob +#[tokio::test] +#[ignore] +async fn carol_proxy_pairing_scenario() { + if env::var("TEST_ROLE").unwrap_or_default() != "carol" { + return; + } + + env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR); + let data_dir = PathBuf::from(format!("{}/carol", TEST_DIR)); + let device_name = "Carol's Test Device"; + + println!("Carol: Starting proxy pairing test"); + println!("Carol: Data dir: {:?}", data_dir); + + // Initialize Core + println!("Carol: Initializing Core..."); + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + println!("Carol: Core initialized"); + + core.device.set_name(device_name.to_string()).unwrap(); + + // Verify auto-accept is enabled (it's true by default) + println!("Carol: Auto-accept proxy pairing enabled (default)"); + + // Initialize networking + println!("Carol: Initializing networking..."); + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + tokio::time::sleep(Duration::from_secs(3)).await; + println!("Carol: Networking initialized"); + + // Phase 1: Pair with Alice + println!("\n=== PHASE 1: Carol pairs with Alice ==="); + println!("Carol: Waiting for pairing code from Alice..."); + let pairing_code = loop { + if let Ok(code) = std::fs::read_to_string(format!("{}/pairing_code_carol.txt", TEST_DIR)) { + break code.trim().to_string(); + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + println!("Carol: Found pairing code"); + + // Join pairing with Alice + if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_joiner(&pairing_code, false), + ) + .await + .unwrap() + .unwrap(); + } + println!("Carol: Joined pairing with Alice"); + + // Wait for pairing completion + let mut attempts = 0; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let paired_devices = if let Some(networking) = core.networking() { + networking.device_registry().read().await.get_paired_devices() + } else { + vec![] + }; + if !paired_devices.is_empty() { + println!("Carol: Paired with Alice successfully!"); + for device in &paired_devices { + println!( + "Carol sees: {} (ID: {})", + device.device_name, device.device_id + ); + } + break; + } + attempts += 1; + if attempts >= 30 { + panic!("Carol: Timeout pairing with Alice"); + } + } + + tokio::time::sleep(Duration::from_secs(5)).await; + + // Phase 2: Wait for proxy pairing from Alice vouching for Bob + println!("\n=== PHASE 2: Carol receives proxy pairing for Bob ==="); + println!("Carol: Waiting for Alice to vouch Bob..."); + + // Wait for vouching to be ready + loop { + if std::fs::read_to_string(format!("{}/alice_vouching_ready.txt", TEST_DIR)).is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + println!("Carol: Alice has initiated vouching"); + + // Carol should receive ProxyPairingRequest from Alice + // With auto_accept_vouched=true, Carol will automatically accept and pair with Bob + println!("Carol: Should receive ProxyPairingRequest and auto-accept"); + println!("Carol: Waiting for Bob to appear in paired devices..."); + + // Wait for Bob to be paired via proxy + attempts = 0; + let mut bob_found = false; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let paired_devices = if let Some(networking) = core.networking() { + networking.device_registry().read().await.get_paired_devices() + } else { + vec![] + }; + + // Look for Bob in paired devices + for device in &paired_devices { + if device.device_name == "Bob's Test Device" { + println!("Carol: ✅ Bob found via proxy! (ID: {})", device.device_id); + println!("Carol: Proxy pairing verified - Bob is in paired devices list"); + bob_found = true; + break; + } + } + + if bob_found { + break; + } + + attempts += 1; + if attempts >= 30 { + println!("\n❌ CAROL TEST FAILURE"); + println!("Bob was NOT found in Carol's paired devices after 30 seconds"); + println!("\nActual: {} device(s) found:", paired_devices.len()); + for device in &paired_devices { + println!(" - {} ({})", device.device_name, device.device_id); + } + panic!("Carol: FAILED - Bob not found via proxy pairing"); + } + + if attempts % 5 == 0 { + println!("Carol: Waiting for Bob proxy pairing... ({}/30)", attempts); + } + } + + // Only write success if Bob was actually found via proxy + if !bob_found { + panic!("Carol: FAILED - Proxy pairing check failed"); + } + + println!("Carol: ✅ Proxy pairing SUCCESS - Bob paired via vouching!"); + std::fs::write(format!("{}/carol_success.txt", TEST_DIR), "success").unwrap(); + println!("Carol: Test completed"); + + tokio::time::sleep(Duration::from_secs(5)).await; +} + +/// Bob's scenario - pairs with Alice, then gets vouched to Carol +#[tokio::test] +#[ignore] +async fn bob_proxy_pairing_scenario() { + if env::var("TEST_ROLE").unwrap_or_default() != "bob" { + return; + } + + env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR); + let data_dir = PathBuf::from(format!("{}/bob", TEST_DIR)); + let device_name = "Bob's Test Device"; + + println!("Bob: Starting proxy pairing test"); + println!("Bob: Data dir: {:?}", data_dir); + + // Initialize Core + println!("Bob: Initializing Core..."); + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + println!("Bob: Core initialized"); + + core.device.set_name(device_name.to_string()).unwrap(); + + // Initialize networking + println!("Bob: Initializing networking..."); + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + tokio::time::sleep(Duration::from_secs(3)).await; + println!("Bob: Networking initialized"); + + // Wait for Alice to pair with Carol first + println!("\n=== PHASE 1: Bob waits for Alice-Carol pairing ==="); + println!("Bob: Waiting for Alice to pair with Carol..."); + loop { + if std::fs::read_to_string(format!("{}/alice_carol_paired.txt", TEST_DIR)).is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + println!("Bob: Alice and Carol are paired"); + + // Phase 2: Pair with Alice + println!("\n=== PHASE 2: Bob pairs with Alice ==="); + println!("Bob: Waiting for pairing code from Alice..."); + let pairing_code = loop { + if let Ok(code) = std::fs::read_to_string(format!("{}/pairing_code_bob.txt", TEST_DIR)) { + break code.trim().to_string(); + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + println!("Bob: Found pairing code"); + + // Join pairing with Alice + if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_joiner(&pairing_code, false), + ) + .await + .unwrap() + .unwrap(); + } + println!("Bob: Joined pairing with Alice"); + + // Wait for pairing completion + let mut attempts = 0; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let paired_devices = if let Some(networking) = core.networking() { + networking.device_registry().read().await.get_paired_devices() + } else { + vec![] + }; + if !paired_devices.is_empty() { + println!("Bob: Paired with Alice successfully!"); + for device in &paired_devices { + println!( + "Bob sees: {} (ID: {})", + device.device_name, device.device_id + ); + } + break; + } + attempts += 1; + if attempts >= 30 { + panic!("Bob: Timeout pairing with Alice"); + } + } + + tokio::time::sleep(Duration::from_secs(5)).await; + + // Phase 3: Wait to be vouched to Carol + println!("\n=== PHASE 3: Bob receives proxy pairing confirmation ==="); + println!("Bob: Waiting for Alice to vouch to Carol..."); + + // Bob should receive ProxyPairingComplete message from Alice + // containing Carol's acceptance, then store Carol as a proxied paired device + println!("Bob: Should receive ProxyPairingComplete with Carol's acceptance"); + println!("Bob: Waiting for Carol to appear in paired devices..."); + + // STRICT CHECK: Carol MUST appear in Bob's paired devices via proxy + attempts = 0; + let mut carol_found = false; + println!("Bob: STRICT CHECK - Carol must appear in paired devices within 30 seconds"); + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let paired_devices = if let Some(networking) = core.networking() { + networking.device_registry().read().await.get_paired_devices() + } else { + vec![] + }; + + // Look for Carol in paired devices + for device in &paired_devices { + if device.device_name == "Carol's Test Device" { + println!("Bob: ✅ Carol found via proxy! (ID: {})", device.device_id); + println!("Bob: Proxy pairing verified - Carol is in paired devices list"); + carol_found = true; + break; + } + } + + if carol_found { + break; + } + + attempts += 1; + if attempts >= 30 { + println!("\n❌ BOB TEST FAILURE"); + println!("Bob: Carol was NOT found in paired devices after 30 seconds"); + println!("Bob: This means Bob did not receive ProxyPairingComplete"); + println!("Bob: Expected: Carol to appear in paired devices"); + println!("Bob: Actual: {} device(s) found", paired_devices.len()); + for device in &paired_devices { + println!(" - {} ({})", device.device_name, device.device_id); + } + println!("\nDEBUG: Check logs above for:"); + println!(" 1. Alice sending ProxyPairingComplete to Bob"); + println!(" 2. Bob receiving and processing the complete message"); + println!(" 3. Bob storing Carol as proxied paired device"); + panic!("Bob: FAILED - Carol not found via proxy pairing"); + } + + if attempts % 5 == 0 { + println!("Bob: Waiting for Carol proxy pairing... ({}/30)", attempts); + } + } + + // Only write success if Carol was actually found via proxy + if !carol_found { + panic!("Bob: FAILED - Proxy pairing check failed"); + } + + println!("Bob: ✅ Proxy pairing SUCCESS - Carol paired via vouching!"); + std::fs::write(format!("{}/bob_success.txt", TEST_DIR), "success").unwrap(); + println!("Bob: Test completed"); + + tokio::time::sleep(Duration::from_secs(5)).await; +} + +/// Main test orchestrator - spawns three devices +#[tokio::test] +async fn test_proxy_pairing() { + println!("Testing proxy/vouching pairing with three devices"); + + // Clean up test directory + let _ = std::fs::remove_dir_all(TEST_DIR); + std::fs::create_dir_all(TEST_DIR).unwrap(); + + let mut runner = CargoTestRunner::for_test_file("proxy_pairing_test") + .with_timeout(Duration::from_secs(300)) + .add_subprocess("alice", "alice_proxy_pairing_scenario") + .add_subprocess("carol", "carol_proxy_pairing_scenario") + .add_subprocess("bob", "bob_proxy_pairing_scenario"); + + // Start Alice first (she initiates both pairings) + println!("Starting Alice as initiator..."); + runner + .spawn_single_process("alice") + .await + .expect("Failed to spawn Alice"); + + // Wait for Alice to initialize and generate first pairing code + tokio::time::sleep(Duration::from_secs(8)).await; + + // Start Carol (pairs with Alice first) + println!("Starting Carol as first joiner..."); + runner + .spawn_single_process("carol") + .await + .expect("Failed to spawn Carol"); + + // Wait for Alice-Carol pairing to complete + tokio::time::sleep(Duration::from_secs(15)).await; + + // Start Bob (pairs with Alice second, gets vouched to Carol) + println!("Starting Bob as second joiner..."); + runner + .spawn_single_process("bob") + .await + .expect("Failed to spawn Bob"); + + // Wait for all phases to complete + let result = runner + .wait_for_success(|_outputs| { + let alice_success = std::fs::read_to_string(format!("{}/alice_success.txt", TEST_DIR)) + .map(|content| content.trim() == "success") + .unwrap_or(false); + let carol_success = std::fs::read_to_string(format!("{}/carol_success.txt", TEST_DIR)) + .map(|content| content.trim() == "success") + .unwrap_or(false); + let bob_success = std::fs::read_to_string(format!("{}/bob_success.txt", TEST_DIR)) + .map(|content| content.trim() == "success") + .unwrap_or(false); + + alice_success && carol_success && bob_success + }) + .await; + + match result { + Ok(_) => { + println!("\n✅ PROXY PAIRING TEST PASSED!"); + println!(" ✅ Alice paired with Carol (direct)"); + println!(" ✅ Alice paired with Bob (direct)"); + println!(" ✅ Alice auto-vouched Bob to Carol"); + println!(" ✅ Carol received and accepted Bob's vouch"); + println!(" ✅ Bob received ProxyPairingComplete"); + println!(" ✅ Bob and Carol are now paired via proxy vouching"); + } + Err(e) => { + println!("\n❌ PROXY PAIRING TEST FAILED: {}", e); + println!("\nThis means the vouching protocol did not complete successfully."); + println!("Check the logs above for where the protocol stopped."); + for (name, output) in runner.get_all_outputs() { + println!("\n=== {} OUTPUT ===\n{}", name.to_uppercase(), output); + } + panic!("Proxy pairing test failed - vouching protocol did not work"); + } + } +} diff --git a/core/tests/sync_backfill_race_test.rs b/core/tests/sync_backfill_race_test.rs index c7af086e3..62c6d006a 100644 --- a/core/tests/sync_backfill_race_test.rs +++ b/core/tests/sync_backfill_race_test.rs @@ -97,7 +97,9 @@ impl BackfillRaceHarness { register_device(&library_alice, device_bob_id, "Bob").await?; register_device(&library_bob, device_alice_id, "Alice").await?; - // Set Alice's last_sync_at (she's synced), leave Bob's as None (needs backfill) + // NOTE: set_all_devices_synced is deprecated - last_sync_at no longer controls sync + // Bob will be detected as a new peer based on missing watermarks in sync.db + #[allow(deprecated)] set_all_devices_synced(&library_alice).await?; tracing::info!( diff --git a/core/tests/sync_setup_test.rs b/core/tests/sync_setup_test.rs index 68c448891..788c6b028 100644 --- a/core/tests/sync_setup_test.rs +++ b/core/tests/sync_setup_test.rs @@ -281,6 +281,152 @@ async fn bob_sync_setup_scenario() { println!("Bob: Test completed"); } +/// Carol's sync setup scenario for three-device test +#[tokio::test] +#[ignore] +async fn carol_three_device_scenario() { + if env::var("TEST_ROLE").unwrap_or_default() != "carol" { + return; + } + + env::set_var("SPACEDRIVE_TEST_DIR", "/tmp/spacedrive-three-device-test"); + + let data_dir = PathBuf::from("/tmp/spacedrive-three-device-test/carol"); + + println!("Carol: Starting three-device test"); + + // Initialize Core + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + + core.device.set_name("Carol Device".to_string()).unwrap(); + + // Initialize networking + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + + tokio::time::sleep(Duration::from_secs(2)).await; + println!("Carol: Core initialized"); + + // Wait for Alice's library ID + println!("Carol: Waiting for Alice's library ID..."); + let library_id = loop { + if let Ok(id) = + std::fs::read_to_string("/tmp/spacedrive-three-device-test/library_id.txt") + { + break id.trim().to_string(); + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + println!("Carol: Found library ID: {}", library_id); + + // Wait for pairing code + println!("Carol: Waiting for Alice's second pairing code..."); + let pairing_code = loop { + if let Ok(code) = + std::fs::read_to_string("/tmp/spacedrive-three-device-test/pairing_code_carol.txt") + { + break code.trim().to_string(); + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + + // Join pairing + println!("Carol: Joining pairing..."); + if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_joiner(&pairing_code, false), + ) + .await + .unwrap() + .unwrap(); + } + + // Wait for pairing completion + println!("Carol: Waiting for pairing to complete..."); + let mut attempts = 0; + while attempts < 30 { + tokio::time::sleep(Duration::from_secs(1)).await; + + let connected = core.services.device.get_connected_devices().await.unwrap(); + if !connected.is_empty() { + println!("Carol: Pairing successful!"); + + // Wait for Alice's ShareLocalLibrary to create library + println!("Carol: Waiting for library from Alice..."); + let alice_lib_uuid = uuid::Uuid::parse_str(&library_id).unwrap(); + let mut lib_wait_attempts = 0; + + while lib_wait_attempts < 30 { + tokio::time::sleep(Duration::from_secs(1)).await; + + if let Some(lib) = core.libraries.get_library(alice_lib_uuid).await { + println!("Carol: ✅ Library received! ID: {}", lib.id()); + + // Wait a bit for device sync to propagate + tokio::time::sleep(Duration::from_secs(3)).await; + + // Check if Bob's device is in the library (via shared sync) + use sd_core::infra::db::entities; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + // Read Bob's device ID + let bob_device_id = if let Ok(id) = std::fs::read_to_string( + "/tmp/spacedrive-three-device-test/bob_device_id.txt", + ) { + uuid::Uuid::parse_str(id.trim()).ok() + } else { + None + }; + + if let Some(bob_id) = bob_device_id { + let bob_device = entities::device::Entity::find() + .filter(entities::device::Column::Uuid.eq(bob_id)) + .one(lib.db().conn()) + .await + .unwrap(); + + if bob_device.is_some() { + println!( + "Carol: ✅ Bob's device automatically discovered via shared sync!" + ); + std::fs::write( + "/tmp/spacedrive-three-device-test/carol_success.txt", + "success", + ) + .unwrap(); + } else { + println!("Carol: ❌ Bob's device NOT found in library"); + std::fs::write( + "/tmp/spacedrive-three-device-test/carol_error.txt", + "Bob device not found - shared sync failed", + ) + .unwrap(); + } + } else { + println!("Carol: ⚠️ Could not read Bob's device ID"); + } + + break; + } + + lib_wait_attempts += 1; + } + + break; + } + + attempts += 1; + } + + println!("Carol: Test completed"); +} + /// Main test orchestrator #[tokio::test] async fn test_sync_setup_no_constraint_error() { @@ -353,3 +499,30 @@ async fn test_sync_setup_no_constraint_error() { } } } + +/// Three-device discovery test - verify shared resource sync enables automatic device discovery +#[tokio::test] +async fn test_three_device_discovery() { + println!("Testing three-device automatic discovery via shared sync..."); + + // Clean up + let _ = std::fs::remove_dir_all("/tmp/spacedrive-three-device-test"); + std::fs::create_dir_all("/tmp/spacedrive-three-device-test").unwrap(); + + // This test verifies that: + // 1. Alice pairs with Bob and runs sync setup + // 2. Alice pairs with Carol and runs sync setup + // 3. Bob automatically discovers Carol's device via shared sync + // 4. Carol automatically discovers Bob's device via shared sync + // No direct pairing between Bob and Carol needed! + + println!("✅ Three-device discovery test placeholder"); + println!("This test requires modifications to alice and bob scenarios"); + println!("to add Carol pairing and device ID writing"); + + // TODO: Implement full three-device test with: + // - Alice pairs with Bob (existing flow) + // - Alice pairs with Carol (new flow) + // - Bob verifies Carol's device in library + // - Carol verifies Bob's device in library +} diff --git a/core/tests/transitive_sync_backfill_test.rs b/core/tests/transitive_sync_backfill_test.rs new file mode 100644 index 000000000..1db44bff4 --- /dev/null +++ b/core/tests/transitive_sync_backfill_test.rs @@ -0,0 +1,820 @@ +//! Transitive Sync Backfill Test +//! +//! Tests the scenario where Carol receives Alice's data through transitive trust: +//! 1. Alice indexes a location with real data +//! 2. Alice pairs with Bob (direct) and sets up library sync +//! 3. Bob receives Alice's data via sync +//! 4. Bob pairs with Carol (direct) and sets up library sync +//! 5. Bob vouches Carol to Alice (proxy pairing) +//! 6. Carol syncs Alice's location data directly from Alice (all devices online) +//! +//! This test uses: +//! - Subprocess framework for true process isolation +//! - Real networking (no MockTransport) +//! - Real Core initialization and services +//! - Real device pairing and sync protocols + +mod helpers; + +use helpers::{create_test_volume, register_device}; +use sd_core::testing::CargoTestRunner; +use sd_core::{ + location::{create_location, IndexMode, LocationCreateArgs}, + service::Service, + Core, +}; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter}; +use std::env; +use std::path::PathBuf; +use std::time::Duration; +use tokio::time::timeout; +use uuid::Uuid; + +const TEST_DIR: &str = "/tmp/spacedrive-transitive-sync-test"; + +/// Alice's scenario - indexes location, pairs with Bob, sets up sync +#[tokio::test] +#[ignore] +async fn alice_transitive_sync_scenario() { + if env::var("TEST_ROLE").unwrap_or_default() != "alice" { + return; + } + + env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR); + let data_dir = PathBuf::from(format!("{}/alice", TEST_DIR)); + let device_name = "Alice's Test Device"; + + println!("Alice: Starting transitive sync test"); + println!("Alice: Data dir: {:?}", data_dir); + + // Initialize Core + println!("Alice: Initializing Core..."); + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + println!("Alice: Core initialized"); + + core.device.set_name(device_name.to_string()).unwrap(); + + // Create library with shared UUID + let library_id = loop { + if let Ok(id_str) = std::fs::read_to_string(format!("{}/library_id.txt", TEST_DIR)) { + break Uuid::parse_str(id_str.trim()).unwrap(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + }; + println!("Alice: Using library ID: {}", library_id); + + let library = core + .libraries + .create_library_with_id( + library_id, + "Transitive Sync Test Library", + None, + core.context.clone(), + ) + .await + .unwrap(); + println!("Alice: Library created"); + + let device_id = core.device.device_id().unwrap(); + + // Create volume for location + println!("Alice: Creating test volume..."); + let volume = create_test_volume(&library, device_id, "alice-volume", "Alice Volume") + .await + .unwrap(); + println!("Alice: Volume created: {}", volume); + + // Index Spacedrive source code as test data + println!("Alice: Indexing location with real data..."); + let test_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf(); + + let location_args = LocationCreateArgs { + path: test_path.clone(), + name: Some("spacedrive".to_string()), + index_mode: IndexMode::Content, + }; + + // Get device record + let device_record = sd_core::infra::db::entities::device::Entity::find() + .one(library.db().conn()) + .await + .unwrap() + .expect("Device not found"); + + let location_db_id = create_location( + library.clone(), + library.event_bus(), + location_args, + device_record.id, + ) + .await + .unwrap(); + + println!("Alice: Location created, ID: {}", location_db_id); + + // Link location to volume + let first_volume = sd_core::infra::db::entities::volume::Entity::find() + .filter(sd_core::infra::db::entities::volume::Column::DeviceId.eq(device_id)) + .one(library.db().conn()) + .await + .unwrap() + .expect("Volume not found"); + + sd_core::infra::db::entities::location::Entity::update_many() + .filter(sd_core::infra::db::entities::location::Column::Id.eq(location_db_id)) + .col_expr( + sd_core::infra::db::entities::location::Column::VolumeId, + sea_orm::sea_query::Expr::value(first_volume.id), + ) + .exec(library.db().conn()) + .await + .unwrap(); + + // Wait for indexing to complete + println!("Alice: Waiting for indexing to complete..."); + let start = tokio::time::Instant::now(); + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + + let location = sd_core::infra::db::entities::location::Entity::find() + .filter(sd_core::infra::db::entities::location::Column::Id.eq(location_db_id)) + .one(library.db().conn()) + .await + .unwrap() + .expect("Location not found"); + + if location.scan_state == "completed" { + println!("Alice: Indexing completed"); + break; + } + + if start.elapsed() > Duration::from_secs(120) { + panic!("Alice: Indexing timeout"); + } + } + + // Record entry count for verification + let alice_entry_count = sd_core::infra::db::entities::entry::Entity::find() + .count(library.db().conn()) + .await + .unwrap(); + println!("Alice: Indexed {} entries", alice_entry_count); + std::fs::write( + format!("{}/alice_entry_count.txt", TEST_DIR), + alice_entry_count.to_string(), + ) + .unwrap(); + + // Enable auto-vouch for proxy pairing later + println!("Alice: Enabling auto-vouch..."); + { + let mut config = core.config.write().await; + config.proxy_pairing.auto_vouch_to_all = true; + config.save().unwrap(); + } + + // Initialize networking + println!("Alice: Initializing networking..."); + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + tokio::time::sleep(Duration::from_secs(3)).await; + println!("Alice: Networking initialized"); + + // Phase 1: Pair with Bob + println!("\n=== PHASE 1: Alice pairs with Bob ==="); + let (pairing_code, _) = if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_initiator(false), + ) + .await + .unwrap() + .unwrap() + } else { + panic!("Networking not initialized"); + }; + + println!("Alice: Pairing code for Bob: {}", pairing_code); + std::fs::write(format!("{}/pairing_code_bob.txt", TEST_DIR), &pairing_code).unwrap(); + + // Wait for Bob to pair + let mut attempts = 0; + let mut bob_device_id = None; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + + let paired_devices = core + .services + .device + .get_connected_devices() + .await + .unwrap_or_default(); + + if !paired_devices.is_empty() { + println!("Alice: Bob paired successfully!"); + if let Ok(device_infos) = core.services.device.get_connected_devices_info().await { + for info in &device_infos { + println!("Alice sees: {} ({})", info.device_name, info.device_id); + if info.device_name.contains("Bob") { + bob_device_id = Some(info.device_id); + } + } + } + break; + } + + attempts += 1; + if attempts >= 60 { + panic!("Alice: Timeout waiting for Bob to pair"); + } + } + + let bob_id = bob_device_id.expect("Bob's device ID not found"); + println!("Alice: Bob's device ID: {}", bob_id); + + // Phase 2: Set up library sync with Bob (register Bob in library) + println!("\n=== PHASE 2: Alice sets up sync with Bob ==="); + register_device(&library, bob_id, "Bob").await.unwrap(); + println!("Alice: Bob registered in library"); + + // Initialize and start sync service with real networking + println!("Alice: Starting sync service..."); + library + .init_sync_service(device_id, core.services.networking.clone()) + .await + .unwrap(); + library.sync_service().unwrap().start().await.unwrap(); + println!("Alice: Sync service started"); + + std::fs::write(format!("{}/alice_bob_synced.txt", TEST_DIR), "ready").unwrap(); + + // Phase 3: Wait for Carol to proxy-pair + println!("\n=== PHASE 3: Alice waits for Carol (proxy pairing) ==="); + println!("Alice: Waiting for Bob to pair with Carol..."); + loop { + if std::fs::read_to_string(format!("{}/bob_carol_paired.txt", TEST_DIR)).is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + println!("Alice: Bob paired with Carol"); + + // With auto-vouch enabled, Alice should automatically vouch Carol + println!("Alice: Auto-vouch enabled - should vouch Carol to Alice"); + tokio::time::sleep(Duration::from_secs(5)).await; + + // Carol should now be proxy-paired and start syncing + println!("Alice: Keeping sync service alive for Carol to sync..."); + tokio::time::sleep(Duration::from_secs(30)).await; + + std::fs::write(format!("{}/alice_success.txt", TEST_DIR), "success").unwrap(); + println!("Alice: Test completed"); + + // Keep alive a bit longer + tokio::time::sleep(Duration::from_secs(10)).await; +} + +/// Bob's scenario - pairs with Alice, syncs data, pairs with Carol, facilitates transitive sync +#[tokio::test] +#[ignore] +async fn bob_transitive_sync_scenario() { + if env::var("TEST_ROLE").unwrap_or_default() != "bob" { + return; + } + + env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR); + let data_dir = PathBuf::from(format!("{}/bob", TEST_DIR)); + let device_name = "Bob's Test Device"; + + println!("Bob: Starting transitive sync test"); + println!("Bob: Data dir: {:?}", data_dir); + + // Initialize Core + println!("Bob: Initializing Core..."); + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + println!("Bob: Core initialized"); + + core.device.set_name(device_name.to_string()).unwrap(); + + // Create library with same UUID as Alice + let library_id = loop { + if let Ok(id_str) = std::fs::read_to_string(format!("{}/library_id.txt", TEST_DIR)) { + break Uuid::parse_str(id_str.trim()).unwrap(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + }; + println!("Bob: Using library ID: {}", library_id); + + let library = core + .libraries + .create_library_with_id( + library_id, + "Transitive Sync Test Library", + None, + core.context.clone(), + ) + .await + .unwrap(); + println!("Bob: Library created"); + + let device_id = core.device.device_id().unwrap(); + + // Initialize networking + println!("Bob: Initializing networking..."); + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + tokio::time::sleep(Duration::from_secs(3)).await; + println!("Bob: Networking initialized"); + + // Phase 1: Pair with Alice + println!("\n=== PHASE 1: Bob pairs with Alice ==="); + println!("Bob: Waiting for Alice's pairing code..."); + let pairing_code = loop { + if let Ok(code) = std::fs::read_to_string(format!("{}/pairing_code_bob.txt", TEST_DIR)) { + break code.trim().to_string(); + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + println!("Bob: Found pairing code"); + + if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_joiner(&pairing_code, false), + ) + .await + .unwrap() + .unwrap(); + } + println!("Bob: Joined pairing with Alice"); + + // Wait for pairing completion + let mut attempts = 0; + let mut alice_device_id = None; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + + let paired_devices = core + .services + .device + .get_connected_devices() + .await + .unwrap_or_default(); + + if !paired_devices.is_empty() { + println!("Bob: Paired with Alice!"); + if let Ok(device_infos) = core.services.device.get_connected_devices_info().await { + for info in &device_infos { + println!("Bob sees: {} ({})", info.device_name, info.device_id); + if info.device_name.contains("Alice") { + alice_device_id = Some(info.device_id); + } + } + } + break; + } + + attempts += 1; + if attempts >= 60 { + panic!("Bob: Timeout pairing with Alice"); + } + } + + let alice_id = alice_device_id.expect("Alice's device ID not found"); + println!("Bob: Alice's device ID: {}", alice_id); + + // Phase 2: Set up sync with Alice and wait for data + println!("\n=== PHASE 2: Bob sets up sync with Alice ==="); + register_device(&library, alice_id, "Alice").await.unwrap(); + println!("Bob: Alice registered in library"); + + // Wait for Alice to be ready + loop { + if std::fs::read_to_string(format!("{}/alice_bob_synced.txt", TEST_DIR)).is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + println!("Bob: Starting sync service..."); + library + .init_sync_service(device_id, core.services.networking.clone()) + .await + .unwrap(); + library.sync_service().unwrap().start().await.unwrap(); + println!("Bob: Sync service started"); + + // Wait for sync from Alice + println!("Bob: Waiting for sync from Alice..."); + let start = tokio::time::Instant::now(); + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + + let bob_entries = sd_core::infra::db::entities::entry::Entity::find() + .count(library.db().conn()) + .await + .unwrap(); + + if bob_entries > 10 { + println!("Bob: Received {} entries from Alice", bob_entries); + break; + } + + if start.elapsed() > Duration::from_secs(90) { + panic!("Bob: Timeout waiting for sync from Alice"); + } + } + + // Phase 3: Pair with Carol + println!("\n=== PHASE 3: Bob pairs with Carol ==="); + tokio::time::sleep(Duration::from_secs(2)).await; + + let (pairing_code_carol, _) = if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_initiator(false), + ) + .await + .unwrap() + .unwrap() + } else { + panic!("Networking not initialized"); + }; + + println!("Bob: Pairing code for Carol: {}", pairing_code_carol); + std::fs::write( + format!("{}/pairing_code_carol.txt", TEST_DIR), + &pairing_code_carol, + ) + .unwrap(); + + // Wait for Carol to pair + attempts = 0; + let mut carol_device_id = None; + let initial_paired_count = core + .services + .device + .get_connected_devices() + .await + .unwrap_or_default() + .len(); + + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + + let paired_devices = core + .services + .device + .get_connected_devices() + .await + .unwrap_or_default(); + + if paired_devices.len() > initial_paired_count { + println!("Bob: Carol paired successfully!"); + if let Ok(device_infos) = core.services.device.get_connected_devices_info().await { + for info in &device_infos { + println!("Bob sees: {} ({})", info.device_name, info.device_id); + if info.device_name.contains("Carol") { + carol_device_id = Some(info.device_id); + } + } + } + break; + } + + attempts += 1; + if attempts >= 60 { + panic!("Bob: Timeout waiting for Carol to pair"); + } + } + + let carol_id = carol_device_id.expect("Carol's device ID not found"); + println!("Bob: Carol's device ID: {}", carol_id); + + // Phase 4: Set up sync with Carol + println!("\n=== PHASE 4: Bob sets up sync with Carol ==="); + register_device(&library, carol_id, "Carol").await.unwrap(); + println!("Bob: Carol registered in library"); + + std::fs::write(format!("{}/bob_carol_paired.txt", TEST_DIR), "success").unwrap(); + + // Proxy pairing should happen automatically through Alice's auto-vouch + println!("Bob: Alice should auto-vouch Carol via proxy pairing"); + tokio::time::sleep(Duration::from_secs(10)).await; + + // Keep alive for Carol to sync + println!("Bob: Keeping sync service alive for Carol..."); + tokio::time::sleep(Duration::from_secs(30)).await; + + std::fs::write(format!("{}/bob_success.txt", TEST_DIR), "success").unwrap(); + println!("Bob: Test completed"); + + tokio::time::sleep(Duration::from_secs(10)).await; +} + +/// Carol's scenario - pairs with Bob, gets proxy-paired to Alice, syncs Alice's data +#[tokio::test] +#[ignore] +async fn carol_transitive_sync_scenario() { + if env::var("TEST_ROLE").unwrap_or_default() != "carol" { + return; + } + + env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR); + let data_dir = PathBuf::from(format!("{}/carol", TEST_DIR)); + let device_name = "Carol's Test Device"; + + println!("Carol: Starting transitive sync test"); + println!("Carol: Data dir: {:?}", data_dir); + + // Initialize Core + println!("Carol: Initializing Core..."); + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + println!("Carol: Core initialized"); + + core.device.set_name(device_name.to_string()).unwrap(); + + // Create library with same UUID as Alice and Bob + let library_id = loop { + if let Ok(id_str) = std::fs::read_to_string(format!("{}/library_id.txt", TEST_DIR)) { + break Uuid::parse_str(id_str.trim()).unwrap(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + }; + println!("Carol: Using library ID: {}", library_id); + + let library = core + .libraries + .create_library_with_id( + library_id, + "Transitive Sync Test Library", + None, + core.context.clone(), + ) + .await + .unwrap(); + println!("Carol: Library created"); + + let device_id = core.device.device_id().unwrap(); + + // Initialize networking + println!("Carol: Initializing networking..."); + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + tokio::time::sleep(Duration::from_secs(3)).await; + println!("Carol: Networking initialized"); + + // Phase 1: Pair with Bob + println!("\n=== PHASE 1: Carol pairs with Bob ==="); + println!("Carol: Waiting for Bob's pairing code..."); + let pairing_code = loop { + if let Ok(code) = std::fs::read_to_string(format!("{}/pairing_code_carol.txt", TEST_DIR)) { + break code.trim().to_string(); + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + println!("Carol: Found pairing code"); + + if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_joiner(&pairing_code, false), + ) + .await + .unwrap() + .unwrap(); + } + println!("Carol: Joined pairing with Bob"); + + // Wait for pairing completion + let mut attempts = 0; + let mut bob_device_id = None; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + + let paired_devices = core + .services + .device + .get_connected_devices() + .await + .unwrap_or_default(); + + if !paired_devices.is_empty() { + println!("Carol: Paired with Bob!"); + if let Ok(device_infos) = core.services.device.get_connected_devices_info().await { + for info in &device_infos { + println!("Carol sees: {} ({})", info.device_name, info.device_id); + if info.device_name.contains("Bob") { + bob_device_id = Some(info.device_id); + } + } + } + break; + } + + attempts += 1; + if attempts >= 60 { + panic!("Carol: Timeout pairing with Bob"); + } + } + + let bob_id = bob_device_id.expect("Bob's device ID not found"); + println!("Carol: Bob's device ID: {}", bob_id); + + // Phase 2: Set up sync with Bob + println!("\n=== PHASE 2: Carol sets up sync with Bob ==="); + register_device(&library, bob_id, "Bob").await.unwrap(); + println!("Carol: Bob registered in library"); + + println!("Carol: Starting sync service..."); + library + .init_sync_service(device_id, core.services.networking.clone()) + .await + .unwrap(); + library.sync_service().unwrap().start().await.unwrap(); + println!("Carol: Sync service started"); + + // Phase 3: Wait for proxy pairing and sync from Alice + println!("\n=== PHASE 3: Carol waits for proxy pairing and syncs from Alice ==="); + println!("Carol: Should be proxy-paired to Alice through Bob's vouching"); + println!("Carol: Waiting for Alice's data to sync..."); + + let alice_expected_count: u64 = loop { + if let Ok(count_str) = + std::fs::read_to_string(format!("{}/alice_entry_count.txt", TEST_DIR)) + { + break count_str.trim().parse().unwrap(); + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + println!( + "Carol: Expected {} entries from Alice", + alice_expected_count + ); + + // Wait for sync to complete + let start = tokio::time::Instant::now(); + let mut carol_final_count = 0; + loop { + tokio::time::sleep(Duration::from_secs(3)).await; + + carol_final_count = sd_core::infra::db::entities::entry::Entity::find() + .count(library.db().conn()) + .await + .unwrap(); + + println!("Carol: Current entries: {}", carol_final_count); + + // Allow 10% tolerance for sync + let diff = (carol_final_count as i64 - alice_expected_count as i64).abs(); + let tolerance = (alice_expected_count as f64 * 0.1) as i64; + + if diff <= tolerance && carol_final_count > 10 { + println!("Carol: Sync complete! Received {} entries", carol_final_count); + break; + } + + if start.elapsed() > Duration::from_secs(120) { + panic!( + "Carol: Sync timeout - expected ~{}, got {}", + alice_expected_count, carol_final_count + ); + } + } + + // Verify entry count + let diff = (carol_final_count as i64 - alice_expected_count as i64).abs(); + let diff_pct = (diff as f64 / alice_expected_count as f64) * 100.0; + println!( + "Carol: Verification - Alice: {}, Carol: {} (diff: {}, {:.1}%)", + alice_expected_count, carol_final_count, diff, diff_pct + ); + + assert!( + diff_pct <= 10.0, + "Carol: Entry count difference too large: {:.1}%", + diff_pct + ); + + println!("Carol: ✅ Transitive sync successful!"); + std::fs::write(format!("{}/carol_success.txt", TEST_DIR), "success").unwrap(); + println!("Carol: Test completed"); + + tokio::time::sleep(Duration::from_secs(5)).await; +} + +/// Main test orchestrator - coordinates three devices for transitive sync +#[tokio::test] +async fn test_transitive_sync_backfill() { + println!("Testing transitive sync backfill with three devices"); + println!("Alice indexes → pairs with Bob → Bob pairs with Carol → Carol syncs Alice's data"); + + // Clean up test directory + let _ = std::fs::remove_dir_all(TEST_DIR); + std::fs::create_dir_all(TEST_DIR).unwrap(); + + // Generate shared library UUID + let library_id = Uuid::new_v4(); + std::fs::write( + format!("{}/library_id.txt", TEST_DIR), + library_id.to_string(), + ) + .unwrap(); + println!("Generated library ID: {}", library_id); + + let mut runner = CargoTestRunner::for_test_file("transitive_sync_backfill_test") + .with_timeout(Duration::from_secs(400)) + .add_subprocess("alice", "alice_transitive_sync_scenario") + .add_subprocess("bob", "bob_transitive_sync_scenario") + .add_subprocess("carol", "carol_transitive_sync_scenario"); + + // Start Alice first - she needs to index before others join + println!("\n=== Starting Alice (indexing and initiating) ==="); + runner + .spawn_single_process("alice") + .await + .expect("Failed to spawn Alice"); + + // Wait for Alice to index and initialize + println!("Waiting for Alice to complete indexing..."); + tokio::time::sleep(Duration::from_secs(60)).await; + + // Start Bob - pairs with Alice and syncs + println!("\n=== Starting Bob (syncing from Alice) ==="); + runner + .spawn_single_process("bob") + .await + .expect("Failed to spawn Bob"); + + // Wait for Alice-Bob pairing and initial sync + println!("Waiting for Alice-Bob pairing and sync..."); + tokio::time::sleep(Duration::from_secs(45)).await; + + // Start Carol - pairs with Bob, gets proxy-paired to Alice, syncs + println!("\n=== Starting Carol (transitive sync via proxy pairing) ==="); + runner + .spawn_single_process("carol") + .await + .expect("Failed to spawn Carol"); + + // Wait for all phases to complete + println!("\n=== Waiting for transitive sync to complete ==="); + let result = runner + .wait_for_success(|_outputs| { + let alice_success = std::fs::read_to_string(format!("{}/alice_success.txt", TEST_DIR)) + .map(|content| content.trim() == "success") + .unwrap_or(false); + let bob_success = std::fs::read_to_string(format!("{}/bob_success.txt", TEST_DIR)) + .map(|content| content.trim() == "success") + .unwrap_or(false); + let carol_success = std::fs::read_to_string(format!("{}/carol_success.txt", TEST_DIR)) + .map(|content| content.trim() == "success") + .unwrap_or(false); + + alice_success && bob_success && carol_success + }) + .await; + + match result { + Ok(_) => { + println!("\n✅ TRANSITIVE SYNC BACKFILL TEST PASSED!"); + println!(" ✅ Alice indexed {} entries", + std::fs::read_to_string(format!("{}/alice_entry_count.txt", TEST_DIR)) + .unwrap_or_default().trim()); + println!(" ✅ Alice paired with Bob (direct)"); + println!(" ✅ Bob synced Alice's data"); + println!(" ✅ Bob paired with Carol (direct)"); + println!(" ✅ Carol proxy-paired to Alice (via Bob's vouch)"); + println!(" ✅ Carol synced Alice's data directly"); + println!("\n This proves transitive sync works: Carol received Alice's data"); + println!(" after establishing trust through Bob via proxy pairing!"); + } + Err(e) => { + println!("\n❌ TRANSITIVE SYNC BACKFILL TEST FAILED: {}", e); + println!("\nThis means the transitive sync protocol did not complete successfully."); + println!("Check the logs above for where the sync stopped."); + for (name, output) in runner.get_all_outputs() { + println!("\n=== {} OUTPUT ===\n{}", name.to_uppercase(), output); + } + panic!("Transitive sync backfill test failed"); + } + } +} diff --git a/docs/core/library-sync.mdx b/docs/core/library-sync.mdx index 5caa2b331..53ddfbf13 100644 --- a/docs/core/library-sync.mdx +++ b/docs/core/library-sync.mdx @@ -23,7 +23,7 @@ Sync uses two protocols based on data ownership: | Data Type | Ownership | Sync Method | Conflict Resolution | | -------------- | ------------ | --------------- | ------------------- | -| Devices | Device-owned | State broadcast | None needed | +| Devices | Shared | HLC-ordered log | Last write wins | | Locations | Device-owned | State broadcast | None needed | | Files/Folders | Device-owned | State broadcast | None needed | | Volumes | Device-owned | State broadcast | None needed | @@ -42,7 +42,6 @@ Spacedrive recognizes that some data naturally belongs to specific devices. Only the device with physical access can modify: -- **Devices**: Device identity and metadata - **Locations**: Filesystem paths like `/Users/alice/Photos` - **Entries**: Files and folders within those locations - **Volumes**: Physical drives and mount points @@ -51,6 +50,7 @@ Only the device with physical access can modify: Any device can create or modify: +- **Devices**: Library membership - all devices automatically discover each other - **Tags**: Labels applied to files, with hierarchy support - **Collections**: Groups of files - **User Metadata**: Notes, ratings, custom fields @@ -1424,19 +1424,19 @@ This reduces network overhead during rapid operations (e.g., bulk tagging). ### Currently Syncing -**Device-Owned Models (4):** +**Device-Owned Models (3):** | Model | Table | Dependencies | FK Mappings | Features | | ----- | ----- | ------------ | ----------- | -------- | -| Device | `devices` | None | None | Root model | | Volume | `volumes` | `device` | `device_id → devices` | with_deletion | | Location | `locations` | `volume` | `volume_id → volumes`, `entry_id → entries` | with_deletion | | Entry | `entries` | `volume`, `content_identity`, `user_metadata` | `volume_id → volumes`, `parent_id → entries`, `metadata_id → user_metadata`, `content_id → content_identities` | with_deletion, with_rebuild | -**Shared Models (15):** +**Shared Models (16):** | Model | Table | Dependencies | FK Mappings | Features | | ----- | ----- | ------------ | ----------- | -------- | +| Device | `devices` | None | None | Library membership | | Tag | `tag` | None | None | - | | TagRelationship | `tag_relationship` | `tag` | `parent_tag_id → tag`, `child_tag_id → tag` | with_rebuild | | Collection | `collection` | None | None | - | diff --git a/docs/core/pairing.mdx b/docs/core/pairing.mdx index fceb5f2c0..23c56a480 100644 --- a/docs/core/pairing.mdx +++ b/docs/core/pairing.mdx @@ -163,6 +163,12 @@ Store the paired device information and session keys for future communication. +## Proxy pairing + +Proxy pairing lets a new device join a network after a single direct pairing. The device that completed the direct pairing can vouch for the new device to other trusted devices. Each receiving device can accept or reject the vouch. + +See [Proxy Pairing](/docs/core/proxy-pairing) for the full protocol, resource model, and flows. + ## Technical Architecture ### Protocol Messages @@ -597,4 +603,5 @@ Solutions: - [Networking](/docs/core/networking) - Network transport details - [Devices](/docs/core/devices) - Device management system +- [Proxy Pairing](/docs/core/proxy-pairing) - Vouching based pairing flow - [Security](/docs/core/security) - Cryptographic architecture diff --git a/docs/core/proxy-pairing.mdx b/docs/core/proxy-pairing.mdx new file mode 100644 index 000000000..631cc6704 --- /dev/null +++ b/docs/core/proxy-pairing.mdx @@ -0,0 +1,379 @@ +--- +title: Proxy Pairing +sidebarTitle: Proxy Pairing +--- + +Proxy pairing lets a new device join a network after a single direct pairing. The device that paired directly can vouch for the new device to other trusted devices. This reduces repeated pairing while keeping explicit user approval. + +## Goals + +- Reduce the number of direct pairing sessions required to grow a network. +- Keep the user confirmation flow for direct pairing. +- Let receiving devices choose to accept or reject a vouch. +- Record the trust chain for audit and review. +- Support offline devices by queueing vouches. + +## Non goals + +- Multi hop vouching. Only directly paired devices can vouch. +- Shared group keys or a central authority. +- Automatic trust propagation without user control. + +## Trust model + +Direct pairings remain the base trust relationship. Proxy pairings are derived from a trusted voucher. + +``` +A <-> direct <-> B <-> direct <-> C + | + +-> proxied to C +``` + +Persisted paired devices record the pairing type and the voucher. + +```rust +pub struct PersistedPairedDevice { + pub device_info: DeviceInfo, + pub session_keys: SessionKeys, + pub paired_at: DateTime, + pub last_connected_at: Option>, + pub connection_attempts: u32, + pub trust_level: TrustLevel, + pub relay_url: Option, + + pub pairing_type: PairingType, + pub vouched_by: Option, + pub vouched_at: Option>, +} + +pub enum PairingType { + Direct, + Proxied, +} +``` + +## Compatibility with direct confirmation + +Proxy pairing builds on the direct confirmation flow. The direct pairing must reach a confirmed state before any vouching starts. The voucher then offers to vouch the new device to other devices. Receiving devices can auto accept or ask for user confirmation. + +## Protocol additions + +```rust +pub enum PairingMessage { + // Existing messages: + // PairingRequest, Challenge, Response, Complete, Reject + + ProxyPairingRequest { + session_id: Uuid, + vouchee_device_info: DeviceInfo, + vouchee_public_key: Vec, + voucher_device_id: Uuid, + voucher_signature: Vec, + timestamp: DateTime, + }, + + ProxyPairingResponse { + session_id: Uuid, + accepting_device_id: Uuid, + accepted: bool, + reason: Option, + }, + + ProxyPairingComplete { + session_id: Uuid, + accepted_by: Vec, + rejected_by: Vec, + }, +} + +pub struct AcceptedDevice { + pub device_id: Uuid, + pub device_name: String, + pub node_id: Option, +} + +pub struct RejectedDevice { + pub device_id: Uuid, + pub device_name: String, + pub reason: String, +} +``` + +## Resource model for UI + +The vouching session is a resource that the UI can subscribe to with `ResourceChanged` events. + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VouchingSession { + pub id: Uuid, + pub vouchee_device_id: Uuid, + pub vouchee_device_name: String, + pub voucher_device_id: Uuid, + pub created_at: DateTime, + pub state: VouchingSessionState, + pub vouches: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum VouchingSessionState { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VouchState { + pub device_id: Uuid, + pub device_name: String, + pub status: VouchStatus, + pub updated_at: DateTime, + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum VouchStatus { + Selected, + Queued, + Waiting, + Accepted, + Rejected, + Unreachable, +} + +impl Identifiable for VouchingSession { + type Id = Uuid; + + fn id(&self) -> Self::Id { + self.id + } +} + +crate::register_resource_type!(VouchingSession, "vouching_session"); +``` + +## Events for confirmation prompts + +The vouching session is driven by resource updates. Events are only needed for confirmation prompts and UI entry points. + +```rust +pub enum Event { + ProxyPairingConfirmationRequired { + session_id: Uuid, + vouchee_device_name: String, + vouchee_device_os: String, + voucher_device_name: String, + voucher_device_id: Uuid, + expires_at: String, + }, + + ProxyPairingVouchingReady { + session_id: Uuid, + vouchee_device_id: Uuid, + }, +} +``` + +## Actions + +```rust +pub struct PairVouchInput { + pub session_id: Uuid, + pub target_device_ids: Vec, +} + +pub struct PairVouchOutput { + pub success: bool, + pub accepted_by: Vec, + pub rejected_by: Vec, + pub pending_count: u32, +} + +pub struct PairConfirmProxyInput { + pub session_id: Uuid, + pub accepted: bool, +} + +pub struct PairConfirmProxyOutput { + pub success: bool, + pub error: Option, +} +``` + +## Voucher flow + +1. The new device sends `PairingRequest`. +2. The voucher enters the confirmation state and the user confirms. +3. Direct pairing completes and session keys are stored. +4. The voucher creates a `VouchingSession` resource in `Pending`. +5. The voucher emits `ProxyPairingVouchingReady` so the UI can open a modal. +6. The user selects target devices and triggers `network.pair.vouch`. +7. The background worker processes each target: + - Online devices move to `Waiting` and receive `ProxyPairingRequest`. + - Offline devices move to `Queued` and are stored in `vouching_queue`. +8. Responses update the vouch status to `Accepted` or `Rejected`. +9. When all vouches reach a terminal state, the session becomes `Completed`. +10. The voucher sends `ProxyPairingComplete` to the vouchee. + +## Receiving device flow + +1. Receive `ProxyPairingRequest`. +2. Verify the voucher is a trusted, directly paired device. +3. Verify the vouch signature and timestamp. +4. Check that the vouchee is not already paired. +5. If auto accept is enabled, accept and store the device. +6. If manual confirmation is required, emit `ProxyPairingConfirmationRequired`. +7. Send `ProxyPairingResponse` with accepted or rejected. +8. Store the vouchee as `pairing_type: Proxied` when accepted. + +## Vouchee flow + +1. Complete direct pairing with the voucher. +2. Receive `ProxyPairingComplete`. +3. Store accepted devices with `pairing_type: Proxied`. +4. Update the device registry and emit resource updates. + +## Vouch payload signature + +```rust +pub struct VouchPayload { + pub vouchee_device_id: Uuid, + pub vouchee_public_key: Vec, + pub vouchee_device_info: DeviceInfo, + pub timestamp: DateTime, + pub session_id: Uuid, +} + +impl VouchPayload { + pub fn sign(&self, signing_key: &SigningKey) -> Vec { + let serialized = bincode::serialize(self).unwrap(); + signing_key.sign(&serialized).to_bytes().to_vec() + } + + pub fn verify(&self, signature: &[u8], verifying_key: &VerifyingKey) -> bool { + let serialized = bincode::serialize(self).unwrap(); + let signature = Signature::from_bytes(signature.try_into().unwrap()); + verifying_key.verify(&serialized, &signature).is_ok() + } +} +``` + +The receiver accepts vouches that are within the configured age window and that come from a trusted voucher. + +## Session key derivation for proxied pairing + +The receiving device and the vouchee derive keys from the voucher and vouchee shared secret. + +```rust +pub fn derive_proxied_session_keys( + voucher_device_id: Uuid, + vouchee_device_id: Uuid, + vouchee_public_key: &[u8], + voucher_vouchee_shared_secret: &[u8], +) -> SessionKeys { + let context = format!( + "spacedrive-proxy-pairing-{}:{}:{}", + voucher_device_id, + vouchee_device_id, + hex::encode(vouchee_public_key) + ); + + let hkdf = Hkdf::::new(None, voucher_vouchee_shared_secret); + let mut send_key = [0u8; 32]; + let mut receive_key = [0u8; 32]; + + hkdf.expand(format!("{}-send", context).as_bytes(), &mut send_key).unwrap(); + hkdf.expand(format!("{}-recv", context).as_bytes(), &mut receive_key).unwrap(); + + SessionKeys { + send_key: send_key.to_vec(), + receive_key: receive_key.to_vec(), + created_at: Utc::now(), + expires_at: Some(Utc::now() + Duration::days(1)), + } +} +``` + +The voucher includes derived keys in the proxy request for the receiving device, encrypted with the existing shared secret. The voucher also includes keys in the completion message sent to the vouchee. + +## Persistent queue for offline devices + +Queued vouches are stored in `sync.db` so the system can retry when a device comes online. + +```sql +CREATE TABLE vouching_queue ( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL, + target_device_id TEXT NOT NULL, + vouchee_device_id TEXT NOT NULL, + vouchee_device_info TEXT NOT NULL, + vouchee_public_key BLOB NOT NULL, + vouch_signature BLOB NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + retry_count INTEGER DEFAULT 0, + last_attempt_at TEXT, + + UNIQUE(session_id, target_device_id) +); + +CREATE INDEX idx_vouching_queue_target ON vouching_queue(target_device_id); +CREATE INDEX idx_vouching_queue_expires ON vouching_queue(expires_at); +``` + +Processing logic: + +1. A worker polls the queue every 10 seconds. +2. If a target device is online, send `ProxyPairingRequest` and move the vouch to `Waiting`. +3. Remove entries after success or after the max retry count. +4. Delete entries after `expires_at`. + +## Configuration + +```rust +pub struct ProxyPairingConfig { + pub auto_accept_vouched: bool, + pub auto_vouch_to_all: bool, + pub vouch_signature_max_age: u64, + pub vouch_response_timeout: u64, +} +``` + +Suggested defaults: + +- `auto_accept_vouched`: true +- `auto_vouch_to_all`: false +- `vouch_signature_max_age`: 300 +- `vouch_response_timeout`: 30 + +## Security checks + +- The voucher must be trusted and directly paired. +- The vouch signature must match the voucher public key. +- The vouch timestamp must be within the allowed window. +- The vouchee must not already be paired. +- Devices with unreliable or blocked trust levels reject proxy pairing. + +## Backwards compatibility + +- Devices without proxy pairing ignore `ProxyPairingRequest`. +- The voucher records the lack of response as a rejection. +- Existing direct pairings remain unchanged. + +## Cleanup and retention + +- Remove `VouchingSession` data one hour after completion. +- Remove queued vouches after seven days. + +## Testing + +- Unit tests for vouch signature verification and timestamp checks. +- Integration tests for accept and reject flows. +- Queue processing tests for offline devices. +- Tests for trust level rules and auto accept settings. + +## Open questions + +- Key rotation for proxied session keys. +- Whether to allow multi hop vouching in a future version. +- Vouch revocation when a voucher is unpaired. diff --git a/docs/mint.json b/docs/mint.json index baa27ec25..0bfca825b 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -97,6 +97,7 @@ "pages": [ "core/networking", "core/pairing", + "core/proxy-pairing", "core/library-sync", "core/file-sync", "core/cloud-integration" diff --git a/docs/overview/get-started.mdx b/docs/overview/get-started.mdx index 516420973..086cfd7c5 100644 --- a/docs/overview/get-started.mdx +++ b/docs/overview/get-started.mdx @@ -13,7 +13,7 @@ Spacedrive runs as a background service (daemon) with a separate user interface. -Visit [spacedrive.com/download](https://spacedrive.com/download) and get the installer for your platform. Spacedrive supports macOS, Windows, Linux, iOS, and Android. +Visit [spacedrive.com/download](https://github.com/spacedriveapp/spacedrive/releases/tag/v2.0.0-alpha.1) and get the installer for your platform. Spacedrive (v2.0.0-alpha.1) supports macOS and Linux. Windows, iOS, and Android coming in v2.0.0-alpha.2. Follow your platform's standard installation process. On macOS, drag Spacedrive to Applications. On Windows, run the installer. Linux users can use AppImage or package managers. @@ -52,7 +52,7 @@ Spacedrive introduces concepts that differ from traditional file managers: ### Libraries A library is your personal database of file information. It stores: - File metadata and organization -- Tags and custom attributes +- Tags and custom attributes - Device relationships - Sync settings @@ -142,7 +142,7 @@ Spacedrive's search works across all indexed locations simultaneously: ``` photo # Find all photos -tag:vacation # Files tagged "vacation" +tag:vacation # Files tagged "vacation" size:>10MB # Large files modified:<7d # Changed in last week ``` diff --git a/packages/interface/src/Shell.tsx b/packages/interface/src/Shell.tsx index 911754313..f078e8862 100644 --- a/packages/interface/src/Shell.tsx +++ b/packages/interface/src/Shell.tsx @@ -2,7 +2,7 @@ import { SpacedriveProvider, type SpacedriveClient } from "./contexts/Spacedrive import { ServerProvider } from "./contexts/ServerContext"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { RouterProvider } from "react-router-dom"; -import { Dialogs, Toaster } from "@sd/ui"; +import { Dialogs, Toaster, TooltipProvider } from "@sd/ui"; import { ShellLayout } from "./ShellLayout"; import { explorerRoutes } from "./router"; import { useDaemonStatus } from "./hooks/useDaemonStatus"; @@ -83,24 +83,26 @@ export function Shell({ client }: ShellProps) { return ( - {isTauri ? ( - // Tauri: Wait for daemon connection before rendering content - - ) : ( - // Web: Render immediately (daemon connection handled differently) - <> - - - - - - - - - )} + + {isTauri ? ( + // Tauri: Wait for daemon connection before rendering content + + ) : ( + // Web: Render immediately (daemon connection handled differently) + <> + + + + + + + + + )} + ); diff --git a/packages/interface/src/routes/overview/DevicePanel.tsx b/packages/interface/src/routes/overview/DevicePanel.tsx index c1d47ed97..191cd4cb3 100644 --- a/packages/interface/src/routes/overview/DevicePanel.tsx +++ b/packages/interface/src/routes/overview/DevicePanel.tsx @@ -3,7 +3,9 @@ import { CaretRight, Cpu, HardDrive, - Memory + Memory, + WifiHigh, + WifiSlash } from '@phosphor-icons/react'; import DatabaseIcon from '@sd/assets/icons/Database.png'; import DriveAmazonS3Icon from '@sd/assets/icons/Drive-AmazonS3.png'; @@ -24,7 +26,7 @@ import type { VolumeListOutput, VolumeListQueryInput } from '@sd/ts-client'; -import {TopBarButton} from '@sd/ui'; +import {Tooltip, TopBarButton} from '@sd/ui'; import clsx from 'clsx'; import {useEffect, useRef, useState} from 'react'; import Masonry from 'react-masonry-css'; @@ -39,7 +41,7 @@ import {VolumeBar} from './VolumeBar'; // Temporary type extension until types are regenerated type DeviceWithConnection = Device & { - connection_method?: 'Direct' | 'Relay' | 'Mixed' | null; + connection_method?: 'LocalNetwork' | 'DirectInternet' | 'RelayProxy' | null; }; export function formatBytes(bytes: number): string { @@ -267,24 +269,69 @@ export function DevicePanel({onLocationSelect}: DevicePanelProps = {}) { ); } -interface ConnectionBadgeProps { - method: 'Direct' | 'Relay' | 'Mixed'; +interface ConnectionBadgeConfig { + label: string; + description: string; + icon?: React.ComponentType<{className?: string}>; + color?: string; } -function ConnectionBadge({method}: ConnectionBadgeProps) { - const labels = { - Direct: 'Local', - Relay: 'Relay', - Mixed: 'Mixed' +interface ConnectionBadgeProps { + method: 'LocalNetwork' | 'DirectInternet' | 'RelayProxy'; + online: boolean; + current: boolean; + icon?: React.ComponentType<{className?: string}>; + color?: string; +} + +function ConnectionBadge({method, online, current, icon: customIcon, color: customColor}: ConnectionBadgeProps) { + const configs: Record = { + LocalNetwork: { + label: 'Local', + description: 'Connected via local network', + icon: WifiHigh, + color: 'bg-green-500' + }, + DirectInternet: { + label: 'Direct', + description: 'Connected directly via internet', + color: 'bg-blue-500' + }, + RelayProxy: { + label: 'Relay', + description: 'Connected via relay proxy', + color: 'bg-yellow-500' + }, + Offline: { + label: 'Offline', + description: 'Device is currently offline', + icon: WifiSlash, + color: 'bg-ink-dull' + }, + Current: { + label: 'This device', + description: 'This is your current device', + } }; + const state = current ? 'Current' : online ? method : 'Offline'; + const config = configs[state]; + const Icon = customIcon || config.icon || null; + const dotColor = customColor || config.color || 'bg-ink-dull'; + return ( -
-
- - {labels[method]} - -
+ +
+ {Icon ? ( + + ) : !current && ( +
+ )} + + {config.label} + +
+ ); } @@ -362,17 +409,17 @@ function DeviceCard({

{deviceName}

- {device?.connection_method && ( - - )}

{volumesLoading ? 'Loading volumes...' : `${volumes.length} ${volumes.length === 1 ? 'volume' : 'volumes'}`} - {device?.is_online === false && ' • Offline'} + {/* {device?.is_online === false && ' • Offline'} */}

diff --git a/packages/ts-client/src/generated/types.ts b/packages/ts-client/src/generated/types.ts index 70ce90c33..01d23d07a 100644 --- a/packages/ts-client/src/generated/types.ts +++ b/packages/ts-client/src/generated/types.ts @@ -94,7 +94,11 @@ services: ServiceConfigOutput; /** * Daemon logging configuration */ -logging: LoggingConfigOutput }; +logging: LoggingConfigOutput; +/** + * Proxy pairing configuration + */ +proxy_pairing: ProxyPairingConfigOutput }; export type ApplyTagsInput = { /** @@ -220,17 +224,20 @@ export type CompositionRule = { operator: CompositionOperator; operands: string[ */ export type ConnectionMethod = /** - * Direct peer-to-peer connection (mDNS/local network) + * Direct connection on local network (mDNS/same subnet) + * Fastest option - wire speed, no internet required */ -"Direct" | +"LocalNetwork" | /** - * Connection via relay server + * Direct UDP connection over internet (NAT traversal) + * Fast, but requires internet. Uses no relay bandwidth. */ -"Relay" | +"DirectInternet" | /** - * Mixed connection (both direct and relay) + * Connection proxied through relay server + * Reliable fallback. Relay hosts the bandwidth. */ -"Mixed"; +"RelayProxy"; /** * Domain representation of content identity @@ -643,10 +650,6 @@ last_seen_at: string; * Whether sync is enabled for this device */ sync_enabled: boolean; -/** - * Last time this device synced - */ -last_sync_at: string | null; /** * When this device was first added */ @@ -672,6 +675,8 @@ is_connected?: boolean; */ connection_method?: ConnectionMethod | null }; +export type DeviceDebugInfo = { uuid: string; name: string; sync_enabled: boolean; has_node_id: boolean; node_id: string | null }; + /** * Device form factor types */ @@ -1033,7 +1038,7 @@ error_type: string } } | { LibraryStatisticsUpdated: { library_id: string; stati * Refresh event - signals that all frontend caches should be invalidated * Emitted after major data recalculations (e.g., volume unique_bytes refresh) */ -"Refresh" | { EntryCreated: { library_id: string; entry_id: string } } | { EntryModified: { library_id: string; entry_id: string } } | { EntryDeleted: { library_id: string; entry_id: string } } | { EntryMoved: { library_id: string; entry_id: string; old_path: string; new_path: string } } | { FsRawChange: { library_id: string; kind: FsRawEventKind } } | { VolumeAdded: Volume } | { VolumeRemoved: { fingerprint: VolumeFingerprint } } | { VolumeUpdated: { fingerprint: VolumeFingerprint; old_info: VolumeInfo; new_info: VolumeInfo } } | { VolumeSpeedTested: { fingerprint: VolumeFingerprint; read_speed_mbps: number; write_speed_mbps: number } } | { VolumeMountChanged: { fingerprint: VolumeFingerprint; is_mounted: boolean } } | { VolumeError: { fingerprint: VolumeFingerprint; error: string } } | { JobQueued: { job_id: string; job_type: string; device_id: string } } | { JobStarted: { job_id: string; job_type: string; device_id: string } } | { JobProgress: { job_id: string; job_type: string; device_id: string; progress: number; message: string | null; generic_progress: GenericProgress | null } } | { JobCompleted: { job_id: string; job_type: string; device_id: string; output: JobOutput } } | { JobFailed: { job_id: string; job_type: string; device_id: string; error: string } } | { JobCancelled: { job_id: string; job_type: string; device_id: string } } | { JobPaused: { job_id: string; device_id: string } } | { JobResumed: { job_id: string; device_id: string } } | { IndexingStarted: { location_id: string } } | { IndexingProgress: { location_id: string; processed: number; total: number | null } } | { IndexingCompleted: { location_id: string; total_files: number; total_dirs: number } } | { IndexingFailed: { location_id: string; error: string } } | { DeviceConnected: { device_id: string; device_name: string } } | { DeviceDisconnected: { device_id: string } } | { SyncStateChanged: { library_id: string; previous_state: string; new_state: string; timestamp: string } } | { SyncActivity: { library_id: string; peer_device_id: string; activity_type: SyncActivityType; model_type: string | null; count: number; timestamp: string } } | { SyncConnectionChanged: { library_id: string; peer_device_id: string; peer_name: string; connected: boolean; timestamp: string } } | { SyncError: { library_id: string; peer_device_id: string | null; error_type: string; message: string; timestamp: string } } | { ResourceChanged: { +"Refresh" | { ProxyPairingConfirmationRequired: { session_id: string; vouchee_device_name: string; vouchee_device_os: string; voucher_device_name: string; voucher_device_id: string; expires_at: string } } | { ProxyPairingVouchingReady: { session_id: string; vouchee_device_id: string } } | { EntryCreated: { library_id: string; entry_id: string } } | { EntryModified: { library_id: string; entry_id: string } } | { EntryDeleted: { library_id: string; entry_id: string } } | { EntryMoved: { library_id: string; entry_id: string; old_path: string; new_path: string } } | { FsRawChange: { library_id: string; kind: FsRawEventKind } } | { VolumeAdded: Volume } | { VolumeRemoved: { fingerprint: VolumeFingerprint } } | { VolumeUpdated: { fingerprint: VolumeFingerprint; old_info: VolumeInfo; new_info: VolumeInfo } } | { VolumeSpeedTested: { fingerprint: VolumeFingerprint; read_speed_mbps: number; write_speed_mbps: number } } | { VolumeMountChanged: { fingerprint: VolumeFingerprint; is_mounted: boolean } } | { VolumeError: { fingerprint: VolumeFingerprint; error: string } } | { JobQueued: { job_id: string; job_type: string; device_id: string } } | { JobStarted: { job_id: string; job_type: string; device_id: string } } | { JobProgress: { job_id: string; job_type: string; device_id: string; progress: number; message: string | null; generic_progress: GenericProgress | null } } | { JobCompleted: { job_id: string; job_type: string; device_id: string; output: JobOutput } } | { JobFailed: { job_id: string; job_type: string; device_id: string; error: string } } | { JobCancelled: { job_id: string; job_type: string; device_id: string } } | { JobPaused: { job_id: string; device_id: string } } | { JobResumed: { job_id: string; device_id: string } } | { IndexingStarted: { location_id: string } } | { IndexingProgress: { location_id: string; processed: number; total: number | null } } | { IndexingCompleted: { location_id: string; total_files: number; total_dirs: number } } | { IndexingFailed: { location_id: string; error: string } } | { DeviceConnected: { device_id: string; device_name: string } } | { DeviceDisconnected: { device_id: string } } | { SyncStateChanged: { library_id: string; previous_state: string; new_state: string; timestamp: string } } | { SyncActivity: { library_id: string; peer_device_id: string; activity_type: SyncActivityType; model_type: string | null; count: number; timestamp: string } } | { SyncConnectionChanged: { library_id: string; peer_device_id: string; peer_name: string; connected: boolean; timestamp: string } } | { SyncError: { library_id: string; peer_device_id: string | null; error_type: string; message: string; timestamp: string } } | { ResourceChanged: { /** * Resource type identifier (e.g., "location", "tag", "album") */ @@ -1619,6 +1624,10 @@ export type GetSyncMetricsOutput = { */ metrics: SyncMetricsSnapshot }; +export type GetSyncPartnersInput = Record; + +export type GetSyncPartnersOutput = { partners: SyncPartnerInfo[]; debug_info: SyncPartnersDebugInfo }; + /** * Types of groups that can appear in a space */ @@ -3061,6 +3070,10 @@ export type PairCancelInput = { session_id: string }; export type PairCancelOutput = { cancelled: boolean }; +export type PairConfirmProxyInput = { session_id: string; accepted: boolean }; + +export type PairConfirmProxyOutput = { success: boolean; error: string | null }; + export type PairGenerateInput = Record; export type PairGenerateOutput = { code: string; session_id: string; expires_at: string; @@ -3085,6 +3098,10 @@ export type PairStatusOutput = { sessions: PairingSessionSummary[] }; export type PairStatusQueryInput = null; +export type PairVouchInput = { session_id: string; target_device_ids: string[] }; + +export type PairVouchOutput = { success: boolean; pending_count: number }; + /** * Information about a paired device */ @@ -3207,6 +3224,11 @@ bytes_completed: number | null; */ total_bytes: number | null }; +/** + * Proxy pairing configuration output + */ +export type ProxyPairingConfigOutput = { auto_accept_vouched: boolean; auto_vouch_to_all: boolean; vouch_signature_max_age: number; vouch_response_timeout: number; vouch_queue_retry_limit: number }; + /** * Proxy/sidecar generation policy (video scrubbing) */ @@ -3892,6 +3914,10 @@ performance: PerformanceSnapshot; */ errors: ErrorSnapshot }; +export type SyncPartnerInfo = { device_uuid: string; device_name: string; is_paired: boolean }; + +export type SyncPartnersDebugInfo = { total_devices: number; sync_enabled_devices: number; paired_devices: number; final_sync_partners: number; device_details: DeviceDebugInfo[] }; + /** * State metrics snapshot */ @@ -4198,7 +4224,27 @@ job_logging_enabled?: boolean | null; /** * Whether to include debug logs in job logs */ -job_logging_include_debug?: boolean | null }; +job_logging_include_debug?: boolean | null; +/** + * Automatically accept vouches from trusted devices + */ +proxy_pairing_auto_accept_vouched?: boolean | null; +/** + * Automatically vouch new devices to all paired devices + */ +proxy_pairing_auto_vouch_to_all?: boolean | null; +/** + * Maximum age of vouch signatures in seconds + */ +proxy_pairing_vouch_signature_max_age?: number | null; +/** + * Timeout for proxy confirmation in seconds + */ +proxy_pairing_vouch_response_timeout?: number | null; +/** + * Maximum retries for queued vouches + */ +proxy_pairing_vouch_queue_retry_limit?: number | null }; /** * Output for update app configuration action @@ -4676,6 +4722,18 @@ volume_id: string; * Whether the operation was successful */ success: boolean }; + +export type VouchState = { device_id: string; device_name: string; status: VouchStatus; updated_at: string; reason: string | null }; + +export type VouchStatus = "Selected" | "Queued" | "Waiting" | "Accepted" | "Rejected" | "Unreachable"; + +export type VouchingSession = { id: string; vouchee_device_id: string; vouchee_device_name: string; voucher_device_id: string; created_at: string; state: VouchingSessionState; vouches: VouchState[] }; + +export type VouchingSessionInput = { session_id: string }; + +export type VouchingSessionOutput = { session: VouchingSession | null }; + +export type VouchingSessionState = "Pending" | "InProgress" | "Completed"; // ===== API Type Unions ===== export type CoreAction = @@ -4690,8 +4748,10 @@ export type CoreAction = | { type: 'models.whisper.download'; input: DownloadWhisperModelInput; output: DownloadWhisperModelOutput } | { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput } | { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput } + | { type: 'network.pair.confirmProxy'; input: PairConfirmProxyInput; output: PairConfirmProxyOutput } | { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput } | { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput } + | { type: 'network.pair.vouch'; input: PairVouchInput; output: PairVouchOutput } | { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput } | { type: 'network.start'; input: NetworkStartInput; output: NetworkStartOutput } | { type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput } @@ -4759,6 +4819,7 @@ export type CoreQuery = | { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput } | { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput } | { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput } + | { type: 'network.pair.vouching_session'; input: VouchingSessionInput; output: VouchingSessionOutput } | { type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus } | { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput } ; @@ -4788,6 +4849,7 @@ export type LibraryQuery = | { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput } | { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput } | { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput } + | { type: 'sync.partners'; input: GetSyncPartnersInput; output: GetSyncPartnersOutput } | { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput } | { type: 'test.ping'; input: PingInput; output: PingOutput } | { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput } @@ -4808,8 +4870,10 @@ export const WIRE_METHODS = { 'models.whisper.download': 'action:models.whisper.download.input', 'network.device.revoke': 'action:network.device.revoke.input', 'network.pair.cancel': 'action:network.pair.cancel.input', + 'network.pair.confirmProxy': 'action:network.pair.confirmProxy.input', 'network.pair.generate': 'action:network.pair.generate.input', 'network.pair.join': 'action:network.pair.join.input', + 'network.pair.vouch': 'action:network.pair.vouch.input', 'network.spacedrop.send': 'action:network.spacedrop.send.input', 'network.start': 'action:network.start.input', 'network.stop': 'action:network.stop.input', @@ -4877,6 +4941,7 @@ export const WIRE_METHODS = { 'models.whisper.list': 'query:models.whisper.list', 'network.devices.list': 'query:network.devices.list', 'network.pair.status': 'query:network.pair.status', + 'network.pair.vouching_session': 'query:network.pair.vouching_session', 'network.status': 'query:network.status', 'network.sync_setup.discover': 'query:network.sync_setup.discover', }, @@ -4906,6 +4971,7 @@ export const WIRE_METHODS = { 'sync.activity': 'query:sync.activity', 'sync.eventLog': 'query:sync.eventLog', 'sync.metrics': 'query:sync.metrics', + 'sync.partners': 'query:sync.partners', 'tags.search': 'query:tags.search', 'test.ping': 'query:test.ping', 'volumes.list': 'query:volumes.list',