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::{
|
||||
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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user