merge: integrate resumable transfers with network profile compatibility

Combine ticket 11 resume/cancel/delete/idempotency with ticket 12 relay
profiles, protocol floors, and typed offer outcomes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 04:02:07 +02:00
10 changed files with 741 additions and 79 deletions

View File

@@ -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<EventHub>,
local_endpoint_id: String,
endpoint: Endpoint,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
peer_locks: Arc<TokioMutex<HashMap<String, Arc<TokioMutex<()>>>>>,
}
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<Arc<SecretCustody>>,
@@ -64,6 +73,8 @@ impl DeviceRelationshipService {
event_hub: Arc<EventHub>,
local_endpoint_id: String,
endpoint: Endpoint,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
) -> 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) {

View File

@@ -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<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::DeviceUnavailable { reason })
}
pub(crate) fn offer_timeout(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::OfferTimeout { reason })
}
pub(crate) fn relay_policy_incompatible(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::RelayPolicyIncompatible {
reason,
})
}
pub(crate) fn protocol_incompatible(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::ProtocolIncompatible { reason })
}
pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> 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 },

View File

@@ -76,6 +76,20 @@ impl VnidropCore {
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
) -> Result<Arc<Self>, 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<dyn CoreEventSink>,
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, 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<String> {
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]

View File

@@ -404,6 +404,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)
@@ -424,6 +426,8 @@ impl CoreInner {
repository.sqlite_pool(),
limits.clone(),
endpoint.id().to_string(),
relay_mode,
relay_urls.clone(),
),
)
.spawn();
@@ -538,38 +542,7 @@ pub(crate) fn filter_peer_addr_for_relay_mode(
relay_mode: CoreRelayMode,
custom_relay_urls: &[RelayUrl],
) -> Result<EndpointAddr> {
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(

View File

@@ -17,8 +17,8 @@ use crate::{
targeted_transfer::{
auth_secret_material,
protocol::{
CancelTargetedOffer, DeliverTargetedAuthorization, SubmitTargetedOffer,
TargetedOfferResponse, TargetedTransferProtocol,
map_offer_refuse_reason, CancelTargetedOffer, DeliverTargetedAuthorization,
SubmitTargetedOffer, TargetedOfferResponse, TargetedTransferProtocol,
},
reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft,
TargetedTransferRole, TargetedTransferRow, TargetedTransferStore,
@@ -212,12 +212,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"
)))
}
}
}
@@ -282,11 +286,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
@@ -303,7 +330,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,
@@ -318,12 +345,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 => {}
@@ -349,9 +406,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));
}
}
@@ -664,6 +719,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<BlobTicket, String>;
}

View File

@@ -9,12 +9,11 @@ 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};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use super::{
auth::TargetedAuthorization,
@@ -22,10 +21,14 @@ use super::{
state_as_str, TargetedTransferStore,
};
use crate::{
api::{experimental_saved_device_capabilities, PendingTargetedOffer, TargetedTransferState},
api::{
experimental_saved_device_capabilities, CoreRelayMode, PendingTargetedOffer,
TargetedTransferState,
},
device_relationship::{DeviceRelationshipService, WireProof},
error::VnidropError,
grant::Challenge,
ticket::relay_profiles_compatible,
util::now_ms,
};
@@ -36,6 +39,8 @@ pub(crate) struct TargetedTransferProtocol {
store: TargetedTransferStore,
limits: crate::api::CoreLimits,
local_endpoint_id: String,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
}
impl fmt::Debug for TargetedTransferProtocol {
@@ -50,9 +55,11 @@ impl TargetedTransferProtocol {
pub(crate) fn new(
relationships: std::sync::Arc<DeviceRelationshipService>,
inbox: TargetedOfferInbox,
pool: SqlitePool,
pool: sqlx::SqlitePool,
limits: crate::api::CoreLimits,
local_endpoint_id: String,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
) -> Self {
Self {
relationships,
@@ -60,6 +67,8 @@ impl TargetedTransferProtocol {
store: TargetedTransferStore::new(pool),
limits,
local_endpoint_id,
relay_mode,
custom_relay_urls,
}
}
@@ -147,10 +156,26 @@ impl TargetedTransferProtocol {
}
TargetedTransferState::Preparing
| TargetedTransferState::Offering
| TargetedTransferState::AwaitingApproval => {
// Live offer path below may still be pending.
TargetedOfferResponse::Accepted
}
| TargetedTransferState::AwaitingApproval => TargetedOfferResponse::Accepted,
};
}
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(),
};
}
@@ -166,6 +191,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(),
};
@@ -242,7 +272,6 @@ impl TargetedTransferProtocol {
if row.sender_endpoint_id != remote_endpoint_id {
return CancelTargetedOfferResponse::Rejected;
}
// Already gone from the live inbox; treat as idempotent success.
return CancelTargetedOfferResponse::Cancelled;
}
CancelTargetedOfferResponse::Cancelled
@@ -343,6 +372,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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -377,6 +408,10 @@ pub(crate) enum CancelTargetedOfferResponse {
#[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<ChallengeResponse>)]
RequestChallenge(RequestChallenge),
@@ -388,8 +423,26 @@ enum TargetedTransferMessages {
CancelTargetedOffer(CancelTargetedOffer),
}
/// 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<Vec<RelayUrl>, ()> {
let mut urls = Vec::with_capacity(values.len());
for value in values {
let Ok(url) = value.parse::<RelayUrl>() 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}")),
}
}

View File

@@ -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 { .. }
));
}

View File

@@ -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 {
@@ -23,25 +23,32 @@ impl CoreEventSink for RecordingSink {
struct ProtectedNode {
data_dir: tempfile::TempDir,
secret_store: Arc<FaultInjectingSecretStore>,
network_config: CoreNetworkConfig,
core: Option<Arc<VnidropCore>>,
}
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.clone(),
network_config.clone(),
)
.expect("protected test core");
Self {
data_dir,
secret_store: store,
network_config,
core: Some(core),
}
}
@@ -57,10 +64,11 @@ impl ProtectedNode {
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let core = VnidropCore::initialize_with_test_secret_store(
let core = VnidropCore::initialize_with_test_secret_store_and_network(
self.data_dir.path().to_string_lossy().into_owned(),
sink,
self.secret_store.clone(),
self.network_config.clone(),
)
.expect("restarted protected test core");
self.core = Some(core);
@@ -809,3 +817,283 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() {
)
.is_err());
}
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<tokio::sync::oneshot::Sender<()>>,
thread: Option<std::thread::JoinHandle<()>>,
}
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),
}
}

View File

@@ -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,
));
}

View File

@@ -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::<BTreeSet<_>>();
let remote = remote_urls.iter().collect::<BTreeSet<_>>();
!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<EndpointAddr> {
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