mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
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:
@@ -10,7 +10,7 @@ use data_encoding::HEXLOWER;
|
|||||||
use iroh::{
|
use iroh::{
|
||||||
endpoint::Connection,
|
endpoint::Connection,
|
||||||
protocol::{AcceptError, ProtocolHandler},
|
protocol::{AcceptError, ProtocolHandler},
|
||||||
Endpoint, EndpointAddr, EndpointId,
|
Endpoint, EndpointAddr, EndpointId, RelayUrl,
|
||||||
};
|
};
|
||||||
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
|
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
|
||||||
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
|
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
|
||||||
@@ -20,13 +20,16 @@ use sqlx::{Row, SqlitePool};
|
|||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{DeviceRelationship, DeviceRelationshipState, SavedDevice},
|
api::{
|
||||||
|
experimental_saved_device_capabilities, CoreRelayMode, DeviceRelationship,
|
||||||
|
DeviceRelationshipState, SavedDevice,
|
||||||
|
},
|
||||||
error::VnidropError,
|
error::VnidropError,
|
||||||
event_hub::EventHub,
|
event_hub::EventHub,
|
||||||
grant::{Challenge, GrantId, GrantProof, GrantSecret},
|
grant::{Challenge, GrantId, GrantProof, GrantSecret},
|
||||||
pairing_eligibility::PairingEligibilityService,
|
pairing_eligibility::PairingEligibilityService,
|
||||||
secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial},
|
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,
|
util::now_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,10 +56,16 @@ pub(crate) struct DeviceRelationshipService {
|
|||||||
event_hub: Arc<EventHub>,
|
event_hub: Arc<EventHub>,
|
||||||
local_endpoint_id: String,
|
local_endpoint_id: String,
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
|
relay_mode: CoreRelayMode,
|
||||||
|
custom_relay_urls: Vec<RelayUrl>,
|
||||||
peer_locks: Arc<TokioMutex<HashMap<String, Arc<TokioMutex<()>>>>>,
|
peer_locks: Arc<TokioMutex<HashMap<String, Arc<TokioMutex<()>>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceRelationshipService {
|
impl DeviceRelationshipService {
|
||||||
|
#[allow(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "constructor wires custody, eligibility, endpoint, and network profile once"
|
||||||
|
)]
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
custody: Option<Arc<SecretCustody>>,
|
custody: Option<Arc<SecretCustody>>,
|
||||||
@@ -64,6 +73,8 @@ impl DeviceRelationshipService {
|
|||||||
event_hub: Arc<EventHub>,
|
event_hub: Arc<EventHub>,
|
||||||
local_endpoint_id: String,
|
local_endpoint_id: String,
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
|
relay_mode: CoreRelayMode,
|
||||||
|
custom_relay_urls: Vec<RelayUrl>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
@@ -72,6 +83,8 @@ impl DeviceRelationshipService {
|
|||||||
event_hub,
|
event_hub,
|
||||||
local_endpoint_id,
|
local_endpoint_id,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
relay_mode,
|
||||||
|
custom_relay_urls,
|
||||||
peer_locks: Arc::new(TokioMutex::new(HashMap::new())),
|
peer_locks: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,6 +524,12 @@ impl DeviceRelationshipService {
|
|||||||
Ok(capability) => capability,
|
Ok(capability) => capability,
|
||||||
Err(_) => return PairingRequestResponse::Rejected,
|
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
|
let accepted = match self
|
||||||
.eligibility
|
.eligibility
|
||||||
.validate_presented_capability(&remote_endpoint_id, &request.session_id, &capability)
|
.validate_presented_capability(&remote_endpoint_id, &request.session_id, &capability)
|
||||||
@@ -569,6 +588,11 @@ impl DeviceRelationshipService {
|
|||||||
if row.state != DeviceRelationshipState::PendingOutgoing {
|
if row.state != DeviceRelationshipState::PendingOutgoing {
|
||||||
return PairingConsentResponse::Rejected;
|
return PairingConsentResponse::Rejected;
|
||||||
}
|
}
|
||||||
|
if consent.protocol_version < row.minimum_protocol_version
|
||||||
|
|| consent.generation != row.generation
|
||||||
|
{
|
||||||
|
return PairingConsentResponse::Rejected;
|
||||||
|
}
|
||||||
if !consent.accepted {
|
if !consent.accepted {
|
||||||
// Peer declined: clear local pending; eligibility was already consumed by requester.
|
// Peer declined: clear local pending; eligibility was already consumed by requester.
|
||||||
let _ = self.delete_relationship(&remote_endpoint_id).await;
|
let _ = self.delete_relationship(&remote_endpoint_id).await;
|
||||||
@@ -895,6 +919,13 @@ impl DeviceRelationshipService {
|
|||||||
"relationship generation mismatch"
|
"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(
|
self.verify_issued_possession(
|
||||||
peer_endpoint_id,
|
peer_endpoint_id,
|
||||||
challenge,
|
challenge,
|
||||||
@@ -917,6 +948,24 @@ impl DeviceRelationshipService {
|
|||||||
Ok(())
|
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(
|
async fn prove_held_possession(
|
||||||
&self,
|
&self,
|
||||||
peer_endpoint_id: &str,
|
peer_endpoint_id: &str,
|
||||||
@@ -1158,13 +1207,21 @@ impl DeviceRelationshipService {
|
|||||||
.parse()
|
.parse()
|
||||||
.context("unusable peer endpoint id")
|
.context("unusable peer endpoint id")
|
||||||
.map_err(VnidropError::invalid_input)?;
|
.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);
|
let mut addr = EndpointAddr::from(parsed);
|
||||||
addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect();
|
addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect();
|
||||||
let _ = encode_persisted_sender_address(&addr);
|
let _ = encode_persisted_sender_address(&addr);
|
||||||
return Ok(addr);
|
addr
|
||||||
}
|
} else {
|
||||||
Ok(EndpointAddr::from(parsed))
|
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) {
|
fn emit_changed(&self, peer_endpoint_id: &str, state: DeviceRelationshipState) {
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ pub enum VnidropError {
|
|||||||
StorageFull { reason: String },
|
StorageFull { reason: String },
|
||||||
#[error("network error: {reason}")]
|
#[error("network error: {reason}")]
|
||||||
Network { reason: String },
|
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}")]
|
#[error("transfer error: {reason}")]
|
||||||
Transfer { reason: String },
|
Transfer { reason: String },
|
||||||
#[error("permission error: {reason}")]
|
#[error("permission error: {reason}")]
|
||||||
@@ -60,6 +68,24 @@ impl VnidropError {
|
|||||||
Self::from_error(error.into(), |reason| Self::Network { reason })
|
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 {
|
pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self {
|
||||||
let error = error.into();
|
let error = error.into();
|
||||||
Self::classify(error, |reason| Self::Transfer { reason })
|
Self::classify(error, |reason| Self::Transfer { reason })
|
||||||
@@ -96,6 +122,10 @@ impl VnidropError {
|
|||||||
Self::DestinationExists { .. } => "destination_exists",
|
Self::DestinationExists { .. } => "destination_exists",
|
||||||
Self::StorageFull { .. } => "storage_full",
|
Self::StorageFull { .. } => "storage_full",
|
||||||
Self::Network { .. } => "network",
|
Self::Network { .. } => "network",
|
||||||
|
Self::DeviceUnavailable { .. } => "device_unavailable",
|
||||||
|
Self::OfferTimeout { .. } => "offer_timeout",
|
||||||
|
Self::RelayPolicyIncompatible { .. } => "relay_policy_incompatible",
|
||||||
|
Self::ProtocolIncompatible { .. } => "protocol_incompatible",
|
||||||
Self::Transfer { .. } => "transfer",
|
Self::Transfer { .. } => "transfer",
|
||||||
Self::Permission { .. } => "permission_denied",
|
Self::Permission { .. } => "permission_denied",
|
||||||
Self::Repository { .. } => "repository",
|
Self::Repository { .. } => "repository",
|
||||||
@@ -119,6 +149,10 @@ impl VnidropError {
|
|||||||
| Self::DestinationExists { reason }
|
| Self::DestinationExists { reason }
|
||||||
| Self::StorageFull { reason }
|
| Self::StorageFull { reason }
|
||||||
| Self::Network { reason }
|
| Self::Network { reason }
|
||||||
|
| Self::DeviceUnavailable { reason }
|
||||||
|
| Self::OfferTimeout { reason }
|
||||||
|
| Self::RelayPolicyIncompatible { reason }
|
||||||
|
| Self::ProtocolIncompatible { reason }
|
||||||
| Self::Transfer { reason }
|
| Self::Transfer { reason }
|
||||||
| Self::Permission { reason }
|
| Self::Permission { reason }
|
||||||
| Self::Repository { reason }
|
| Self::Repository { reason }
|
||||||
@@ -176,6 +210,10 @@ impl VnidropError {
|
|||||||
Self::DestinationExists { .. } => Self::DestinationExists { reason },
|
Self::DestinationExists { .. } => Self::DestinationExists { reason },
|
||||||
Self::StorageFull { .. } => Self::StorageFull { reason },
|
Self::StorageFull { .. } => Self::StorageFull { reason },
|
||||||
Self::Network { .. } => Self::Network { 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::Transfer { .. } => Self::Transfer { reason },
|
||||||
Self::Permission { .. } => Self::Permission { reason },
|
Self::Permission { .. } => Self::Permission { reason },
|
||||||
Self::Repository { .. } => Self::Repository { reason },
|
Self::Repository { .. } => Self::Repository { reason },
|
||||||
|
|||||||
@@ -76,6 +76,20 @@ impl VnidropCore {
|
|||||||
app_data_dir: String,
|
app_data_dir: String,
|
||||||
event_sink: Arc<dyn CoreEventSink>,
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
|
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> {
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
let app_data_path = PathBuf::from(&app_data_dir);
|
let app_data_path = PathBuf::from(&app_data_dir);
|
||||||
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
|
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
|
||||||
@@ -86,7 +100,7 @@ impl VnidropCore {
|
|||||||
app_data_dir,
|
app_data_dir,
|
||||||
event_sink,
|
event_sink,
|
||||||
CoreLimits::default(),
|
CoreLimits::default(),
|
||||||
CoreNetworkConfig::default(),
|
network_config,
|
||||||
IdentityMode::Protected {
|
IdentityMode::Protected {
|
||||||
store,
|
store,
|
||||||
profile_lock,
|
profile_lock,
|
||||||
@@ -159,6 +173,21 @@ impl VnidropCore {
|
|||||||
pub(crate) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
|
pub(crate) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
|
||||||
self.inner.targeted_cancel_log_for_test()
|
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]
|
#[uniffi::export]
|
||||||
|
|||||||
@@ -404,6 +404,8 @@ impl CoreInner {
|
|||||||
event_hub.clone(),
|
event_hub.clone(),
|
||||||
endpoint.id().to_string(),
|
endpoint.id().to_string(),
|
||||||
endpoint.clone(),
|
endpoint.clone(),
|
||||||
|
relay_mode,
|
||||||
|
relay_urls.clone(),
|
||||||
));
|
));
|
||||||
let router = Router::builder(endpoint.clone())
|
let router = Router::builder(endpoint.clone())
|
||||||
.accept(iroh_blobs::ALPN, blobs)
|
.accept(iroh_blobs::ALPN, blobs)
|
||||||
@@ -424,6 +426,8 @@ impl CoreInner {
|
|||||||
repository.sqlite_pool(),
|
repository.sqlite_pool(),
|
||||||
limits.clone(),
|
limits.clone(),
|
||||||
endpoint.id().to_string(),
|
endpoint.id().to_string(),
|
||||||
|
relay_mode,
|
||||||
|
relay_urls.clone(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.spawn();
|
.spawn();
|
||||||
@@ -538,38 +542,7 @@ pub(crate) fn filter_peer_addr_for_relay_mode(
|
|||||||
relay_mode: CoreRelayMode,
|
relay_mode: CoreRelayMode,
|
||||||
custom_relay_urls: &[RelayUrl],
|
custom_relay_urls: &[RelayUrl],
|
||||||
) -> Result<EndpointAddr> {
|
) -> Result<EndpointAddr> {
|
||||||
match relay_mode {
|
crate::ticket::filter_peer_addr_for_relay_mode(addr, relay_mode, custom_relay_urls)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn wait_for_relay(
|
pub(crate) async fn wait_for_relay(
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use crate::{
|
|||||||
targeted_transfer::{
|
targeted_transfer::{
|
||||||
auth_secret_material,
|
auth_secret_material,
|
||||||
protocol::{
|
protocol::{
|
||||||
CancelTargetedOffer, DeliverTargetedAuthorization, SubmitTargetedOffer,
|
map_offer_refuse_reason, CancelTargetedOffer, DeliverTargetedAuthorization,
|
||||||
TargetedOfferResponse, TargetedTransferProtocol,
|
SubmitTargetedOffer, TargetedOfferResponse, TargetedTransferProtocol,
|
||||||
},
|
},
|
||||||
reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft,
|
reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft,
|
||||||
TargetedTransferRole, TargetedTransferRow, TargetedTransferStore,
|
TargetedTransferRole, TargetedTransferRow, TargetedTransferStore,
|
||||||
@@ -212,12 +212,16 @@ impl CoreInner {
|
|||||||
Err(crate::targeted_transfer::RespondError::Unknown) => Err(
|
Err(crate::targeted_transfer::RespondError::Unknown) => Err(
|
||||||
VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")),
|
VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")),
|
||||||
),
|
),
|
||||||
Err(crate::targeted_transfer::RespondError::SenderGone) => Err(VnidropError::network(
|
Err(crate::targeted_transfer::RespondError::SenderGone) => {
|
||||||
anyhow::anyhow!("sender disconnected before approval completed"),
|
Err(VnidropError::device_unavailable(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::AuthorizationTimeout) => {
|
||||||
|
Err(VnidropError::offer_timeout(anyhow::anyhow!(
|
||||||
|
"authorization was not delivered in time"
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,11 +286,34 @@ impl CoreInner {
|
|||||||
.peer_addr(&receiver_endpoint_id)
|
.peer_addr(&receiver_endpoint_id)
|
||||||
.await?;
|
.await?;
|
||||||
let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr);
|
let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr);
|
||||||
let challenge = tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge())
|
let challenge =
|
||||||
.await
|
match tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge()).await {
|
||||||
.map_err(|_| VnidropError::network(anyhow::anyhow!("device did not answer in time")))?
|
Ok(Ok(challenge)) => challenge,
|
||||||
.context("device is not reachable")
|
Ok(Err(error)) => {
|
||||||
.map_err(VnidropError::network)?;
|
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
|
let (proof, generation, relationship_protocol_version) = self
|
||||||
.device_relationships
|
.device_relationships
|
||||||
@@ -303,7 +330,7 @@ impl CoreInner {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let response = tokio::time::timeout(
|
let response = match tokio::time::timeout(
|
||||||
OFFER_CONNECT_TIMEOUT + std::time::Duration::from_secs(120),
|
OFFER_CONNECT_TIMEOUT + std::time::Duration::from_secs(120),
|
||||||
client.submit_offer(SubmitTargetedOffer {
|
client.submit_offer(SubmitTargetedOffer {
|
||||||
proof,
|
proof,
|
||||||
@@ -318,12 +345,42 @@ impl CoreInner {
|
|||||||
transfer_name: share.transfer_name.clone(),
|
transfer_name: share.transfer_name.clone(),
|
||||||
file_count: share.file_count,
|
file_count: share.file_count,
|
||||||
total_size: share.total_size,
|
total_size: share.total_size,
|
||||||
|
relay_mode: self.relay_mode,
|
||||||
|
relay_urls: self
|
||||||
|
.custom_relay_urls
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| VnidropError::network(anyhow::anyhow!("offer timed out")))?
|
{
|
||||||
.context("failed to submit targeted offer")
|
Ok(Ok(response)) => response,
|
||||||
.map_err(VnidropError::network)?;
|
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 {
|
match response {
|
||||||
TargetedOfferResponse::Accepted => {}
|
TargetedOfferResponse::Accepted => {}
|
||||||
@@ -349,9 +406,7 @@ impl CoreInner {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
|
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
|
||||||
return Err(VnidropError::permission(anyhow::anyhow!(
|
return Err(map_offer_refuse_reason(&reason));
|
||||||
"targeted offer refused: {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 {
|
trait BlobTicketParse {
|
||||||
fn from_str_compat(value: &str) -> Result<BlobTicket, String>;
|
fn from_str_compat(value: &str) -> Result<BlobTicket, String>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ use anyhow::Result;
|
|||||||
use iroh::{
|
use iroh::{
|
||||||
endpoint::Connection,
|
endpoint::Connection,
|
||||||
protocol::{AcceptError, ProtocolHandler},
|
protocol::{AcceptError, ProtocolHandler},
|
||||||
Endpoint, EndpointAddr,
|
Endpoint, EndpointAddr, RelayUrl,
|
||||||
};
|
};
|
||||||
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
|
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
|
||||||
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
|
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::SqlitePool;
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
auth::TargetedAuthorization,
|
auth::TargetedAuthorization,
|
||||||
@@ -22,10 +21,14 @@ use super::{
|
|||||||
state_as_str, TargetedTransferStore,
|
state_as_str, TargetedTransferStore,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{experimental_saved_device_capabilities, PendingTargetedOffer, TargetedTransferState},
|
api::{
|
||||||
|
experimental_saved_device_capabilities, CoreRelayMode, PendingTargetedOffer,
|
||||||
|
TargetedTransferState,
|
||||||
|
},
|
||||||
device_relationship::{DeviceRelationshipService, WireProof},
|
device_relationship::{DeviceRelationshipService, WireProof},
|
||||||
error::VnidropError,
|
error::VnidropError,
|
||||||
grant::Challenge,
|
grant::Challenge,
|
||||||
|
ticket::relay_profiles_compatible,
|
||||||
util::now_ms,
|
util::now_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +39,8 @@ pub(crate) struct TargetedTransferProtocol {
|
|||||||
store: TargetedTransferStore,
|
store: TargetedTransferStore,
|
||||||
limits: crate::api::CoreLimits,
|
limits: crate::api::CoreLimits,
|
||||||
local_endpoint_id: String,
|
local_endpoint_id: String,
|
||||||
|
relay_mode: CoreRelayMode,
|
||||||
|
custom_relay_urls: Vec<RelayUrl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for TargetedTransferProtocol {
|
impl fmt::Debug for TargetedTransferProtocol {
|
||||||
@@ -50,9 +55,11 @@ impl TargetedTransferProtocol {
|
|||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
relationships: std::sync::Arc<DeviceRelationshipService>,
|
relationships: std::sync::Arc<DeviceRelationshipService>,
|
||||||
inbox: TargetedOfferInbox,
|
inbox: TargetedOfferInbox,
|
||||||
pool: SqlitePool,
|
pool: sqlx::SqlitePool,
|
||||||
limits: crate::api::CoreLimits,
|
limits: crate::api::CoreLimits,
|
||||||
local_endpoint_id: String,
|
local_endpoint_id: String,
|
||||||
|
relay_mode: CoreRelayMode,
|
||||||
|
custom_relay_urls: Vec<RelayUrl>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
relationships,
|
relationships,
|
||||||
@@ -60,6 +67,8 @@ impl TargetedTransferProtocol {
|
|||||||
store: TargetedTransferStore::new(pool),
|
store: TargetedTransferStore::new(pool),
|
||||||
limits,
|
limits,
|
||||||
local_endpoint_id,
|
local_endpoint_id,
|
||||||
|
relay_mode,
|
||||||
|
custom_relay_urls,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,10 +156,26 @@ impl TargetedTransferProtocol {
|
|||||||
}
|
}
|
||||||
TargetedTransferState::Preparing
|
TargetedTransferState::Preparing
|
||||||
| TargetedTransferState::Offering
|
| TargetedTransferState::Offering
|
||||||
| TargetedTransferState::AwaitingApproval => {
|
| TargetedTransferState::AwaitingApproval => TargetedOfferResponse::Accepted,
|
||||||
// Live offer path below may still be pending.
|
};
|
||||||
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
|
.await
|
||||||
{
|
{
|
||||||
tracing::debug!(%error, "targeted offer relationship proof rejected");
|
tracing::debug!(%error, "targeted offer relationship proof rejected");
|
||||||
|
if matches!(error, VnidropError::ProtocolIncompatible { .. }) {
|
||||||
|
return TargetedOfferResponse::Refused {
|
||||||
|
reason: "protocol-incompatible".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
return TargetedOfferResponse::Refused {
|
return TargetedOfferResponse::Refused {
|
||||||
reason: "unauthenticated".to_string(),
|
reason: "unauthenticated".to_string(),
|
||||||
};
|
};
|
||||||
@@ -242,7 +272,6 @@ impl TargetedTransferProtocol {
|
|||||||
if row.sender_endpoint_id != remote_endpoint_id {
|
if row.sender_endpoint_id != remote_endpoint_id {
|
||||||
return CancelTargetedOfferResponse::Rejected;
|
return CancelTargetedOfferResponse::Rejected;
|
||||||
}
|
}
|
||||||
// Already gone from the live inbox; treat as idempotent success.
|
|
||||||
return CancelTargetedOfferResponse::Cancelled;
|
return CancelTargetedOfferResponse::Cancelled;
|
||||||
}
|
}
|
||||||
CancelTargetedOfferResponse::Cancelled
|
CancelTargetedOfferResponse::Cancelled
|
||||||
@@ -343,6 +372,8 @@ pub(crate) struct SubmitTargetedOffer {
|
|||||||
pub(crate) transfer_name: String,
|
pub(crate) transfer_name: String,
|
||||||
pub(crate) file_count: u64,
|
pub(crate) file_count: u64,
|
||||||
pub(crate) total_size: u64,
|
pub(crate) total_size: u64,
|
||||||
|
pub(crate) relay_mode: CoreRelayMode,
|
||||||
|
pub(crate) relay_urls: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -377,6 +408,10 @@ pub(crate) enum CancelTargetedOfferResponse {
|
|||||||
|
|
||||||
#[rpc_requests(message = TargetedTransferMessage)]
|
#[rpc_requests(message = TargetedTransferMessage)]
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[allow(
|
||||||
|
clippy::large_enum_variant,
|
||||||
|
reason = "offer payload carries relay profile + manifest summary; boxing breaks irpc channels"
|
||||||
|
)]
|
||||||
enum TargetedTransferMessages {
|
enum TargetedTransferMessages {
|
||||||
#[rpc(tx = oneshot::Sender<ChallengeResponse>)]
|
#[rpc(tx = oneshot::Sender<ChallengeResponse>)]
|
||||||
RequestChallenge(RequestChallenge),
|
RequestChallenge(RequestChallenge),
|
||||||
@@ -388,8 +423,26 @@ enum TargetedTransferMessages {
|
|||||||
CancelTargetedOffer(CancelTargetedOffer),
|
CancelTargetedOffer(CancelTargetedOffer),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper kept for type visibility in callers that map refuse reasons.
|
fn parse_offer_relay_urls(values: &[String]) -> Result<Vec<RelayUrl>, ()> {
|
||||||
#[allow(dead_code)]
|
let mut urls = Vec::with_capacity(values.len());
|
||||||
pub(crate) fn map_offer_error(reason: &str) -> VnidropError {
|
for value in values {
|
||||||
VnidropError::permission(anyhow::anyhow!("targeted offer refused: {reason}"))
|
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}")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,3 +68,29 @@ fn transfer_boundary_classifies_database_failures() {
|
|||||||
assert!(matches!(transfer, VnidropError::Repository { .. }));
|
assert!(matches!(transfer, VnidropError::Repository { .. }));
|
||||||
assert!(matches!(approval, 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 { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, DeviceRelationshipState,
|
secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, CoreNetworkConfig,
|
||||||
PendingTargetedOffer, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState,
|
CoreRelayMode, DeviceRelationshipState, PendingTargetedOffer, ShareMetadataInput, ShareSource,
|
||||||
TransferAccessMode, VnidropCore, VnidropError,
|
SourceKind, TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RecordingSink {
|
struct RecordingSink {
|
||||||
@@ -23,25 +23,32 @@ impl CoreEventSink for RecordingSink {
|
|||||||
struct ProtectedNode {
|
struct ProtectedNode {
|
||||||
data_dir: tempfile::TempDir,
|
data_dir: tempfile::TempDir,
|
||||||
secret_store: Arc<FaultInjectingSecretStore>,
|
secret_store: Arc<FaultInjectingSecretStore>,
|
||||||
|
network_config: CoreNetworkConfig,
|
||||||
core: Option<Arc<VnidropCore>>,
|
core: Option<Arc<VnidropCore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProtectedNode {
|
impl ProtectedNode {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
|
Self::with_network_config(CoreNetworkConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_network_config(network_config: CoreNetworkConfig) -> Self {
|
||||||
let data_dir = tempfile::tempdir().unwrap();
|
let data_dir = tempfile::tempdir().unwrap();
|
||||||
let sink = Arc::new(RecordingSink {
|
let sink = Arc::new(RecordingSink {
|
||||||
events: Mutex::new(Vec::new()),
|
events: Mutex::new(Vec::new()),
|
||||||
});
|
});
|
||||||
let store = Arc::new(FaultInjectingSecretStore::default());
|
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(),
|
data_dir.path().to_string_lossy().into_owned(),
|
||||||
sink,
|
sink,
|
||||||
store.clone(),
|
store.clone(),
|
||||||
|
network_config.clone(),
|
||||||
)
|
)
|
||||||
.expect("protected test core");
|
.expect("protected test core");
|
||||||
Self {
|
Self {
|
||||||
data_dir,
|
data_dir,
|
||||||
secret_store: store,
|
secret_store: store,
|
||||||
|
network_config,
|
||||||
core: Some(core),
|
core: Some(core),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,10 +64,11 @@ impl ProtectedNode {
|
|||||||
let sink = Arc::new(RecordingSink {
|
let sink = Arc::new(RecordingSink {
|
||||||
events: Mutex::new(Vec::new()),
|
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(),
|
self.data_dir.path().to_string_lossy().into_owned(),
|
||||||
sink,
|
sink,
|
||||||
self.secret_store.clone(),
|
self.secret_store.clone(),
|
||||||
|
self.network_config.clone(),
|
||||||
)
|
)
|
||||||
.expect("restarted protected test core");
|
.expect("restarted protected test core");
|
||||||
self.core = Some(core);
|
self.core = Some(core);
|
||||||
@@ -809,3 +817,283 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() {
|
|||||||
)
|
)
|
||||||
.is_err());
|
.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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use crate::{
|
|||||||
api::{CoreLimits, CoreRelayMode, TransferMetadata},
|
api::{CoreLimits, CoreRelayMode, TransferMetadata},
|
||||||
ticket::{
|
ticket::{
|
||||||
encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_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);
|
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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
fn normalize_ticket_input(value: &str) -> String {
|
||||||
// Tickets are commonly copied from text views or chat apps that insert line
|
// Tickets are commonly copied from text views or chat apps that insert line
|
||||||
// breaks. Strip whitespace only; other corrupt characters should still be
|
// breaks. Strip whitespace only; other corrupt characters should still be
|
||||||
|
|||||||
Reference in New Issue
Block a user