diff --git a/DESIGN-DEVICE-HISTORY.md b/DESIGN-DEVICE-HISTORY.md index 29273eb..c264183 100644 --- a/DESIGN-DEVICE-HISTORY.md +++ b/DESIGN-DEVICE-HISTORY.md @@ -429,6 +429,33 @@ wake-up notifications, not authoritative storage. They may be delivered at least once; consumers deduplicate by stable ID and revision, then query current state after reconnect or restart. +### 13.1 Pairing and targeted-transfer event catalog + +Canonical kinds emitted on `CoreEvent` (phase → kind). Treat every event as a +wake-up: refresh durable state via list/get APIs. Mid-transfer progress polish +(live `verified_bytes` updates) may follow; this catalog is the readiness bar. + +**`pairing`** + +| Kind | Meaning | +|---|---| +| `eligibility-available` | Pairing eligibility exists for a peer after a completed authenticated invitation transfer. | +| `eligibility-removed` | Eligibility expired or was consumed/removed. | +| `relationship-changed` | Device-relationship state changed (pending, saved, revoked, blocked). Payload includes peer id and state. | +| `relationship-grant-rotated` | Local relationship grant generation advanced for a peer. | +| `saved-device-forgotten` | Local forget completed for a saved peer. | +| `device-blocked` | Peer was blocked locally. | + +**`targeted_transfer`** + +| Kind | Meaning | +|---|---| +| `offer-received` | A pre-approval offer is pending local approve/decline. | +| `offer-accepted` | Local approval completed; authorization is in core custody. | +| `offer-declined` | Local decline completed. | + +See also [`crates/vnidrop/CORE_FLOW.md`](crates/vnidrop/CORE_FLOW.md) (same catalog, linked so the lists cannot fork). + Failures remain typed where callers can act differently, including: - Device unavailable or offer timeout. diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index f0d1fa4..556149a 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -41,11 +41,17 @@ bytes through Kotlin memory. - Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`, `stopped`. - Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`, - `access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`. + `access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`, + plus experimental `pairing` and `targeted_transfer` (see catalog below). - Events are sent to `CoreEventSink` immediately and persisted through the event hub. `list_events` flushes queued persistence before reading SQLite. - `shutdown()` is idempotent and flushes events before stopping the router. +### Pairing and targeted-transfer event catalog + +Canonical catalog: [DESIGN-DEVICE-HISTORY.md §13.1](../../DESIGN-DEVICE-HISTORY.md). +Do not maintain a second kind list here — link only. Events are wake-ups; query +durable state after receive. Mid-transfer progress polish may follow. ## Platform File Rules - Desktop uses normal filesystem paths. diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index b47aa95..12c7854 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -118,6 +118,16 @@ pub struct PendingTargetedOffer { pub received_at: i64, } +/// Local approve/decline outcome for a pending targeted offer. +/// +/// Authorization stays in core custody; callers only receive transfer ids. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum TargetedOfferResponse { + Approved { transfer_id: String }, + Declined, + AlreadySettled { transfer_id: String }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum CoreRelayMode { Automatic, diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index e396f59..e5f33a5 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -32,8 +32,9 @@ pub use api::{ DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, PairingEligibilitySummary, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice, - ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TargetedTransfer, - TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata, + ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, + TargetedOfferResponse, TargetedTransfer, TargetedTransferState, TicketInspection, + TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 7df21db..6f9b4ba 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -544,7 +544,8 @@ impl VnidropCore { /// Create an immutable one-receiver transfer and submit its pre-approval offer. /// /// Blocks until the saved receiver approves or declines. On approval the - /// receiver obtains bound authorization via [`Self::respond_to_targeted_offer`]. + /// receiver stores bound authorization locally via + /// [`Self::respond_to_targeted_offer`]. pub fn create_targeted_transfer( &self, receiver_endpoint_id: String, @@ -565,25 +566,47 @@ impl VnidropCore { /// Approve or decline a pending targeted offer. /// - /// On approval, returns the recipient-bound authorization capability used - /// with [`Self::receive_targeted_transfer`]. Declining returns `None`. + /// On approval, authorization stays in core custody; callers pull content + /// with [`Self::receive_targeted_transfer`] using the transfer id. pub fn respond_to_targeted_offer( &self, transfer_id: String, accepted: bool, - ) -> Result, VnidropError> { + ) -> Result { self.block_on(self.inner.respond_to_targeted_offer(transfer_id, accepted)) } /// Pull an approved targeted transfer through existing output-sink machinery. pub fn receive_targeted_transfer( &self, - authorization: String, + transfer_id: String, output_dir: String, ) -> Result<(), VnidropError> { self.block_on( self.inner - .receive_targeted_transfer(authorization, output_dir), + .receive_targeted_transfer(transfer_id, output_dir), + ) + } + + pub fn receive_targeted_transfer_with_output_sink( + &self, + transfer_id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.block_on( + self.inner + .receive_targeted_transfer_with_output_sink(transfer_id, output_sink), + ) + } + + pub fn receive_targeted_transfer_with_output_sink_v2( + &self, + transfer_id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.block_on( + self.inner + .receive_targeted_transfer_with_output_sink_v2(transfer_id, output_sink), ) } @@ -633,6 +656,28 @@ impl VnidropCore { self.block_on(self.inner.resume_targeted_transfer(id, output_dir)) } + pub fn resume_targeted_transfer_with_output_sink( + &self, + id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.block_on( + self.inner + .resume_targeted_transfer_with_output_sink(id, output_sink), + ) + } + + pub fn resume_targeted_transfer_with_output_sink_v2( + &self, + id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.block_on( + self.inner + .resume_targeted_transfer_with_output_sink_v2(id, output_sink), + ) + } + pub fn list_transfers(&self) -> Result, VnidropError> { self.block_on(self.inner.repository.list_transfers()) .map_err(VnidropError::repository) diff --git a/crates/vnidrop/src/runtime/targeted.rs b/crates/vnidrop/src/runtime/targeted.rs index 7dd38cf..7426572 100644 --- a/crates/vnidrop/src/runtime/targeted.rs +++ b/crates/vnidrop/src/runtime/targeted.rs @@ -6,11 +6,12 @@ use anyhow::{Context, Result}; use iroh_blobs::{ticket::BlobTicket, BlobFormat}; use uuid::Uuid; -use super::CoreInner; +use super::{receive::ReceiveTarget, CoreInner}; use crate::{ api::{ experimental_saved_device_capabilities, PendingTargetedOffer, ShareMetadataInput, - ShareSource, TargetedTransfer, TargetedTransferState, TransferAccessMode, TransferMetadata, + ShareSource, TargetedOfferResponse, TargetedTransfer, TargetedTransferState, + TransferAccessMode, TransferMetadata, }, error::VnidropError, secure_secret::{SecretHandle, SecretKind}, @@ -18,7 +19,7 @@ use crate::{ auth_secret_material, protocol::{ map_offer_refuse_reason, CancelTargetedOffer, DeliverTargetedAuthorization, - SubmitTargetedOffer, TargetedOfferResponse, TargetedTransferProtocol, + SubmitTargetedOffer, TargetedTransferProtocol, WireOfferResponse, }, reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft, TargetedTransferRole, TargetedTransferRow, @@ -195,26 +196,35 @@ impl CoreInner { self: &Arc, transfer_id: String, accepted: bool, - ) -> Result, VnidropError> { - if let Some(auth) = self - .targeted_offers - .settled_authorization(&transfer_id) - .await - { - return Ok(Some(auth)); + ) -> Result { + if self.targeted_offers.is_settled(&transfer_id).await { + return Ok(TargetedOfferResponse::AlreadySettled { transfer_id }); } if let Ok(Some(row)) = self.targeted_store().get_row(&transfer_id).await { - if let Some(encoded) = self.load_stored_authorization(&row).await? { - return Ok(Some(encoded)); + if self.load_stored_authorization(&row).await?.is_some() + || matches!( + row.state, + TargetedTransferState::Approved + | TargetedTransferState::Connecting + | TargetedTransferState::Transferring + | TargetedTransferState::Interrupted + | TargetedTransferState::Completed + | TargetedTransferState::Declined + | TargetedTransferState::Cancelled + | TargetedTransferState::Failed + | TargetedTransferState::Deleted + ) + { + return Ok(TargetedOfferResponse::AlreadySettled { transfer_id }); } } match self.targeted_offers.respond(&transfer_id, accepted).await { Ok(Some(auth)) => { self.persist_receiver_authorization(&auth).await?; - Ok(Some(auth)) + Ok(TargetedOfferResponse::Approved { transfer_id }) } - Ok(None) => Ok(None), + Ok(None) => Ok(TargetedOfferResponse::Declined), Err(crate::targeted_transfer::RespondError::Unknown) => Err( VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")), ), @@ -390,8 +400,8 @@ impl CoreInner { }; match response { - TargetedOfferResponse::Accepted => {} - TargetedOfferResponse::Declined { reason } => { + WireOfferResponse::Accepted => {} + WireOfferResponse::Declined { reason } => { let _ = store .set_state( &transfer_uuid, @@ -404,7 +414,7 @@ impl CoreInner { "targeted offer declined: {reason}" ))); } - TargetedOfferResponse::Refused { reason } => { + WireOfferResponse::Refused { reason } => { let _ = store .set_state( &transfer_uuid, @@ -483,18 +493,77 @@ impl CoreInner { pub(super) async fn receive_targeted_transfer( self: &Arc, - authorization: String, + transfer_id: String, output_dir: String, ) -> Result<(), VnidropError> { - let auth = TargetedAuthorization::decode(&authorization)?; - auth.verify_for_receiver(&self.endpoint.id().to_string())?; - self.run_targeted_receive(&auth, output_dir).await + self.receive_targeted_to_target( + transfer_id, + ReceiveTarget::Directory(std::path::PathBuf::from(output_dir)), + ) + .await + } + + pub(super) async fn receive_targeted_transfer_with_output_sink( + self: &Arc, + transfer_id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSink(output_sink)) + .await + } + + pub(super) async fn receive_targeted_transfer_with_output_sink_v2( + self: &Arc, + transfer_id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSinkV2(output_sink)) + .await } pub(super) async fn resume_targeted_transfer( self: &Arc, id: String, output_dir: String, + ) -> Result<(), VnidropError> { + self.resume_targeted_to_target( + id, + ReceiveTarget::Directory(std::path::PathBuf::from(output_dir)), + ) + .await + } + + pub(super) async fn resume_targeted_transfer_with_output_sink( + self: &Arc, + id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.resume_targeted_to_target(id, ReceiveTarget::OutputSink(output_sink)) + .await + } + + pub(super) async fn resume_targeted_transfer_with_output_sink_v2( + self: &Arc, + id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.resume_targeted_to_target(id, ReceiveTarget::OutputSinkV2(output_sink)) + .await + } + + async fn receive_targeted_to_target( + self: &Arc, + transfer_id: String, + target: ReceiveTarget, + ) -> Result<(), VnidropError> { + let auth = self.load_receiver_authorization(&transfer_id).await?; + self.run_targeted_receive(&auth, target).await + } + + async fn resume_targeted_to_target( + self: &Arc, + id: String, + target: ReceiveTarget, ) -> Result<(), VnidropError> { let store = self.targeted_store(); let row = store.get_row(&id).await?.ok_or_else(|| { @@ -514,6 +583,18 @@ impl CoreInner { ), }); } + let auth = self.load_receiver_authorization(&id).await?; + self.run_targeted_receive(&auth, target).await + } + + async fn load_receiver_authorization( + &self, + transfer_id: &str, + ) -> Result { + let store = self.targeted_store(); + let row = store.get_row(transfer_id).await?.ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("unknown targeted transfer")) + })?; let encoded = self.load_stored_authorization(&row).await?.ok_or_else(|| { VnidropError::invalid_input(anyhow::anyhow!( "targeted transfer has no durable authorization" @@ -521,13 +602,13 @@ impl CoreInner { })?; let auth = TargetedAuthorization::decode(&encoded)?; auth.verify_for_receiver(&self.endpoint.id().to_string())?; - self.run_targeted_receive(&auth, output_dir).await + Ok(auth) } async fn run_targeted_receive( self: &Arc, auth: &TargetedAuthorization, - output_dir: String, + target: ReceiveTarget, ) -> Result<(), VnidropError> { let store = self.targeted_store(); if let Ok(Some(row)) = store.get_row(&auth.transfer_id).await { @@ -584,9 +665,7 @@ impl CoreInner { .encode() .map_err(VnidropError::ticket)?; - let receive_result = self - .receive(ticket, std::path::PathBuf::from(output_dir), None) - .await; + let receive_result = self.receive_to_target(ticket, target, None).await; match receive_result { Ok(()) => { diff --git a/crates/vnidrop/src/targeted_transfer/inbox.rs b/crates/vnidrop/src/targeted_transfer/inbox.rs index 3800a4d..232e88d 100644 --- a/crates/vnidrop/src/targeted_transfer/inbox.rs +++ b/crates/vnidrop/src/targeted_transfer/inbox.rs @@ -227,6 +227,10 @@ impl TargetedOfferInbox { } } + pub(crate) async fn is_settled(&self, transfer_id: &str) -> bool { + self.settled.lock().await.contains_key(transfer_id) + } + /// Record the local decision. On accept, wait for sender-issued authorization. pub(crate) async fn respond( &self, diff --git a/crates/vnidrop/src/targeted_transfer/protocol.rs b/crates/vnidrop/src/targeted_transfer/protocol.rs index d68f0a8..e3d52ff 100644 --- a/crates/vnidrop/src/targeted_transfer/protocol.rs +++ b/crates/vnidrop/src/targeted_transfer/protocol.rs @@ -87,28 +87,28 @@ impl TargetedTransferProtocol { remote_endpoint_id: &str, challenge: &Challenge, offer: SubmitTargetedOffer, - ) -> TargetedOfferResponse { + ) -> WireOfferResponse { let expected = experimental_saved_device_capabilities().targeted_transfer_protocol_version; if self.inbox.cooldown().is_cooling(remote_endpoint_id) { - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "identity-cooldown".to_string(), }; } if offer.protocol_version != expected { self.inbox.cooldown().record_malformed(remote_endpoint_id); - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "protocol-incompatible".to_string(), }; } if offer.receiver_endpoint_id != self.local_endpoint_id { self.inbox.cooldown().record_malformed(remote_endpoint_id); - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "receiver-mismatch".to_string(), }; } if offer.sender_endpoint_id != remote_endpoint_id { self.inbox.cooldown().record_malformed(remote_endpoint_id); - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "sender-mismatch".to_string(), }; } @@ -121,7 +121,7 @@ impl TargetedTransferProtocol { || offer.content_hash.is_empty() { self.inbox.cooldown().record_malformed(remote_endpoint_id); - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "manifest-limits".to_string(), }; } @@ -130,7 +130,7 @@ impl TargetedTransferProtocol { .validate_metadata_text("transfer name", Some(offer.transfer_name.as_str())) { self.inbox.cooldown().record_malformed(remote_endpoint_id); - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: error.to_string(), }; } @@ -143,7 +143,7 @@ impl TargetedTransferProtocol { || existing.sender_endpoint_id != offer.sender_endpoint_id || existing.receiver_endpoint_id != offer.receiver_endpoint_id { - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "immutable-transfer-mismatch".to_string(), }; } @@ -152,28 +152,28 @@ impl TargetedTransferProtocol { | TargetedTransferState::Connecting | TargetedTransferState::Transferring | TargetedTransferState::Interrupted - | TargetedTransferState::Completed => TargetedOfferResponse::Accepted, - TargetedTransferState::Declined => TargetedOfferResponse::Declined { + | TargetedTransferState::Completed => WireOfferResponse::Accepted, + TargetedTransferState::Declined => WireOfferResponse::Declined { reason: "receiver-declined".to_string(), }, - TargetedTransferState::Cancelled => TargetedOfferResponse::Declined { + TargetedTransferState::Cancelled => WireOfferResponse::Declined { reason: "cancelled".to_string(), }, TargetedTransferState::Failed | TargetedTransferState::Deleted => { - TargetedOfferResponse::Refused { + WireOfferResponse::Refused { reason: format!("transfer-{}", state_as_str(existing.state)), } } TargetedTransferState::Preparing | TargetedTransferState::Offering - | TargetedTransferState::AwaitingApproval => TargetedOfferResponse::Accepted, + | TargetedTransferState::AwaitingApproval => WireOfferResponse::Accepted, }; } let remote_urls = match parse_offer_relay_urls(&offer.relay_urls) { Ok(urls) => urls, Err(_) => { - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "relay-policy-incompatible".to_string(), }; } @@ -184,7 +184,7 @@ impl TargetedTransferProtocol { offer.relay_mode, &remote_urls, ) { - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "relay-policy-incompatible".to_string(), }; } @@ -203,11 +203,11 @@ impl TargetedTransferProtocol { tracing::debug!(error = %error, "targeted offer relationship proof rejected"); self.inbox.cooldown().record_malformed(remote_endpoint_id); if matches!(error, VnidropError::ProtocolIncompatible { .. }) { - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "protocol-incompatible".to_string(), }; } - return TargetedOfferResponse::Refused { + return WireOfferResponse::Refused { reason: "unauthenticated".to_string(), }; } @@ -226,11 +226,9 @@ impl TargetedTransferProtocol { }; match self.inbox.submit(pending).await { - TargetedOfferDecision::Accepted => TargetedOfferResponse::Accepted, - TargetedOfferDecision::Declined { reason } => { - TargetedOfferResponse::Declined { reason } - } - TargetedOfferDecision::Refused { reason } => TargetedOfferResponse::Refused { reason }, + TargetedOfferDecision::Accepted => WireOfferResponse::Accepted, + TargetedOfferDecision::Declined { reason } => WireOfferResponse::Declined { reason }, + TargetedOfferDecision::Refused { reason } => WireOfferResponse::Refused { reason }, } } @@ -271,21 +269,21 @@ impl TargetedTransferProtocol { &self, remote_endpoint_id: &str, cancel: CancelTargetedOffer, - ) -> CancelTargetedOfferResponse { + ) -> CancelWireOfferResponse { if let Some(pending) = self.inbox.get_pending(&cancel.transfer_id).await { if pending.sender_endpoint_id != remote_endpoint_id { - return CancelTargetedOfferResponse::Rejected; + return CancelWireOfferResponse::Rejected; } self.inbox.discard(&cancel.transfer_id).await; - return CancelTargetedOfferResponse::Cancelled; + return CancelWireOfferResponse::Cancelled; } if let Ok(Some(row)) = self.store.get_row(&cancel.transfer_id).await { if row.sender_endpoint_id != remote_endpoint_id { - return CancelTargetedOfferResponse::Rejected; + return CancelWireOfferResponse::Rejected; } - return CancelTargetedOfferResponse::Cancelled; + return CancelWireOfferResponse::Cancelled; } - CancelTargetedOfferResponse::Cancelled + CancelWireOfferResponse::Cancelled } } @@ -342,7 +340,7 @@ impl TargetedTransferClient { pub(crate) async fn submit_offer( &self, offer: SubmitTargetedOffer, - ) -> Result { + ) -> Result { self.inner.rpc(offer).await } @@ -356,7 +354,7 @@ impl TargetedTransferClient { pub(crate) async fn cancel_offer( &self, cancel: CancelTargetedOffer, - ) -> Result { + ) -> Result { self.inner.rpc(cancel).await } } @@ -388,7 +386,7 @@ pub(crate) struct SubmitTargetedOffer { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) enum TargetedOfferResponse { +pub(crate) enum WireOfferResponse { Accepted, Declined { reason: String }, Refused { reason: String }, @@ -412,7 +410,7 @@ pub(crate) struct CancelTargetedOffer { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) enum CancelTargetedOfferResponse { +pub(crate) enum CancelWireOfferResponse { Cancelled, Rejected, } @@ -426,11 +424,11 @@ pub(crate) enum CancelTargetedOfferResponse { enum TargetedTransferMessages { #[rpc(tx = oneshot::Sender)] RequestChallenge(RequestChallenge), - #[rpc(tx = oneshot::Sender)] + #[rpc(tx = oneshot::Sender)] SubmitTargetedOffer(SubmitTargetedOffer), #[rpc(tx = oneshot::Sender)] DeliverTargetedAuthorization(DeliverTargetedAuthorization), - #[rpc(tx = oneshot::Sender)] + #[rpc(tx = oneshot::Sender)] CancelTargetedOffer(CancelTargetedOffer), } diff --git a/crates/vnidrop/src/tests/control_plane.rs b/crates/vnidrop/src/tests/control_plane.rs index 5be6a33..a87e4d1 100644 --- a/crates/vnidrop/src/tests/control_plane.rs +++ b/crates/vnidrop/src/tests/control_plane.rs @@ -439,7 +439,7 @@ fn saved_device_cap_blocks_only_new_relationships() { Some("payload.txt".to_string()), ) .unwrap(); - accept.join().unwrap().unwrap(); + accept.join().unwrap(); // New relationship is refused while the cap is full. complete_transfer(&alice, &carol, 13_002); diff --git a/crates/vnidrop/src/tests/platform_contract_android.rs b/crates/vnidrop/src/tests/platform_contract_android.rs index cbc5de6..b3c6623 100644 --- a/crates/vnidrop/src/tests/platform_contract_android.rs +++ b/crates/vnidrop/src/tests/platform_contract_android.rs @@ -21,8 +21,9 @@ use crate::{ android::{AndroidKeystore, AndroidSealedValue, AndroidSecureSecretStore}, SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, }, - CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind, - TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError, + CoreEvent, CoreEventSink, DeviceRelationshipState, PublishedOutput, ReceiveOutputSinkV2, + ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, + TransferAccessMode, VnidropCore, VnidropError, }; #[derive(Default)] @@ -408,7 +409,7 @@ fn approve_targeted( bob: &AndroidContractNode, payload: &[u8], name: &str, -) -> (crate::TargetedTransfer, String) { +) -> crate::TargetedTransfer { let bob_id = bob.core().status().endpoint_id.clone(); let source_dir = tempfile::tempdir().unwrap(); let source_path = source_dir.path().join(name); @@ -429,8 +430,12 @@ fn approve_targeted( Some(name.to_string()), ) .unwrap(); - let auth = accept.join().unwrap().expect("authorization"); - (transfer, auth) + let response = accept.join().unwrap(); + assert!(matches!( + response, + crate::TargetedOfferResponse::Approved { .. } + )); + transfer } #[test] @@ -461,7 +466,7 @@ fn android_public_api_covers_saved_device_and_targeted_lifecycle() { assert_eq!(saved[0].endpoint_id, bob_id); assert_eq!(saved[0].local_label.as_deref(), Some("Kitchen Tablet")); - let (transfer, _auth) = approve_targeted(&alice, &bob, b"android payload", "payload.txt"); + let transfer = approve_targeted(&alice, &bob, b"android payload", "payload.txt"); assert_eq!(transfer.receiver_endpoint_id, bob_id); assert_eq!( bob.core() @@ -501,6 +506,155 @@ fn android_public_api_covers_saved_device_and_targeted_lifecycle() { assert!(alice.core().list_saved_devices().unwrap().is_empty()); } +/// Host-side stand-in for MediaStore Downloads publish: durable Android locator, not a path dir. +#[derive(Default)] +struct AndroidMediaStoreSink { + files: Mutex>>, + published: Mutex>, +} + +impl AndroidMediaStoreSink { + fn bytes(&self, relative_path: &str) -> Vec { + self.files.lock().unwrap()[relative_path].clone() + } + + fn published(&self, relative_path: &str) -> PublishedOutput { + self.published.lock().unwrap()[relative_path].clone() + } +} + +impl ReceiveOutputSinkV2 for AndroidMediaStoreSink { + fn start_file(&self, relative_path: String) -> Result<(), VnidropError> { + self.files.lock().unwrap().insert(relative_path, Vec::new()); + Ok(()) + } + + fn write_chunk(&self, relative_path: String, bytes: Vec) -> Result<(), VnidropError> { + self.files + .lock() + .unwrap() + .get_mut(&relative_path) + .expect("started") + .extend(bytes); + Ok(()) + } + + fn finish_file(&self, relative_path: String) -> Result { + let published = PublishedOutput { + locator_kind: ReceivedLocatorKind::AndroidMediaStore, + locator: format!("content://media/external/downloads/{relative_path}"), + }; + self.published + .lock() + .unwrap() + .insert(relative_path, published.clone()); + Ok(published) + } + + fn abort_file(&self, relative_path: String, _reason: String) -> Result<(), VnidropError> { + self.files.lock().unwrap().remove(&relative_path); + self.published.lock().unwrap().remove(&relative_path); + Ok(()) + } +} + +#[test] +fn android_targeted_receive_and_resume_via_media_store_sink() { + let alice = AndroidContractNode::new(); + let mut bob = AndroidContractNode::new(); + establish_saved(&alice, &bob, 15_101); + + let transfer = approve_targeted(&alice, &bob, b"media store payload", "download.txt"); + let sink = Arc::new(AndroidMediaStoreSink::default()); + bob.core() + .receive_targeted_transfer_with_output_sink_v2(transfer.id.clone(), sink.clone()) + .unwrap(); + assert_eq!(sink.bytes("download.txt"), b"media store payload"); + let published = sink.published("download.txt"); + assert_eq!( + published.locator_kind, + ReceivedLocatorKind::AndroidMediaStore + ); + assert!(published.locator.starts_with("content://media/")); + assert_eq!( + bob.core() + .get_targeted_transfer(transfer.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Completed + ); + + let transfer2 = approve_targeted(&alice, &bob, b"resume via sink", "resume.txt"); + bob.restart(); + let resume_sink = Arc::new(AndroidMediaStoreSink::default()); + bob.core() + .resume_targeted_transfer_with_output_sink_v2(transfer2.id.clone(), resume_sink.clone()) + .unwrap(); + assert_eq!(resume_sink.bytes("resume.txt"), b"resume via sink"); + assert_eq!( + resume_sink.published("resume.txt").locator_kind, + ReceivedLocatorKind::AndroidMediaStore + ); + assert_eq!( + bob.core() + .get_targeted_transfer(transfer2.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Completed + ); +} + +#[test] +fn android_targeted_sink_failure_marks_transfer_interrupted() { + let alice = AndroidContractNode::new(); + let bob = AndroidContractNode::new(); + establish_saved(&alice, &bob, 15_102); + let transfer = approve_targeted(&alice, &bob, b"will fail", "fail.txt"); + + struct FailingMediaStoreSink; + impl ReceiveOutputSinkV2 for FailingMediaStoreSink { + fn start_file(&self, _relative_path: String) -> Result<(), VnidropError> { + Ok(()) + } + fn write_chunk(&self, _relative_path: String, _bytes: Vec) -> Result<(), VnidropError> { + Err(VnidropError::Filesystem { + reason: "media store write failed".to_string(), + }) + } + fn finish_file(&self, _relative_path: String) -> Result { + unreachable!("write failed") + } + fn abort_file(&self, _relative_path: String, _reason: String) -> Result<(), VnidropError> { + Ok(()) + } + } + + let err = bob + .core() + .receive_targeted_transfer_with_output_sink_v2( + transfer.id.clone(), + Arc::new(FailingMediaStoreSink), + ) + .unwrap_err(); + assert!( + matches!( + err, + VnidropError::Transfer { .. } | VnidropError::Filesystem { .. } + ), + "expected transfer/filesystem failure, got {err:?}" + ); + assert_eq!( + bob.core() + .get_targeted_transfer(transfer.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Interrupted + ); +} + #[test] fn locked_or_invalidated_identity_blocks_restart() { let mut alice = AndroidContractNode::new(); diff --git a/crates/vnidrop/src/tests/platform_contract_apple.rs b/crates/vnidrop/src/tests/platform_contract_apple.rs index 3660a91..0c97950 100644 --- a/crates/vnidrop/src/tests/platform_contract_apple.rs +++ b/crates/vnidrop/src/tests/platform_contract_apple.rs @@ -508,7 +508,7 @@ fn approve_one( bob: &Arc, payload: &[u8], name: &str, -) -> (crate::TargetedTransfer, String) { +) -> crate::TargetedTransfer { let bob_id = bob.status().endpoint_id.clone(); let source_dir = tempfile::tempdir().unwrap(); let source_path = source_dir.path().join(name); @@ -528,8 +528,12 @@ fn approve_one( Some(name.to_string()), ) .unwrap(); - let auth = accept.join().unwrap().expect("authorization"); - (transfer, auth) + let response = accept.join().unwrap(); + assert!(matches!( + response, + crate::TargetedOfferResponse::Approved { .. } + )); + transfer } fn recover_authoritative_state( @@ -615,7 +619,7 @@ fn public_api_contract_eligibility_through_unblock_on_apple_path() { assert_eq!(saved[0].endpoint_id, bob_id); assert_eq!(saved[0].local_label.as_deref(), Some("Bob Mac")); - let (transfer, _auth) = approve_one(&alice.core(), &bob.core(), b"apple contract", "a.txt"); + let transfer = approve_one(&alice.core(), &bob.core(), b"apple contract", "a.txt"); assert_eq!(transfer.receiver_endpoint_id, bob_id); let alice = alice.restart(); diff --git a/crates/vnidrop/src/tests/platform_contract_linux.rs b/crates/vnidrop/src/tests/platform_contract_linux.rs index ee8fe1d..be6d276 100644 --- a/crates/vnidrop/src/tests/platform_contract_linux.rs +++ b/crates/vnidrop/src/tests/platform_contract_linux.rs @@ -448,7 +448,7 @@ fn approve_one( bob: &Arc, payload: &[u8], name: &str, -) -> (crate::TargetedTransfer, String) { +) -> crate::TargetedTransfer { let bob_id = bob.status().endpoint_id.clone(); let source_dir = tempfile::tempdir().unwrap(); let source_path = source_dir.path().join(name); @@ -468,8 +468,12 @@ fn approve_one( Some(name.to_string()), ) .unwrap(); - let auth = accept.join().unwrap().expect("authorization"); - (transfer, auth) + let response = accept.join().unwrap(); + assert!(matches!( + response, + crate::TargetedOfferResponse::Approved { .. } + )); + transfer } fn recover_authoritative_state( @@ -586,7 +590,7 @@ fn public_api_contract_eligibility_through_unblock_on_linux_path() { assert_eq!(saved[0].endpoint_id, bob_id); assert_eq!(saved[0].local_label.as_deref(), Some("Bob Linux")); - let (transfer, _auth) = approve_one(&alice.core(), &bob.core(), b"linux contract", "a.txt"); + let transfer = approve_one(&alice.core(), &bob.core(), b"linux contract", "a.txt"); assert_eq!(transfer.receiver_endpoint_id, bob_id); let alice = alice.restart(); diff --git a/crates/vnidrop/src/tests/platform_contract_windows.rs b/crates/vnidrop/src/tests/platform_contract_windows.rs index 512ace4..fb2be96 100644 --- a/crates/vnidrop/src/tests/platform_contract_windows.rs +++ b/crates/vnidrop/src/tests/platform_contract_windows.rs @@ -367,7 +367,11 @@ fn public_api_exercises_complete_windows_saved_device_contract() { Some("payload.txt".to_string()), ) .unwrap(); - let _auth = accept.join().unwrap().expect("authorization"); + let response = accept.join().unwrap(); + assert!(matches!( + response, + crate::TargetedOfferResponse::Approved { .. } + )); assert_eq!(transfer.state, TargetedTransferState::Approved); // Interrupt via receiver restart, then resume without re-approval. diff --git a/crates/vnidrop/src/tests/targeted_transfer.rs b/crates/vnidrop/src/tests/targeted_transfer.rs index 27ff729..5065270 100644 --- a/crates/vnidrop/src/tests/targeted_transfer.rs +++ b/crates/vnidrop/src/tests/targeted_transfer.rs @@ -6,7 +6,8 @@ use std::{ use crate::{ secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, CoreNetworkConfig, - CoreRelayMode, DeviceRelationshipState, PendingTargetedOffer, ShareMetadataInput, ShareSource, + CoreRelayMode, DeviceRelationshipState, PendingTargetedOffer, PublishedOutput, + ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError, }; @@ -272,7 +273,12 @@ fn create_targeted_transfer_is_immutable_and_saved_only() { Some("payload.txt".to_string()), ) .unwrap(); - let _auth = accept.join().unwrap().expect("authorization after approve"); + let response = accept.join().unwrap(); + assert!(matches!( + response, + crate::TargetedOfferResponse::Approved { transfer_id } + if transfer_id == transfer.id + )); assert_eq!( transfer.sender_endpoint_id, @@ -339,7 +345,7 @@ fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() { Some("payload.txt".to_string()), ) .unwrap(); - accept.join().unwrap().unwrap(); + accept.join().unwrap(); } fn alice_id_from(bob: &VnidropCore) -> String { @@ -429,10 +435,10 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() { std::thread::sleep(Duration::from_millis(25)); }; - let _ = transfer_id; - // Without an approved authorization, receive must fail — no content yet. + let _ = transfer_id.clone(); + // Without durable authorization, receive by id must fail — no content yet. let early_receive = bob.core().receive_targeted_transfer( - "not-a-real-authorization".to_string(), + transfer_id.clone(), tempfile::tempdir() .unwrap() .path() @@ -442,12 +448,19 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() { assert!(early_receive.is_err()); *gate.lock().unwrap() = true; - let auth = accept.join().unwrap().expect("receiver authorization"); + let response = accept.join().unwrap(); + assert!(matches!( + response, + crate::TargetedOfferResponse::Approved { transfer_id: id } if id == transfer_id + )); create.join().unwrap().unwrap(); let output = tempfile::tempdir().unwrap(); bob.core() - .receive_targeted_transfer(auth.clone(), output.path().to_string_lossy().into_owned()) + .receive_targeted_transfer( + transfer_id.clone(), + output.path().to_string_lossy().into_owned(), + ) .unwrap(); assert_eq!( std::fs::read(output.path().join("payload.txt")).unwrap(), @@ -455,12 +468,13 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() { ); let charlie_output = tempfile::tempdir().unwrap(); - let leaked = charlie - .core() - .receive_targeted_transfer(auth, charlie_output.path().to_string_lossy().into_owned()); + let leaked = charlie.core().receive_targeted_transfer( + transfer_id, + charlie_output.path().to_string_lossy().into_owned(), + ); assert!( leaked.is_err(), - "leaked authorization must not authorize another endpoint" + "another endpoint must not pull by the same transfer id" ); } @@ -516,7 +530,7 @@ fn approve_one( bob: &ProtectedNode, payload: &[u8], name: &str, -) -> (crate::TargetedTransfer, String) { +) -> crate::TargetedTransfer { let bob_id = bob.core().status().endpoint_id.clone(); let source_dir = tempfile::tempdir().unwrap(); let source_path = source_dir.path().join(name); @@ -537,8 +551,12 @@ fn approve_one( Some(name.to_string()), ) .unwrap(); - let auth = accept.join().unwrap().expect("authorization"); - (transfer, auth) + let response = accept.join().unwrap(); + assert!(matches!( + response, + crate::TargetedOfferResponse::Approved { .. } + )); + transfer } #[test] @@ -546,15 +564,19 @@ fn protocol_ops_are_idempotent_for_stable_transfer_id() { let alice = ProtectedNode::new(); let bob = ProtectedNode::new(); establish_saved(&alice, &bob, 11_001); - let (transfer, auth) = approve_one(&alice, &bob, b"idempotent payload", "payload.txt"); + let transfer = approve_one(&alice, &bob, b"idempotent payload", "payload.txt"); - // Replaying approval returns the same authorization — no duplicate prompts. + // Replaying approval returns AlreadySettled — no duplicate prompts. let again = bob .core() .respond_to_targeted_offer(transfer.id.clone(), true) - .unwrap() - .expect("idempotent authorization"); - assert_eq!(again, auth); + .unwrap(); + assert_eq!( + again, + crate::TargetedOfferResponse::AlreadySettled { + transfer_id: transfer.id.clone() + } + ); let listed = bob .core() @@ -627,7 +649,7 @@ fn approved_transfer_resumes_after_restart_without_reapproval() { let alice = ProtectedNode::new(); let bob = ProtectedNode::new(); establish_saved(&alice, &bob, 11_020); - let (transfer, _auth) = approve_one(&alice, &bob, b"resume me please", "payload.txt"); + let transfer = approve_one(&alice, &bob, b"resume me please", "payload.txt"); let bob_before = bob .core() @@ -678,8 +700,8 @@ fn manifest_change_requires_new_transfer_identity() { let alice = ProtectedNode::new(); let bob = ProtectedNode::new(); establish_saved(&alice, &bob, 11_030); - let (first, _) = approve_one(&alice, &bob, b"first manifest", "a.txt"); - let (second, _) = approve_one(&alice, &bob, b"second manifest", "b.txt"); + let first = approve_one(&alice, &bob, b"first manifest", "a.txt"); + let second = approve_one(&alice, &bob, b"second manifest", "b.txt"); assert_ne!(first.id, second.id); assert_ne!(first.manifest_id, second.manifest_id); } @@ -689,7 +711,7 @@ fn cancel_revokes_access_and_stops_streaming() { let alice = ProtectedNode::new(); let bob = ProtectedNode::new(); establish_saved(&alice, &bob, 11_040); - let (transfer, auth) = approve_one(&alice, &bob, b"cancel me", "payload.txt"); + let transfer = approve_one(&alice, &bob, b"cancel me", "payload.txt"); alice .core() @@ -705,7 +727,7 @@ fn cancel_revokes_access_and_stops_streaming() { let output = tempfile::tempdir().unwrap(); let receive = bob .core() - .receive_targeted_transfer(auth, output.path().to_string_lossy().into_owned()); + .receive_targeted_transfer(transfer.id, output.path().to_string_lossy().into_owned()); assert!( receive.is_err(), "cancelled transfer must not remain receivable" @@ -717,7 +739,7 @@ fn delete_removes_authorization_and_resumable_state() { let alice = ProtectedNode::new(); let bob = ProtectedNode::new(); establish_saved(&alice, &bob, 11_050); - let (transfer, auth) = approve_one(&alice, &bob, b"delete me", "payload.txt"); + let transfer = approve_one(&alice, &bob, b"delete me", "payload.txt"); bob.core() .delete_targeted_transfer(transfer.id.clone()) @@ -741,16 +763,17 @@ fn delete_removes_authorization_and_resumable_state() { assert!(resume.is_err(), "deleted transfer must not resume"); let receive = bob.core().receive_targeted_transfer( - auth, + transfer.id.clone(), tempfile::tempdir() .unwrap() .path() .to_string_lossy() .into_owned(), ); - // Auth blob may still decode, but durable resume path is gone; receive may - // still attempt content pull if sender serves — sender delete is separate. - let _ = receive; + assert!( + receive.is_err(), + "deleted transfer must not remain receivable by id" + ); alice .core() @@ -772,8 +795,8 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() { // Design allows only one unresolved offer per sender; approve sequentially, // then prove independent approved transfers do not corrupt each other. - let (first, first_auth) = approve_one(&alice, &bob, b"alpha", "one.txt"); - let (second, second_auth) = approve_one(&alice, &bob, b"beta-payload", "two.txt"); + let first = approve_one(&alice, &bob, b"alpha", "one.txt"); + let second = approve_one(&alice, &bob, b"beta-payload", "two.txt"); assert_ne!(first.id, second.id); alice @@ -783,7 +806,7 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() { assert_eq!( alice .core() - .get_targeted_transfer(first.id) + .get_targeted_transfer(first.id.clone()) .unwrap() .unwrap() .state, @@ -801,7 +824,10 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() { let output = tempfile::tempdir().unwrap(); bob.core() - .receive_targeted_transfer(second_auth, output.path().to_string_lossy().into_owned()) + .receive_targeted_transfer( + second.id.clone(), + output.path().to_string_lossy().into_owned(), + ) .unwrap(); assert_eq!( std::fs::read(output.path().join("two.txt")).unwrap(), @@ -812,7 +838,7 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() { assert!(bob .core() .receive_targeted_transfer( - first_auth, + first.id, cancelled_output.path().to_string_lossy().into_owned(), ) .is_err()); @@ -842,10 +868,16 @@ fn complete_targeted_roundtrip(alice: &ProtectedNode, bob: &ProtectedNode, trans Some("payload.txt".to_string()), ) .unwrap(); - let auth = accept.join().unwrap().expect("authorization"); + assert!(matches!( + accept.join().unwrap(), + crate::TargetedOfferResponse::Approved { .. } + )); let output = tempfile::tempdir().unwrap(); bob.core() - .receive_targeted_transfer(auth, output.path().to_string_lossy().into_owned()) + .receive_targeted_transfer( + transfer.id.clone(), + output.path().to_string_lossy().into_owned(), + ) .unwrap(); assert_eq!( std::fs::read(output.path().join("payload.txt")).unwrap(), @@ -1097,3 +1129,133 @@ fn start_loopback_relay() -> LoopbackRelay { thread: Some(thread), } } + +#[derive(Default)] +struct MemoryOutputSink { + files: Mutex>>, +} + +impl MemoryOutputSink { + fn file(&self, relative_path: &str) -> Vec { + self.files.lock().unwrap()[relative_path].clone() + } +} + +impl ReceiveOutputSink for MemoryOutputSink { + fn start_file(&self, relative_path: String) -> Result<(), VnidropError> { + self.files.lock().unwrap().insert(relative_path, Vec::new()); + Ok(()) + } + + fn write_chunk(&self, relative_path: String, bytes: Vec) -> Result<(), VnidropError> { + self.files + .lock() + .unwrap() + .get_mut(&relative_path) + .expect("started") + .extend(bytes); + Ok(()) + } + + fn finish_file(&self, _relative_path: String) -> Result<(), VnidropError> { + Ok(()) + } + + fn abort_file(&self, relative_path: String, _reason: String) -> Result<(), VnidropError> { + self.files.lock().unwrap().remove(&relative_path); + Ok(()) + } +} + +impl ReceiveOutputSinkV2 for MemoryOutputSink { + fn start_file(&self, relative_path: String) -> Result<(), VnidropError> { + ReceiveOutputSink::start_file(self, relative_path) + } + + fn write_chunk(&self, relative_path: String, bytes: Vec) -> Result<(), VnidropError> { + ReceiveOutputSink::write_chunk(self, relative_path, bytes) + } + + fn finish_file(&self, relative_path: String) -> Result { + ReceiveOutputSink::finish_file(self, relative_path.clone())?; + Ok(PublishedOutput { + locator_kind: ReceivedLocatorKind::FilesystemPath, + locator: format!("memory://{relative_path}"), + }) + } + + fn abort_file(&self, relative_path: String, reason: String) -> Result<(), VnidropError> { + ReceiveOutputSink::abort_file(self, relative_path, reason) + } +} + +#[test] +fn targeted_receive_and_resume_through_output_sinks() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 11_070); + let transfer = approve_one(&alice, &bob, b"sink payload", "sink.txt"); + + let sink = Arc::new(MemoryOutputSink::default()); + bob.core() + .receive_targeted_transfer_with_output_sink(transfer.id.clone(), sink.clone()) + .unwrap(); + assert_eq!(sink.file("sink.txt"), b"sink payload"); + assert_eq!( + bob.core() + .get_targeted_transfer(transfer.id.clone()) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Completed + ); + + let transfer2 = approve_one(&alice, &bob, b"resume sink", "resume.txt"); + let bob = bob.restart(); + let sink_v2 = Arc::new(MemoryOutputSink::default()); + bob.core() + .resume_targeted_transfer_with_output_sink_v2(transfer2.id.clone(), sink_v2.clone()) + .unwrap(); + assert_eq!(sink_v2.file("resume.txt"), b"resume sink"); + assert_eq!( + bob.core() + .get_targeted_transfer(transfer2.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Completed + ); +} + +#[test] +fn decline_returns_typed_declined_outcome() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + let bob_id = bob.core().status().endpoint_id.clone(); + establish_saved(&alice, &bob, 11_080); + + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("payload.txt"); + std::fs::write(&source_path, b"no thanks").unwrap(); + + let bob_core = bob.core().clone(); + let decline = std::thread::spawn(move || { + let offer = wait_for_pending_offer(&bob_core); + bob_core + .respond_to_targeted_offer(offer.transfer_id, false) + .unwrap() + }); + let create_err = alice + .core() + .create_targeted_transfer( + bob_id, + vec![targeted_source(&source_path)], + Some("payload.txt".to_string()), + ) + .unwrap_err(); + assert!(matches!(create_err, VnidropError::Permission { .. })); + assert_eq!( + decline.join().unwrap(), + crate::TargetedOfferResponse::Declined + ); +}