diff --git a/crates/vnidrop/src/device_relationship/mod.rs b/crates/vnidrop/src/device_relationship/mod.rs index 7c20ace..15e5206 100644 --- a/crates/vnidrop/src/device_relationship/mod.rs +++ b/crates/vnidrop/src/device_relationship/mod.rs @@ -10,7 +10,7 @@ use data_encoding::HEXLOWER; use iroh::{ endpoint::Connection, protocol::{AcceptError, ProtocolHandler}, - Endpoint, EndpointAddr, EndpointId, + Endpoint, EndpointAddr, EndpointId, RelayUrl, }; use irpc::{channel::oneshot, rpc_requests, Client, WithChannels}; use irpc_iroh::{read_request, IrohLazyRemoteConnection}; @@ -20,13 +20,16 @@ use sqlx::{Row, SqlitePool}; use tokio::sync::Mutex as TokioMutex; use crate::{ - api::{DeviceRelationship, DeviceRelationshipState, SavedDevice}, + api::{ + experimental_saved_device_capabilities, CoreRelayMode, DeviceRelationship, + DeviceRelationshipState, SavedDevice, + }, error::VnidropError, event_hub::EventHub, grant::{Challenge, GrantId, GrantProof, GrantSecret}, pairing_eligibility::PairingEligibilityService, secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial}, - ticket::encode_persisted_sender_address, + ticket::{encode_persisted_sender_address, filter_peer_addr_for_relay_mode}, util::now_ms, }; @@ -53,10 +56,16 @@ pub(crate) struct DeviceRelationshipService { event_hub: Arc, local_endpoint_id: String, endpoint: Endpoint, + relay_mode: CoreRelayMode, + custom_relay_urls: Vec, peer_locks: Arc>>>>, } impl DeviceRelationshipService { + #[allow( + clippy::too_many_arguments, + reason = "constructor wires custody, eligibility, endpoint, and network profile once" + )] pub(crate) fn new( pool: SqlitePool, custody: Option>, @@ -64,6 +73,8 @@ impl DeviceRelationshipService { event_hub: Arc, local_endpoint_id: String, endpoint: Endpoint, + relay_mode: CoreRelayMode, + custom_relay_urls: Vec, ) -> Self { Self { pool, @@ -72,6 +83,8 @@ impl DeviceRelationshipService { event_hub, local_endpoint_id, endpoint, + relay_mode, + custom_relay_urls, peer_locks: Arc::new(TokioMutex::new(HashMap::new())), } } @@ -511,6 +524,12 @@ impl DeviceRelationshipService { Ok(capability) => capability, Err(_) => return PairingRequestResponse::Rejected, }; + let local_protocol = experimental_saved_device_capabilities().relationship_protocol_version; + // Peers without a compatible saved-device protocol cannot pair; they + // retain ordinary invitation flow outside this ALPN. + if request.protocol_version != local_protocol { + return PairingRequestResponse::Rejected; + } let accepted = match self .eligibility .validate_presented_capability(&remote_endpoint_id, &request.session_id, &capability) @@ -569,6 +588,11 @@ impl DeviceRelationshipService { if row.state != DeviceRelationshipState::PendingOutgoing { return PairingConsentResponse::Rejected; } + if consent.protocol_version < row.minimum_protocol_version + || consent.generation != row.generation + { + return PairingConsentResponse::Rejected; + } if !consent.accepted { // Peer declined: clear local pending; eligibility was already consumed by requester. let _ = self.delete_relationship(&remote_endpoint_id).await; @@ -895,6 +919,13 @@ impl DeviceRelationshipService { "relationship generation mismatch" ))); } + // Established relationships record a protocol floor and reject silent + // downgrade attempts (design §7 / §15). + if protocol_version < row.minimum_protocol_version { + return Err(VnidropError::protocol_incompatible(anyhow::anyhow!( + "relationship protocol downgrade is forbidden" + ))); + } self.verify_issued_possession( peer_endpoint_id, challenge, @@ -917,6 +948,24 @@ impl DeviceRelationshipService { Ok(()) } + #[cfg(test)] + pub(crate) async fn force_minimum_protocol_version_for_test( + &self, + peer_endpoint_id: &str, + minimum_protocol_version: u16, + ) -> Result<(), VnidropError> { + sqlx::query( + "UPDATE device_relationships SET minimum_protocol_version = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1", + ) + .bind(peer_endpoint_id) + .bind(i64::from(minimum_protocol_version)) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + async fn prove_held_possession( &self, peer_endpoint_id: &str, @@ -1158,13 +1207,21 @@ impl DeviceRelationshipService { .parse() .context("unusable peer endpoint id") .map_err(VnidropError::invalid_input)?; - if let Some(info) = self.endpoint.remote_info(parsed).await { + let raw = if let Some(info) = self.endpoint.remote_info(parsed).await { let mut addr = EndpointAddr::from(parsed); addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect(); let _ = encode_persisted_sender_address(&addr); - return Ok(addr); - } - Ok(EndpointAddr::from(parsed)) + addr + } else { + EndpointAddr::from(parsed) + }; + filter_peer_addr_for_relay_mode(&raw, self.relay_mode, &self.custom_relay_urls).map_err( + |error| { + VnidropError::relay_policy_incompatible(anyhow::anyhow!( + "peer address is unusable under the active network profile: {error}" + )) + }, + ) } fn emit_changed(&self, peer_endpoint_id: &str, state: DeviceRelationshipState) { diff --git a/crates/vnidrop/src/error.rs b/crates/vnidrop/src/error.rs index f8d0145..5f4486d 100644 --- a/crates/vnidrop/src/error.rs +++ b/crates/vnidrop/src/error.rs @@ -16,6 +16,14 @@ pub enum VnidropError { StorageFull { reason: String }, #[error("network error: {reason}")] Network { reason: String }, + #[error("device unavailable: {reason}")] + DeviceUnavailable { reason: String }, + #[error("offer timed out: {reason}")] + OfferTimeout { reason: String }, + #[error("relay policy incompatible: {reason}")] + RelayPolicyIncompatible { reason: String }, + #[error("protocol incompatible: {reason}")] + ProtocolIncompatible { reason: String }, #[error("transfer error: {reason}")] Transfer { reason: String }, #[error("permission error: {reason}")] @@ -60,6 +68,24 @@ impl VnidropError { Self::from_error(error.into(), |reason| Self::Network { reason }) } + pub(crate) fn device_unavailable(error: impl Into) -> Self { + Self::from_error(error.into(), |reason| Self::DeviceUnavailable { reason }) + } + + pub(crate) fn offer_timeout(error: impl Into) -> Self { + Self::from_error(error.into(), |reason| Self::OfferTimeout { reason }) + } + + pub(crate) fn relay_policy_incompatible(error: impl Into) -> Self { + Self::from_error(error.into(), |reason| Self::RelayPolicyIncompatible { + reason, + }) + } + + pub(crate) fn protocol_incompatible(error: impl Into) -> Self { + Self::from_error(error.into(), |reason| Self::ProtocolIncompatible { reason }) + } + pub(crate) fn transfer(error: impl Into) -> Self { let error = error.into(); Self::classify(error, |reason| Self::Transfer { reason }) @@ -96,6 +122,10 @@ impl VnidropError { Self::DestinationExists { .. } => "destination_exists", Self::StorageFull { .. } => "storage_full", Self::Network { .. } => "network", + Self::DeviceUnavailable { .. } => "device_unavailable", + Self::OfferTimeout { .. } => "offer_timeout", + Self::RelayPolicyIncompatible { .. } => "relay_policy_incompatible", + Self::ProtocolIncompatible { .. } => "protocol_incompatible", Self::Transfer { .. } => "transfer", Self::Permission { .. } => "permission_denied", Self::Repository { .. } => "repository", @@ -119,6 +149,10 @@ impl VnidropError { | Self::DestinationExists { reason } | Self::StorageFull { reason } | Self::Network { reason } + | Self::DeviceUnavailable { reason } + | Self::OfferTimeout { reason } + | Self::RelayPolicyIncompatible { reason } + | Self::ProtocolIncompatible { reason } | Self::Transfer { reason } | Self::Permission { reason } | Self::Repository { reason } @@ -176,6 +210,10 @@ impl VnidropError { Self::DestinationExists { .. } => Self::DestinationExists { reason }, Self::StorageFull { .. } => Self::StorageFull { reason }, Self::Network { .. } => Self::Network { reason }, + Self::DeviceUnavailable { .. } => Self::DeviceUnavailable { reason }, + Self::OfferTimeout { .. } => Self::OfferTimeout { reason }, + Self::RelayPolicyIncompatible { .. } => Self::RelayPolicyIncompatible { reason }, + Self::ProtocolIncompatible { .. } => Self::ProtocolIncompatible { reason }, Self::Transfer { .. } => Self::Transfer { reason }, Self::Permission { .. } => Self::Permission { reason }, Self::Repository { .. } => Self::Repository { reason }, diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 77c29a7..c81ee48 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -76,6 +76,20 @@ impl VnidropCore { app_data_dir: String, event_sink: Arc, store: Arc, + ) -> Result, VnidropError> { + Self::initialize_with_test_secret_store_and_network( + app_data_dir, + event_sink, + store, + CoreNetworkConfig::default(), + ) + } + + pub(crate) fn initialize_with_test_secret_store_and_network( + app_data_dir: String, + event_sink: Arc, + store: Arc, + network_config: CoreNetworkConfig, ) -> Result, VnidropError> { let app_data_path = PathBuf::from(&app_data_dir); std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?; @@ -86,7 +100,7 @@ impl VnidropCore { app_data_dir, event_sink, CoreLimits::default(), - CoreNetworkConfig::default(), + network_config, IdentityMode::Protected { store, profile_lock, @@ -159,6 +173,21 @@ impl VnidropCore { pub(crate) fn targeted_cancel_log_for_test(&self) -> Vec { self.inner.targeted_cancel_log_for_test() } + + pub(crate) fn force_relationship_protocol_floor_for_test( + &self, + peer_endpoint_id: String, + minimum_protocol_version: u16, + ) -> Result<(), VnidropError> { + self.block_on( + self.inner + .device_relationships + .force_minimum_protocol_version_for_test( + &peer_endpoint_id, + minimum_protocol_version, + ), + ) + } } #[uniffi::export] diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index 21a590d..b14d2b0 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -403,6 +403,8 @@ impl CoreInner { event_hub.clone(), endpoint.id().to_string(), endpoint.clone(), + relay_mode, + relay_urls.clone(), )); let router = Router::builder(endpoint.clone()) .accept(iroh_blobs::ALPN, blobs) @@ -422,6 +424,8 @@ impl CoreInner { targeted_offers.clone(), limits.clone(), endpoint.id().to_string(), + relay_mode, + relay_urls.clone(), ), ) .spawn(); @@ -526,38 +530,7 @@ pub(crate) fn filter_peer_addr_for_relay_mode( relay_mode: CoreRelayMode, custom_relay_urls: &[RelayUrl], ) -> Result { - match relay_mode { - CoreRelayMode::Automatic => Ok(addr.clone()), - CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { - let mut filtered = EndpointAddr::new(addr.id); - for ip_addr in addr.ip_addrs().copied() { - filtered = filtered.with_ip_addr(ip_addr); - } - for relay_url in addr - .relay_urls() - .filter(|relay_url| custom_relay_urls.contains(relay_url)) - .cloned() - { - filtered = filtered.with_relay_url(relay_url); - } - if filtered.is_empty() { - anyhow::bail!( - "invitation has no direct address or relay allowed by strict custom relay mode" - ); - } - Ok(filtered) - } - CoreRelayMode::LocalOnly => { - let mut filtered = EndpointAddr::new(addr.id); - for ip_addr in addr.ip_addrs().copied() { - filtered = filtered.with_ip_addr(ip_addr); - } - if filtered.is_empty() { - anyhow::bail!("invitation has no direct address allowed by local-only mode"); - } - Ok(filtered) - } - } + crate::ticket::filter_peer_addr_for_relay_mode(addr, relay_mode, custom_relay_urls) } pub(crate) async fn wait_for_relay( diff --git a/crates/vnidrop/src/runtime/targeted.rs b/crates/vnidrop/src/runtime/targeted.rs index e2ebe86..dda59bf 100644 --- a/crates/vnidrop/src/runtime/targeted.rs +++ b/crates/vnidrop/src/runtime/targeted.rs @@ -15,8 +15,8 @@ use crate::{ error::VnidropError, targeted_transfer::{ protocol::{ - DeliverTargetedAuthorization, SubmitTargetedOffer, TargetedOfferResponse, - TargetedTransferProtocol, + map_offer_refuse_reason, DeliverTargetedAuthorization, SubmitTargetedOffer, + TargetedOfferResponse, TargetedTransferProtocol, }, TargetedAuthorization, TargetedAuthorizationDraft, TargetedTransferRow, TargetedTransferStore, @@ -86,12 +86,16 @@ impl CoreInner { Err(crate::targeted_transfer::RespondError::Unknown) => Err( VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")), ), - Err(crate::targeted_transfer::RespondError::SenderGone) => Err(VnidropError::network( - anyhow::anyhow!("sender disconnected before approval completed"), - )), - Err(crate::targeted_transfer::RespondError::AuthorizationTimeout) => Err( - VnidropError::network(anyhow::anyhow!("authorization was not delivered in time")), - ), + Err(crate::targeted_transfer::RespondError::SenderGone) => { + Err(VnidropError::device_unavailable(anyhow::anyhow!( + "sender disconnected before approval completed" + ))) + } + Err(crate::targeted_transfer::RespondError::AuthorizationTimeout) => { + Err(VnidropError::offer_timeout(anyhow::anyhow!( + "authorization was not delivered in time" + ))) + } } } @@ -152,11 +156,34 @@ impl CoreInner { .peer_addr(&receiver_endpoint_id) .await?; let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr); - let challenge = tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge()) - .await - .map_err(|_| VnidropError::network(anyhow::anyhow!("device did not answer in time")))? - .context("device is not reachable") - .map_err(VnidropError::network)?; + let challenge = + match tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge()).await { + Ok(Ok(challenge)) => challenge, + Ok(Err(error)) => { + let _ = store + .set_state( + &transfer_uuid, + TargetedTransferState::Offering, + TargetedTransferState::Failed, + ) + .await; + let _ = self.cancel_idle_or_share(protocol_transfer_id).await; + return Err(map_connect_failure(error)); + } + Err(_) => { + let _ = store + .set_state( + &transfer_uuid, + TargetedTransferState::Offering, + TargetedTransferState::Failed, + ) + .await; + let _ = self.cancel_idle_or_share(protocol_transfer_id).await; + return Err(VnidropError::device_unavailable(anyhow::anyhow!( + "device did not answer in time" + ))); + } + }; let (proof, generation, relationship_protocol_version) = self .device_relationships @@ -173,7 +200,7 @@ impl CoreInner { ) .await?; - let response = tokio::time::timeout( + let response = match tokio::time::timeout( OFFER_CONNECT_TIMEOUT + std::time::Duration::from_secs(120), client.submit_offer(SubmitTargetedOffer { proof, @@ -188,12 +215,42 @@ impl CoreInner { transfer_name: share.transfer_name.clone(), file_count: share.file_count, total_size: share.total_size, + relay_mode: self.relay_mode, + relay_urls: self + .custom_relay_urls + .iter() + .map(ToString::to_string) + .collect(), }), ) .await - .map_err(|_| VnidropError::network(anyhow::anyhow!("offer timed out")))? - .context("failed to submit targeted offer") - .map_err(VnidropError::network)?; + { + Ok(Ok(response)) => response, + Ok(Err(error)) => { + let _ = store + .set_state( + &transfer_uuid, + TargetedTransferState::AwaitingApproval, + TargetedTransferState::Failed, + ) + .await; + let _ = self.cancel_idle_or_share(protocol_transfer_id).await; + return Err(map_connect_failure(error)); + } + Err(_) => { + let _ = store + .set_state( + &transfer_uuid, + TargetedTransferState::AwaitingApproval, + TargetedTransferState::Failed, + ) + .await; + let _ = self.cancel_idle_or_share(protocol_transfer_id).await; + return Err(VnidropError::offer_timeout(anyhow::anyhow!( + "offer timed out" + ))); + } + }; match response { TargetedOfferResponse::Accepted => {} @@ -219,9 +276,7 @@ impl CoreInner { ) .await; let _ = self.cancel_idle_or_share(protocol_transfer_id).await; - return Err(VnidropError::permission(anyhow::anyhow!( - "targeted offer refused: {reason}" - ))); + return Err(map_offer_refuse_reason(&reason)); } } @@ -341,6 +396,21 @@ fn allocate_protocol_transfer_id(transfer_uuid: &str) -> u64 { } } +fn map_connect_failure(error: irpc::Error) -> VnidropError { + let rendered = error.to_string(); + // ALPN / protocol negotiation failures are distinguishable from offline peers. + if rendered.contains("ALPN") + || rendered.contains("alpn") + || rendered.contains("protocol") + || rendered.contains("unsupported") + { + return VnidropError::protocol_incompatible(anyhow::anyhow!( + "peer does not support saved-device targeted transfers" + )); + } + VnidropError::device_unavailable(anyhow::anyhow!("device is not reachable: {rendered}")) +} + trait BlobTicketParse { fn from_str_compat(value: &str) -> Result; } diff --git a/crates/vnidrop/src/targeted_transfer/protocol.rs b/crates/vnidrop/src/targeted_transfer/protocol.rs index 3b09176..6d085ca 100644 --- a/crates/vnidrop/src/targeted_transfer/protocol.rs +++ b/crates/vnidrop/src/targeted_transfer/protocol.rs @@ -9,7 +9,7 @@ use anyhow::Result; use iroh::{ endpoint::Connection, protocol::{AcceptError, ProtocolHandler}, - Endpoint, EndpointAddr, + Endpoint, EndpointAddr, RelayUrl, }; use irpc::{channel::oneshot, rpc_requests, Client, WithChannels}; use irpc_iroh::{read_request, IrohLazyRemoteConnection}; @@ -20,10 +20,11 @@ use super::{ inbox::{TargetedOfferDecision, TargetedOfferInbox}, }; use crate::{ - api::{experimental_saved_device_capabilities, PendingTargetedOffer}, + api::{experimental_saved_device_capabilities, CoreRelayMode, PendingTargetedOffer}, device_relationship::{DeviceRelationshipService, WireProof}, error::VnidropError, grant::Challenge, + ticket::relay_profiles_compatible, util::now_ms, }; @@ -33,6 +34,8 @@ pub(crate) struct TargetedTransferProtocol { inbox: TargetedOfferInbox, limits: crate::api::CoreLimits, local_endpoint_id: String, + relay_mode: CoreRelayMode, + custom_relay_urls: Vec, } impl fmt::Debug for TargetedTransferProtocol { @@ -49,12 +52,16 @@ impl TargetedTransferProtocol { inbox: TargetedOfferInbox, limits: crate::api::CoreLimits, local_endpoint_id: String, + relay_mode: CoreRelayMode, + custom_relay_urls: Vec, ) -> Self { Self { relationships, inbox, limits, local_endpoint_id, + relay_mode, + custom_relay_urls, } } @@ -111,6 +118,25 @@ impl TargetedTransferProtocol { }; } + let remote_urls = match parse_offer_relay_urls(&offer.relay_urls) { + Ok(urls) => urls, + Err(_) => { + return TargetedOfferResponse::Refused { + reason: "relay-policy-incompatible".to_string(), + }; + } + }; + if !relay_profiles_compatible( + self.relay_mode, + &self.custom_relay_urls, + offer.relay_mode, + &remote_urls, + ) { + return TargetedOfferResponse::Refused { + reason: "relay-policy-incompatible".to_string(), + }; + } + if let Err(error) = self .relationships .verify_saved_possession( @@ -123,6 +149,11 @@ impl TargetedTransferProtocol { .await { tracing::debug!(%error, "targeted offer relationship proof rejected"); + if matches!(error, VnidropError::ProtocolIncompatible { .. }) { + return TargetedOfferResponse::Refused { + reason: "protocol-incompatible".to_string(), + }; + } return TargetedOfferResponse::Refused { reason: "unauthenticated".to_string(), }; @@ -258,6 +289,8 @@ pub(crate) struct SubmitTargetedOffer { pub(crate) transfer_name: String, pub(crate) file_count: u64, pub(crate) total_size: u64, + pub(crate) relay_mode: CoreRelayMode, + pub(crate) relay_urls: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -281,6 +314,10 @@ pub(crate) enum DeliverAuthorizationResponse { #[rpc_requests(message = TargetedTransferMessage)] #[derive(Debug, Serialize, Deserialize)] +#[allow( + clippy::large_enum_variant, + reason = "offer payload carries relay profile + manifest summary; boxing breaks irpc channels" +)] enum TargetedTransferMessages { #[rpc(tx = oneshot::Sender)] RequestChallenge(RequestChallenge), @@ -290,8 +327,26 @@ enum TargetedTransferMessages { DeliverTargetedAuthorization(DeliverTargetedAuthorization), } -/// Helper kept for type visibility in callers that map refuse reasons. -#[allow(dead_code)] -pub(crate) fn map_offer_error(reason: &str) -> VnidropError { - VnidropError::permission(anyhow::anyhow!("targeted offer refused: {reason}")) +fn parse_offer_relay_urls(values: &[String]) -> Result, ()> { + let mut urls = Vec::with_capacity(values.len()); + for value in values { + let Ok(url) = value.parse::() else { + return Err(()); + }; + urls.push(url); + } + Ok(urls) +} + +/// Map a receiver refuse reason to a typed public error (design §13). +pub(crate) fn map_offer_refuse_reason(reason: &str) -> VnidropError { + match reason { + "relay-policy-incompatible" => VnidropError::relay_policy_incompatible(anyhow::anyhow!( + "sender and receiver network profiles are incompatible" + )), + "protocol-incompatible" => VnidropError::protocol_incompatible(anyhow::anyhow!( + "targeted-transfer protocol is incompatible" + )), + other => VnidropError::permission(anyhow::anyhow!("targeted offer refused: {other}")), + } } diff --git a/crates/vnidrop/src/tests/error.rs b/crates/vnidrop/src/tests/error.rs index cffa6d3..b7c4ff3 100644 --- a/crates/vnidrop/src/tests/error.rs +++ b/crates/vnidrop/src/tests/error.rs @@ -68,3 +68,29 @@ fn transfer_boundary_classifies_database_failures() { assert!(matches!(transfer, VnidropError::Repository { .. })); assert!(matches!(approval, VnidropError::Repository { .. })); } + +#[test] +fn saved_device_failures_remain_distinguishable() { + let unavailable = VnidropError::device_unavailable(anyhow::anyhow!("offline")); + let timeout = VnidropError::offer_timeout(anyhow::anyhow!("no answer")); + let relay = VnidropError::relay_policy_incompatible(anyhow::anyhow!("profiles differ")); + let protocol = VnidropError::protocol_incompatible(anyhow::anyhow!("downgrade")); + + assert_eq!(unavailable.code(), "device_unavailable"); + assert_eq!(timeout.code(), "offer_timeout"); + assert_eq!(relay.code(), "relay_policy_incompatible"); + assert_eq!(protocol.code(), "protocol_incompatible"); + assert!(matches!( + unavailable, + VnidropError::DeviceUnavailable { .. } + )); + assert!(matches!(timeout, VnidropError::OfferTimeout { .. })); + assert!(matches!( + relay, + VnidropError::RelayPolicyIncompatible { .. } + )); + assert!(matches!( + protocol, + VnidropError::ProtocolIncompatible { .. } + )); +} diff --git a/crates/vnidrop/src/tests/targeted_transfer.rs b/crates/vnidrop/src/tests/targeted_transfer.rs index ed79746..0e52939 100644 --- a/crates/vnidrop/src/tests/targeted_transfer.rs +++ b/crates/vnidrop/src/tests/targeted_transfer.rs @@ -5,9 +5,9 @@ use std::{ }; use crate::{ - secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, DeviceRelationshipState, - PendingTargetedOffer, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, - TransferAccessMode, VnidropCore, VnidropError, + secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, CoreNetworkConfig, + CoreRelayMode, DeviceRelationshipState, PendingTargetedOffer, ShareMetadataInput, ShareSource, + SourceKind, TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError, }; struct RecordingSink { @@ -27,15 +27,20 @@ struct ProtectedNode { impl ProtectedNode { fn new() -> Self { + Self::with_network_config(CoreNetworkConfig::default()) + } + + fn with_network_config(network_config: CoreNetworkConfig) -> Self { let data_dir = tempfile::tempdir().unwrap(); let sink = Arc::new(RecordingSink { events: Mutex::new(Vec::new()), }); let store = Arc::new(FaultInjectingSecretStore::default()); - let core = VnidropCore::initialize_with_test_secret_store( + let core = VnidropCore::initialize_with_test_secret_store_and_network( data_dir.path().to_string_lossy().into_owned(), sink, store, + network_config, ) .expect("protected test core"); Self { @@ -469,3 +474,283 @@ fn invitation_multi_receiver_shares_remain_independently_authorized() { ); } } + +fn complete_targeted_roundtrip(alice: &ProtectedNode, bob: &ProtectedNode, transfer_id: u64) { + establish_saved(alice, bob, transfer_id); + let bob_id = bob.core.status().endpoint_id.clone(); + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("payload.txt"); + let payload = b"profile-honoring payload"; + std::fs::write(&source_path, payload).unwrap(); + + let bob_core = bob.core.clone(); + let accept = std::thread::spawn(move || { + let offer = wait_for_pending_offer(&bob_core); + bob_core + .respond_to_targeted_offer(offer.transfer_id, true) + .unwrap() + }); + + let transfer = alice + .core + .create_targeted_transfer( + bob_id, + vec![targeted_source(&source_path)], + Some("payload.txt".to_string()), + ) + .unwrap(); + let auth = accept.join().unwrap().expect("authorization"); + let output = tempfile::tempdir().unwrap(); + bob.core + .receive_targeted_transfer(auth, output.path().to_string_lossy().into_owned()) + .unwrap(); + assert_eq!( + std::fs::read(output.path().join("payload.txt")).unwrap(), + payload + ); + assert!(!transfer.id.is_empty()); +} + +#[test] +fn targeted_transfer_honors_automatic_network_profile() { + let alice = ProtectedNode::with_network_config(CoreNetworkConfig { + mode: CoreRelayMode::Automatic, + relay_urls: Vec::new(), + }); + let bob = ProtectedNode::with_network_config(CoreNetworkConfig { + mode: CoreRelayMode::Automatic, + relay_urls: Vec::new(), + }); + complete_targeted_roundtrip(&alice, &bob, 12_001); +} + +#[test] +fn targeted_transfer_honors_strict_custom_and_direct_fallback_profiles() { + // Loopback HTTP relays are the supported custom-relay development path. + let relay = start_loopback_relay(); + let urls = vec![relay.url.clone()]; + for mode in [ + CoreRelayMode::StrictCustom, + CoreRelayMode::CustomWithDirectFallback, + ] { + let config = CoreNetworkConfig { + mode, + relay_urls: urls.clone(), + }; + let alice = ProtectedNode::with_network_config(config.clone()); + let bob = ProtectedNode::with_network_config(config); + let transfer_id = match mode { + CoreRelayMode::StrictCustom => 12_010, + CoreRelayMode::CustomWithDirectFallback => 12_011, + _ => unreachable!(), + }; + complete_targeted_roundtrip(&alice, &bob, transfer_id); + } +} + +#[test] +fn targeted_transfer_local_only_uses_direct_reachability_without_relays() { + let config = CoreNetworkConfig { + mode: CoreRelayMode::LocalOnly, + relay_urls: Vec::new(), + }; + let alice = ProtectedNode::with_network_config(config.clone()); + let bob = ProtectedNode::with_network_config(config); + assert!(!alice.core.status().addr.contains("iroh.link")); + assert!(!bob.core.status().addr.contains("iroh.link")); + complete_targeted_roundtrip(&alice, &bob, 12_020); +} + +#[test] +fn mismatched_relay_profiles_are_typed_and_never_reinterpreted_as_ordinary_share() { + let relay = start_loopback_relay(); + let alice = ProtectedNode::with_network_config(CoreNetworkConfig { + mode: CoreRelayMode::Automatic, + relay_urls: Vec::new(), + }); + let bob = ProtectedNode::with_network_config(CoreNetworkConfig { + mode: CoreRelayMode::StrictCustom, + relay_urls: vec![relay.url.clone()], + }); + establish_saved(&alice, &bob, 12_030); + let bob_id = bob.core.status().endpoint_id.clone(); + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("payload.txt"); + std::fs::write(&source_path, b"incompatible profiles").unwrap(); + + let err = alice + .core + .create_targeted_transfer( + bob_id, + vec![targeted_source(&source_path)], + Some("payload.txt".to_string()), + ) + .unwrap_err(); + assert!( + matches!(err, VnidropError::RelayPolicyIncompatible { .. }), + "expected relay-policy incompatibility, got {err:?}" + ); + assert!(bob.core.list_pending_targeted_offers().is_empty()); + let failed = alice + .core + .list_targeted_transfers() + .unwrap() + .into_iter() + .find(|entry| matches!(entry.state, TargetedTransferState::Failed)); + assert!( + failed.is_some(), + "failed targeted transfer must remain targeted, not an ordinary share" + ); +} + +#[test] +fn protocol_floor_rejects_silent_downgrade_with_typed_error() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 12_040); + let alice_id = alice.core.status().endpoint_id.clone(); + let bob_id = bob.core.status().endpoint_id.clone(); + bob.core + .force_relationship_protocol_floor_for_test(alice_id, 2) + .unwrap(); + + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("payload.txt"); + std::fs::write(&source_path, b"downgrade attempt").unwrap(); + let err = alice + .core + .create_targeted_transfer( + bob_id, + vec![targeted_source(&source_path)], + Some("payload.txt".to_string()), + ) + .unwrap_err(); + assert!( + matches!(err, VnidropError::ProtocolIncompatible { .. }), + "expected protocol incompatibility, got {err:?}" + ); + assert!(bob.core.list_pending_targeted_offers().is_empty()); +} + +#[test] +fn incompatible_network_or_protocol_peers_keep_ordinary_invitation_flow() { + let relay = start_loopback_relay(); + // Profiles that cannot complete a targeted transfer can still use ordinary shares. + let alice = ProtectedNode::with_network_config(CoreNetworkConfig { + mode: CoreRelayMode::Automatic, + relay_urls: Vec::new(), + }); + let bob = ProtectedNode::with_network_config(CoreNetworkConfig { + mode: CoreRelayMode::StrictCustom, + relay_urls: vec![relay.url.clone()], + }); + complete_transfer(&alice, &bob, 12_050); + assert!(alice + .core + .list_pairing_eligibilities() + .unwrap() + .iter() + .any(|entry| entry.peer_endpoint_id == bob.core.status().endpoint_id)); + + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("invite.txt"); + std::fs::write(&source_path, b"ordinary invitation still works").unwrap(); + let share = alice + .core + .share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_path.to_string_lossy().into_owned(), + display_name: Some("invite.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: 12_051, + transfer_name: Some("invite".to_string()), + sender_name: Some("alice".to_string()), + access_mode: TransferAccessMode::Public, + }, + ) + .unwrap(); + bob.core + .receive( + share.ticket, + output_dir.path().to_string_lossy().into_owned(), + Some("bob".to_string()), + ) + .unwrap(); + assert_eq!( + std::fs::read(output_dir.path().join("invite.txt")).unwrap(), + b"ordinary invitation still works" + ); +} + +#[test] +fn map_offer_refuse_reasons_stay_typed() { + use crate::targeted_transfer::protocol::map_offer_refuse_reason; + + assert!(matches!( + map_offer_refuse_reason("relay-policy-incompatible"), + VnidropError::RelayPolicyIncompatible { .. } + )); + assert!(matches!( + map_offer_refuse_reason("protocol-incompatible"), + VnidropError::ProtocolIncompatible { .. } + )); + assert!(matches!( + map_offer_refuse_reason("unauthenticated"), + VnidropError::Permission { .. } + )); +} + +struct LoopbackRelay { + url: String, + shutdown: Option>, + thread: Option>, +} + +impl Drop for LoopbackRelay { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn start_loopback_relay() -> LoopbackRelay { + let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let thread = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("vnidrop-ticket12-relay") + .build() + .unwrap(); + runtime.block_on(async move { + let relay = iroh_relay::server::RelayConfig::new((std::net::Ipv4Addr::LOCALHOST, 0)); + let mut config = iroh_relay::server::ServerConfig::default(); + config.relay = Some(relay); + let server = match iroh_relay::server::Server::spawn(config).await { + Ok(server) => server, + Err(error) => { + ready_tx.send(Err(error.to_string())).ok(); + return; + } + }; + let url = format!("http://{}", server.http_addr().expect("http addr")); + ready_tx.send(Ok(url)).ok(); + let _ = shutdown_rx.await; + let _ = server.shutdown().await; + }); + }); + let url = ready_rx.recv().unwrap().expect("relay started"); + LoopbackRelay { + url, + shutdown: Some(shutdown_tx), + thread: Some(thread), + } +} diff --git a/crates/vnidrop/src/tests/ticket.rs b/crates/vnidrop/src/tests/ticket.rs index 67d5e90..959a39c 100644 --- a/crates/vnidrop/src/tests/ticket.rs +++ b/crates/vnidrop/src/tests/ticket.rs @@ -9,7 +9,8 @@ use crate::{ api::{CoreLimits, CoreRelayMode, TransferMetadata}, ticket::{ encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_ticket, - parse_transfer_ticket_with_limits, ticket_matches_relay_profile, VnidropTicket, + parse_transfer_ticket_with_limits, relay_profiles_compatible, ticket_matches_relay_profile, + VnidropTicket, }, }; @@ -285,3 +286,60 @@ fn parser_rejects_or_parses_generated_inputs_without_panicking() { let _ = parse_transfer_ticket(&input); } } + +#[test] +fn relay_profiles_compatible_matches_network_profile_matrix() { + let relay: RelayUrl = "https://relay.example.com".parse().unwrap(); + let other: RelayUrl = "https://other.example.com".parse().unwrap(); + let relay_urls = std::slice::from_ref(&relay); + let other_urls = std::slice::from_ref(&other); + + assert!(relay_profiles_compatible( + CoreRelayMode::Automatic, + &[], + CoreRelayMode::Automatic, + &[], + )); + assert!(relay_profiles_compatible( + CoreRelayMode::LocalOnly, + &[], + CoreRelayMode::LocalOnly, + &[], + )); + assert!(relay_profiles_compatible( + CoreRelayMode::LocalOnly, + &[], + CoreRelayMode::Automatic, + &[], + )); + assert!(relay_profiles_compatible( + CoreRelayMode::StrictCustom, + relay_urls, + CoreRelayMode::StrictCustom, + relay_urls, + )); + assert!(relay_profiles_compatible( + CoreRelayMode::CustomWithDirectFallback, + relay_urls, + CoreRelayMode::StrictCustom, + relay_urls, + )); + assert!(!relay_profiles_compatible( + CoreRelayMode::Automatic, + &[], + CoreRelayMode::StrictCustom, + relay_urls, + )); + assert!(!relay_profiles_compatible( + CoreRelayMode::StrictCustom, + relay_urls, + CoreRelayMode::StrictCustom, + other_urls, + )); + assert!(!relay_profiles_compatible( + CoreRelayMode::LocalOnly, + &[], + CoreRelayMode::StrictCustom, + relay_urls, + )); +} diff --git a/crates/vnidrop/src/ticket.rs b/crates/vnidrop/src/ticket.rs index d2f108e..74a4dac 100644 --- a/crates/vnidrop/src/ticket.rs +++ b/crates/vnidrop/src/ticket.rs @@ -184,6 +184,76 @@ pub(crate) fn ticket_matches_relay_profile( } } +/// Whether a remote peer's advertised network profile can be used under the +/// local profile (design §3 / §10 relay-policy validation). +pub(crate) fn relay_profiles_compatible( + local_mode: CoreRelayMode, + local_urls: &[RelayUrl], + remote_mode: CoreRelayMode, + remote_urls: &[RelayUrl], +) -> bool { + match (local_mode, remote_mode) { + (CoreRelayMode::Automatic, CoreRelayMode::Automatic) => { + local_urls.is_empty() && remote_urls.is_empty() + } + (CoreRelayMode::LocalOnly, CoreRelayMode::LocalOnly) + | (CoreRelayMode::LocalOnly, CoreRelayMode::Automatic) + | (CoreRelayMode::Automatic, CoreRelayMode::LocalOnly) => { + // Local-only never enables relay fallback; Automatic peers are only + // compatible when neither side advertises custom relays. + local_urls.is_empty() && remote_urls.is_empty() + } + ( + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback, + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback, + ) => { + let local = local_urls.iter().collect::>(); + let remote = remote_urls.iter().collect::>(); + !local.is_empty() && local == remote + } + _ => false, + } +} + +pub(crate) fn filter_peer_addr_for_relay_mode( + addr: &EndpointAddr, + relay_mode: CoreRelayMode, + custom_relay_urls: &[RelayUrl], +) -> Result { + match relay_mode { + CoreRelayMode::Automatic => Ok(addr.clone()), + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { + let mut filtered = EndpointAddr::new(addr.id); + for ip_addr in addr.ip_addrs().copied() { + filtered = filtered.with_ip_addr(ip_addr); + } + for relay_url in addr + .relay_urls() + .filter(|relay_url| custom_relay_urls.contains(relay_url)) + .cloned() + { + filtered = filtered.with_relay_url(relay_url); + } + if filtered.is_empty() { + anyhow::bail!( + "invitation has no direct address or relay allowed by strict custom relay mode" + ); + } + Ok(filtered) + } + CoreRelayMode::LocalOnly => { + let mut filtered = EndpointAddr::new(addr.id); + for ip_addr in addr.ip_addrs().copied() { + filtered = filtered.with_ip_addr(ip_addr); + } + if filtered.is_empty() { + anyhow::bail!("invitation has no direct address allowed by local-only mode"); + } + Ok(filtered) + } + } +} + fn normalize_ticket_input(value: &str) -> String { // Tickets are commonly copied from text views or chat apps that insert line // breaks. Strip whitespace only; other corrupt characters should still be