feat(core): id-centric targeted approve/receive with output sinks

Stop returning grant strings across UniFFI; approve yields typed outcomes and
pull/resume use transfer id plus path or ReceiveOutputSink. Document the
pairing/targeted event catalog and cover Android MediaStore-style sink contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 16:50:10 +02:00
parent 3b7fd3d468
commit d91820b867
14 changed files with 620 additions and 122 deletions

View File

@@ -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 least once; consumers deduplicate by stable ID and revision, then query current
state after reconnect or restart. 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: Failures remain typed where callers can act differently, including:
- Device unavailable or offer timeout. - Device unavailable or offer timeout.

View File

@@ -41,11 +41,17 @@ bytes through Kotlin memory.
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`, - Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
`stopped`. `stopped`.
- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`, - 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 - Events are sent to `CoreEventSink` immediately and persisted through the event
hub. `list_events` flushes queued persistence before reading SQLite. hub. `list_events` flushes queued persistence before reading SQLite.
- `shutdown()` is idempotent and flushes events before stopping the router. - `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 ## Platform File Rules
- Desktop uses normal filesystem paths. - Desktop uses normal filesystem paths.

View File

@@ -118,6 +118,16 @@ pub struct PendingTargetedOffer {
pub received_at: i64, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum CoreRelayMode { pub enum CoreRelayMode {
Automatic, Automatic,

View File

@@ -32,8 +32,9 @@ pub use api::{
DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, PairingEligibilitySummary, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, PairingEligibilitySummary,
PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice,
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TargetedTransfer, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata, TargetedOfferResponse, TargetedTransfer, TargetedTransferState, TicketInspection,
TransferAccessMode, TransferMetadata,
}; };
pub use error::VnidropError; pub use error::VnidropError;
pub use runtime::VnidropCore; pub use runtime::VnidropCore;

View File

@@ -544,7 +544,8 @@ impl VnidropCore {
/// Create an immutable one-receiver transfer and submit its pre-approval offer. /// Create an immutable one-receiver transfer and submit its pre-approval offer.
/// ///
/// Blocks until the saved receiver approves or declines. On approval the /// 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( pub fn create_targeted_transfer(
&self, &self,
receiver_endpoint_id: String, receiver_endpoint_id: String,
@@ -565,25 +566,47 @@ impl VnidropCore {
/// Approve or decline a pending targeted offer. /// Approve or decline a pending targeted offer.
/// ///
/// On approval, returns the recipient-bound authorization capability used /// On approval, authorization stays in core custody; callers pull content
/// with [`Self::receive_targeted_transfer`]. Declining returns `None`. /// with [`Self::receive_targeted_transfer`] using the transfer id.
pub fn respond_to_targeted_offer( pub fn respond_to_targeted_offer(
&self, &self,
transfer_id: String, transfer_id: String,
accepted: bool, accepted: bool,
) -> Result<Option<String>, VnidropError> { ) -> Result<crate::api::TargetedOfferResponse, VnidropError> {
self.block_on(self.inner.respond_to_targeted_offer(transfer_id, accepted)) self.block_on(self.inner.respond_to_targeted_offer(transfer_id, accepted))
} }
/// Pull an approved targeted transfer through existing output-sink machinery. /// Pull an approved targeted transfer through existing output-sink machinery.
pub fn receive_targeted_transfer( pub fn receive_targeted_transfer(
&self, &self,
authorization: String, transfer_id: String,
output_dir: String, output_dir: String,
) -> Result<(), VnidropError> { ) -> Result<(), VnidropError> {
self.block_on( self.block_on(
self.inner 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<dyn ReceiveOutputSink>,
) -> 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<dyn ReceiveOutputSinkV2>,
) -> 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)) 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<dyn ReceiveOutputSink>,
) -> 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<dyn ReceiveOutputSinkV2>,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.resume_targeted_transfer_with_output_sink_v2(id, output_sink),
)
}
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> { pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
self.block_on(self.inner.repository.list_transfers()) self.block_on(self.inner.repository.list_transfers())
.map_err(VnidropError::repository) .map_err(VnidropError::repository)

View File

@@ -6,11 +6,12 @@ use anyhow::{Context, Result};
use iroh_blobs::{ticket::BlobTicket, BlobFormat}; use iroh_blobs::{ticket::BlobTicket, BlobFormat};
use uuid::Uuid; use uuid::Uuid;
use super::CoreInner; use super::{receive::ReceiveTarget, CoreInner};
use crate::{ use crate::{
api::{ api::{
experimental_saved_device_capabilities, PendingTargetedOffer, ShareMetadataInput, experimental_saved_device_capabilities, PendingTargetedOffer, ShareMetadataInput,
ShareSource, TargetedTransfer, TargetedTransferState, TransferAccessMode, TransferMetadata, ShareSource, TargetedOfferResponse, TargetedTransfer, TargetedTransferState,
TransferAccessMode, TransferMetadata,
}, },
error::VnidropError, error::VnidropError,
secure_secret::{SecretHandle, SecretKind}, secure_secret::{SecretHandle, SecretKind},
@@ -18,7 +19,7 @@ use crate::{
auth_secret_material, auth_secret_material,
protocol::{ protocol::{
map_offer_refuse_reason, CancelTargetedOffer, DeliverTargetedAuthorization, map_offer_refuse_reason, CancelTargetedOffer, DeliverTargetedAuthorization,
SubmitTargetedOffer, TargetedOfferResponse, TargetedTransferProtocol, SubmitTargetedOffer, TargetedTransferProtocol, WireOfferResponse,
}, },
reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft, reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft,
TargetedTransferRole, TargetedTransferRow, TargetedTransferRole, TargetedTransferRow,
@@ -195,26 +196,35 @@ impl CoreInner {
self: &Arc<Self>, self: &Arc<Self>,
transfer_id: String, transfer_id: String,
accepted: bool, accepted: bool,
) -> Result<Option<String>, VnidropError> { ) -> Result<TargetedOfferResponse, VnidropError> {
if let Some(auth) = self if self.targeted_offers.is_settled(&transfer_id).await {
.targeted_offers return Ok(TargetedOfferResponse::AlreadySettled { transfer_id });
.settled_authorization(&transfer_id)
.await
{
return Ok(Some(auth));
} }
if let Ok(Some(row)) = self.targeted_store().get_row(&transfer_id).await { if let Ok(Some(row)) = self.targeted_store().get_row(&transfer_id).await {
if let Some(encoded) = self.load_stored_authorization(&row).await? { if self.load_stored_authorization(&row).await?.is_some()
return Ok(Some(encoded)); || 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 { match self.targeted_offers.respond(&transfer_id, accepted).await {
Ok(Some(auth)) => { Ok(Some(auth)) => {
self.persist_receiver_authorization(&auth).await?; 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( Err(crate::targeted_transfer::RespondError::Unknown) => Err(
VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")), VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")),
), ),
@@ -390,8 +400,8 @@ impl CoreInner {
}; };
match response { match response {
TargetedOfferResponse::Accepted => {} WireOfferResponse::Accepted => {}
TargetedOfferResponse::Declined { reason } => { WireOfferResponse::Declined { reason } => {
let _ = store let _ = store
.set_state( .set_state(
&transfer_uuid, &transfer_uuid,
@@ -404,7 +414,7 @@ impl CoreInner {
"targeted offer declined: {reason}" "targeted offer declined: {reason}"
))); )));
} }
TargetedOfferResponse::Refused { reason } => { WireOfferResponse::Refused { reason } => {
let _ = store let _ = store
.set_state( .set_state(
&transfer_uuid, &transfer_uuid,
@@ -483,18 +493,77 @@ impl CoreInner {
pub(super) async fn receive_targeted_transfer( pub(super) async fn receive_targeted_transfer(
self: &Arc<Self>, self: &Arc<Self>,
authorization: String, transfer_id: String,
output_dir: String, output_dir: String,
) -> Result<(), VnidropError> { ) -> Result<(), VnidropError> {
let auth = TargetedAuthorization::decode(&authorization)?; self.receive_targeted_to_target(
auth.verify_for_receiver(&self.endpoint.id().to_string())?; transfer_id,
self.run_targeted_receive(&auth, output_dir).await ReceiveTarget::Directory(std::path::PathBuf::from(output_dir)),
)
.await
}
pub(super) async fn receive_targeted_transfer_with_output_sink(
self: &Arc<Self>,
transfer_id: String,
output_sink: Arc<dyn crate::ReceiveOutputSink>,
) -> 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<Self>,
transfer_id: String,
output_sink: Arc<dyn crate::ReceiveOutputSinkV2>,
) -> Result<(), VnidropError> {
self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSinkV2(output_sink))
.await
} }
pub(super) async fn resume_targeted_transfer( pub(super) async fn resume_targeted_transfer(
self: &Arc<Self>, self: &Arc<Self>,
id: String, id: String,
output_dir: 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<Self>,
id: String,
output_sink: Arc<dyn crate::ReceiveOutputSink>,
) -> 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<Self>,
id: String,
output_sink: Arc<dyn crate::ReceiveOutputSinkV2>,
) -> Result<(), VnidropError> {
self.resume_targeted_to_target(id, ReceiveTarget::OutputSinkV2(output_sink))
.await
}
async fn receive_targeted_to_target(
self: &Arc<Self>,
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<Self>,
id: String,
target: ReceiveTarget,
) -> Result<(), VnidropError> { ) -> Result<(), VnidropError> {
let store = self.targeted_store(); let store = self.targeted_store();
let row = store.get_row(&id).await?.ok_or_else(|| { 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<TargetedAuthorization, VnidropError> {
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(|| { let encoded = self.load_stored_authorization(&row).await?.ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!( VnidropError::invalid_input(anyhow::anyhow!(
"targeted transfer has no durable authorization" "targeted transfer has no durable authorization"
@@ -521,13 +602,13 @@ impl CoreInner {
})?; })?;
let auth = TargetedAuthorization::decode(&encoded)?; let auth = TargetedAuthorization::decode(&encoded)?;
auth.verify_for_receiver(&self.endpoint.id().to_string())?; auth.verify_for_receiver(&self.endpoint.id().to_string())?;
self.run_targeted_receive(&auth, output_dir).await Ok(auth)
} }
async fn run_targeted_receive( async fn run_targeted_receive(
self: &Arc<Self>, self: &Arc<Self>,
auth: &TargetedAuthorization, auth: &TargetedAuthorization,
output_dir: String, target: ReceiveTarget,
) -> Result<(), VnidropError> { ) -> Result<(), VnidropError> {
let store = self.targeted_store(); let store = self.targeted_store();
if let Ok(Some(row)) = store.get_row(&auth.transfer_id).await { if let Ok(Some(row)) = store.get_row(&auth.transfer_id).await {
@@ -584,9 +665,7 @@ impl CoreInner {
.encode() .encode()
.map_err(VnidropError::ticket)?; .map_err(VnidropError::ticket)?;
let receive_result = self let receive_result = self.receive_to_target(ticket, target, None).await;
.receive(ticket, std::path::PathBuf::from(output_dir), None)
.await;
match receive_result { match receive_result {
Ok(()) => { Ok(()) => {

View File

@@ -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. /// Record the local decision. On accept, wait for sender-issued authorization.
pub(crate) async fn respond( pub(crate) async fn respond(
&self, &self,

View File

@@ -87,28 +87,28 @@ impl TargetedTransferProtocol {
remote_endpoint_id: &str, remote_endpoint_id: &str,
challenge: &Challenge, challenge: &Challenge,
offer: SubmitTargetedOffer, offer: SubmitTargetedOffer,
) -> TargetedOfferResponse { ) -> WireOfferResponse {
let expected = experimental_saved_device_capabilities().targeted_transfer_protocol_version; let expected = experimental_saved_device_capabilities().targeted_transfer_protocol_version;
if self.inbox.cooldown().is_cooling(remote_endpoint_id) { if self.inbox.cooldown().is_cooling(remote_endpoint_id) {
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "identity-cooldown".to_string(), reason: "identity-cooldown".to_string(),
}; };
} }
if offer.protocol_version != expected { if offer.protocol_version != expected {
self.inbox.cooldown().record_malformed(remote_endpoint_id); self.inbox.cooldown().record_malformed(remote_endpoint_id);
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "protocol-incompatible".to_string(), reason: "protocol-incompatible".to_string(),
}; };
} }
if offer.receiver_endpoint_id != self.local_endpoint_id { if offer.receiver_endpoint_id != self.local_endpoint_id {
self.inbox.cooldown().record_malformed(remote_endpoint_id); self.inbox.cooldown().record_malformed(remote_endpoint_id);
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "receiver-mismatch".to_string(), reason: "receiver-mismatch".to_string(),
}; };
} }
if offer.sender_endpoint_id != remote_endpoint_id { if offer.sender_endpoint_id != remote_endpoint_id {
self.inbox.cooldown().record_malformed(remote_endpoint_id); self.inbox.cooldown().record_malformed(remote_endpoint_id);
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "sender-mismatch".to_string(), reason: "sender-mismatch".to_string(),
}; };
} }
@@ -121,7 +121,7 @@ impl TargetedTransferProtocol {
|| offer.content_hash.is_empty() || offer.content_hash.is_empty()
{ {
self.inbox.cooldown().record_malformed(remote_endpoint_id); self.inbox.cooldown().record_malformed(remote_endpoint_id);
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "manifest-limits".to_string(), reason: "manifest-limits".to_string(),
}; };
} }
@@ -130,7 +130,7 @@ impl TargetedTransferProtocol {
.validate_metadata_text("transfer name", Some(offer.transfer_name.as_str())) .validate_metadata_text("transfer name", Some(offer.transfer_name.as_str()))
{ {
self.inbox.cooldown().record_malformed(remote_endpoint_id); self.inbox.cooldown().record_malformed(remote_endpoint_id);
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: error.to_string(), reason: error.to_string(),
}; };
} }
@@ -143,7 +143,7 @@ impl TargetedTransferProtocol {
|| existing.sender_endpoint_id != offer.sender_endpoint_id || existing.sender_endpoint_id != offer.sender_endpoint_id
|| existing.receiver_endpoint_id != offer.receiver_endpoint_id || existing.receiver_endpoint_id != offer.receiver_endpoint_id
{ {
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "immutable-transfer-mismatch".to_string(), reason: "immutable-transfer-mismatch".to_string(),
}; };
} }
@@ -152,28 +152,28 @@ impl TargetedTransferProtocol {
| TargetedTransferState::Connecting | TargetedTransferState::Connecting
| TargetedTransferState::Transferring | TargetedTransferState::Transferring
| TargetedTransferState::Interrupted | TargetedTransferState::Interrupted
| TargetedTransferState::Completed => TargetedOfferResponse::Accepted, | TargetedTransferState::Completed => WireOfferResponse::Accepted,
TargetedTransferState::Declined => TargetedOfferResponse::Declined { TargetedTransferState::Declined => WireOfferResponse::Declined {
reason: "receiver-declined".to_string(), reason: "receiver-declined".to_string(),
}, },
TargetedTransferState::Cancelled => TargetedOfferResponse::Declined { TargetedTransferState::Cancelled => WireOfferResponse::Declined {
reason: "cancelled".to_string(), reason: "cancelled".to_string(),
}, },
TargetedTransferState::Failed | TargetedTransferState::Deleted => { TargetedTransferState::Failed | TargetedTransferState::Deleted => {
TargetedOfferResponse::Refused { WireOfferResponse::Refused {
reason: format!("transfer-{}", state_as_str(existing.state)), reason: format!("transfer-{}", state_as_str(existing.state)),
} }
} }
TargetedTransferState::Preparing TargetedTransferState::Preparing
| TargetedTransferState::Offering | TargetedTransferState::Offering
| TargetedTransferState::AwaitingApproval => TargetedOfferResponse::Accepted, | TargetedTransferState::AwaitingApproval => WireOfferResponse::Accepted,
}; };
} }
let remote_urls = match parse_offer_relay_urls(&offer.relay_urls) { let remote_urls = match parse_offer_relay_urls(&offer.relay_urls) {
Ok(urls) => urls, Ok(urls) => urls,
Err(_) => { Err(_) => {
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "relay-policy-incompatible".to_string(), reason: "relay-policy-incompatible".to_string(),
}; };
} }
@@ -184,7 +184,7 @@ impl TargetedTransferProtocol {
offer.relay_mode, offer.relay_mode,
&remote_urls, &remote_urls,
) { ) {
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "relay-policy-incompatible".to_string(), reason: "relay-policy-incompatible".to_string(),
}; };
} }
@@ -203,11 +203,11 @@ impl TargetedTransferProtocol {
tracing::debug!(error = %error, "targeted offer relationship proof rejected"); tracing::debug!(error = %error, "targeted offer relationship proof rejected");
self.inbox.cooldown().record_malformed(remote_endpoint_id); self.inbox.cooldown().record_malformed(remote_endpoint_id);
if matches!(error, VnidropError::ProtocolIncompatible { .. }) { if matches!(error, VnidropError::ProtocolIncompatible { .. }) {
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "protocol-incompatible".to_string(), reason: "protocol-incompatible".to_string(),
}; };
} }
return TargetedOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "unauthenticated".to_string(), reason: "unauthenticated".to_string(),
}; };
} }
@@ -226,11 +226,9 @@ impl TargetedTransferProtocol {
}; };
match self.inbox.submit(pending).await { match self.inbox.submit(pending).await {
TargetedOfferDecision::Accepted => TargetedOfferResponse::Accepted, TargetedOfferDecision::Accepted => WireOfferResponse::Accepted,
TargetedOfferDecision::Declined { reason } => { TargetedOfferDecision::Declined { reason } => WireOfferResponse::Declined { reason },
TargetedOfferResponse::Declined { reason } TargetedOfferDecision::Refused { reason } => WireOfferResponse::Refused { reason },
}
TargetedOfferDecision::Refused { reason } => TargetedOfferResponse::Refused { reason },
} }
} }
@@ -271,21 +269,21 @@ impl TargetedTransferProtocol {
&self, &self,
remote_endpoint_id: &str, remote_endpoint_id: &str,
cancel: CancelTargetedOffer, cancel: CancelTargetedOffer,
) -> CancelTargetedOfferResponse { ) -> CancelWireOfferResponse {
if let Some(pending) = self.inbox.get_pending(&cancel.transfer_id).await { if let Some(pending) = self.inbox.get_pending(&cancel.transfer_id).await {
if pending.sender_endpoint_id != remote_endpoint_id { if pending.sender_endpoint_id != remote_endpoint_id {
return CancelTargetedOfferResponse::Rejected; return CancelWireOfferResponse::Rejected;
} }
self.inbox.discard(&cancel.transfer_id).await; 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 let Ok(Some(row)) = self.store.get_row(&cancel.transfer_id).await {
if row.sender_endpoint_id != remote_endpoint_id { 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( pub(crate) async fn submit_offer(
&self, &self,
offer: SubmitTargetedOffer, offer: SubmitTargetedOffer,
) -> Result<TargetedOfferResponse, irpc::Error> { ) -> Result<WireOfferResponse, irpc::Error> {
self.inner.rpc(offer).await self.inner.rpc(offer).await
} }
@@ -356,7 +354,7 @@ impl TargetedTransferClient {
pub(crate) async fn cancel_offer( pub(crate) async fn cancel_offer(
&self, &self,
cancel: CancelTargetedOffer, cancel: CancelTargetedOffer,
) -> Result<CancelTargetedOfferResponse, irpc::Error> { ) -> Result<CancelWireOfferResponse, irpc::Error> {
self.inner.rpc(cancel).await self.inner.rpc(cancel).await
} }
} }
@@ -388,7 +386,7 @@ pub(crate) struct SubmitTargetedOffer {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum TargetedOfferResponse { pub(crate) enum WireOfferResponse {
Accepted, Accepted,
Declined { reason: String }, Declined { reason: String },
Refused { reason: String }, Refused { reason: String },
@@ -412,7 +410,7 @@ pub(crate) struct CancelTargetedOffer {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum CancelTargetedOfferResponse { pub(crate) enum CancelWireOfferResponse {
Cancelled, Cancelled,
Rejected, Rejected,
} }
@@ -426,11 +424,11 @@ pub(crate) enum CancelTargetedOfferResponse {
enum TargetedTransferMessages { enum TargetedTransferMessages {
#[rpc(tx = oneshot::Sender<ChallengeResponse>)] #[rpc(tx = oneshot::Sender<ChallengeResponse>)]
RequestChallenge(RequestChallenge), RequestChallenge(RequestChallenge),
#[rpc(tx = oneshot::Sender<TargetedOfferResponse>)] #[rpc(tx = oneshot::Sender<WireOfferResponse>)]
SubmitTargetedOffer(SubmitTargetedOffer), SubmitTargetedOffer(SubmitTargetedOffer),
#[rpc(tx = oneshot::Sender<DeliverAuthorizationResponse>)] #[rpc(tx = oneshot::Sender<DeliverAuthorizationResponse>)]
DeliverTargetedAuthorization(DeliverTargetedAuthorization), DeliverTargetedAuthorization(DeliverTargetedAuthorization),
#[rpc(tx = oneshot::Sender<CancelTargetedOfferResponse>)] #[rpc(tx = oneshot::Sender<CancelWireOfferResponse>)]
CancelTargetedOffer(CancelTargetedOffer), CancelTargetedOffer(CancelTargetedOffer),
} }

View File

@@ -439,7 +439,7 @@ fn saved_device_cap_blocks_only_new_relationships() {
Some("payload.txt".to_string()), Some("payload.txt".to_string()),
) )
.unwrap(); .unwrap();
accept.join().unwrap().unwrap(); accept.join().unwrap();
// New relationship is refused while the cap is full. // New relationship is refused while the cap is full.
complete_transfer(&alice, &carol, 13_002); complete_transfer(&alice, &carol, 13_002);

View File

@@ -21,8 +21,9 @@ use crate::{
android::{AndroidKeystore, AndroidSealedValue, AndroidSecureSecretStore}, android::{AndroidKeystore, AndroidSealedValue, AndroidSecureSecretStore},
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError,
}, },
CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind, CoreEvent, CoreEventSink, DeviceRelationshipState, PublishedOutput, ReceiveOutputSinkV2,
TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError, ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState,
TransferAccessMode, VnidropCore, VnidropError,
}; };
#[derive(Default)] #[derive(Default)]
@@ -408,7 +409,7 @@ fn approve_targeted(
bob: &AndroidContractNode, bob: &AndroidContractNode,
payload: &[u8], payload: &[u8],
name: &str, name: &str,
) -> (crate::TargetedTransfer, String) { ) -> crate::TargetedTransfer {
let bob_id = bob.core().status().endpoint_id.clone(); let bob_id = bob.core().status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join(name); let source_path = source_dir.path().join(name);
@@ -429,8 +430,12 @@ fn approve_targeted(
Some(name.to_string()), Some(name.to_string()),
) )
.unwrap(); .unwrap();
let auth = accept.join().unwrap().expect("authorization"); let response = accept.join().unwrap();
(transfer, auth) assert!(matches!(
response,
crate::TargetedOfferResponse::Approved { .. }
));
transfer
} }
#[test] #[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].endpoint_id, bob_id);
assert_eq!(saved[0].local_label.as_deref(), Some("Kitchen Tablet")); 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!(transfer.receiver_endpoint_id, bob_id);
assert_eq!( assert_eq!(
bob.core() 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()); 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<HashMap<String, Vec<u8>>>,
published: Mutex<HashMap<String, PublishedOutput>>,
}
impl AndroidMediaStoreSink {
fn bytes(&self, relative_path: &str) -> Vec<u8> {
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<u8>) -> Result<(), VnidropError> {
self.files
.lock()
.unwrap()
.get_mut(&relative_path)
.expect("started")
.extend(bytes);
Ok(())
}
fn finish_file(&self, relative_path: String) -> Result<PublishedOutput, VnidropError> {
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<u8>) -> Result<(), VnidropError> {
Err(VnidropError::Filesystem {
reason: "media store write failed".to_string(),
})
}
fn finish_file(&self, _relative_path: String) -> Result<PublishedOutput, VnidropError> {
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] #[test]
fn locked_or_invalidated_identity_blocks_restart() { fn locked_or_invalidated_identity_blocks_restart() {
let mut alice = AndroidContractNode::new(); let mut alice = AndroidContractNode::new();

View File

@@ -508,7 +508,7 @@ fn approve_one(
bob: &Arc<VnidropCore>, bob: &Arc<VnidropCore>,
payload: &[u8], payload: &[u8],
name: &str, name: &str,
) -> (crate::TargetedTransfer, String) { ) -> crate::TargetedTransfer {
let bob_id = bob.status().endpoint_id.clone(); let bob_id = bob.status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join(name); let source_path = source_dir.path().join(name);
@@ -528,8 +528,12 @@ fn approve_one(
Some(name.to_string()), Some(name.to_string()),
) )
.unwrap(); .unwrap();
let auth = accept.join().unwrap().expect("authorization"); let response = accept.join().unwrap();
(transfer, auth) assert!(matches!(
response,
crate::TargetedOfferResponse::Approved { .. }
));
transfer
} }
fn recover_authoritative_state( 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].endpoint_id, bob_id);
assert_eq!(saved[0].local_label.as_deref(), Some("Bob Mac")); 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); assert_eq!(transfer.receiver_endpoint_id, bob_id);
let alice = alice.restart(); let alice = alice.restart();

View File

@@ -448,7 +448,7 @@ fn approve_one(
bob: &Arc<VnidropCore>, bob: &Arc<VnidropCore>,
payload: &[u8], payload: &[u8],
name: &str, name: &str,
) -> (crate::TargetedTransfer, String) { ) -> crate::TargetedTransfer {
let bob_id = bob.status().endpoint_id.clone(); let bob_id = bob.status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join(name); let source_path = source_dir.path().join(name);
@@ -468,8 +468,12 @@ fn approve_one(
Some(name.to_string()), Some(name.to_string()),
) )
.unwrap(); .unwrap();
let auth = accept.join().unwrap().expect("authorization"); let response = accept.join().unwrap();
(transfer, auth) assert!(matches!(
response,
crate::TargetedOfferResponse::Approved { .. }
));
transfer
} }
fn recover_authoritative_state( 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].endpoint_id, bob_id);
assert_eq!(saved[0].local_label.as_deref(), Some("Bob Linux")); 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); assert_eq!(transfer.receiver_endpoint_id, bob_id);
let alice = alice.restart(); let alice = alice.restart();

View File

@@ -367,7 +367,11 @@ fn public_api_exercises_complete_windows_saved_device_contract() {
Some("payload.txt".to_string()), Some("payload.txt".to_string()),
) )
.unwrap(); .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); assert_eq!(transfer.state, TargetedTransferState::Approved);
// Interrupt via receiver restart, then resume without re-approval. // Interrupt via receiver restart, then resume without re-approval.

View File

@@ -6,7 +6,8 @@ use std::{
use crate::{ use crate::{
secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, CoreNetworkConfig, 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, SourceKind, TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
}; };
@@ -272,7 +273,12 @@ fn create_targeted_transfer_is_immutable_and_saved_only() {
Some("payload.txt".to_string()), Some("payload.txt".to_string()),
) )
.unwrap(); .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!( assert_eq!(
transfer.sender_endpoint_id, transfer.sender_endpoint_id,
@@ -339,7 +345,7 @@ fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() {
Some("payload.txt".to_string()), Some("payload.txt".to_string()),
) )
.unwrap(); .unwrap();
accept.join().unwrap().unwrap(); accept.join().unwrap();
} }
fn alice_id_from(bob: &VnidropCore) -> String { 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)); std::thread::sleep(Duration::from_millis(25));
}; };
let _ = transfer_id; let _ = transfer_id.clone();
// Without an approved authorization, receive must fail — no content yet. // Without durable authorization, receive by id must fail — no content yet.
let early_receive = bob.core().receive_targeted_transfer( let early_receive = bob.core().receive_targeted_transfer(
"not-a-real-authorization".to_string(), transfer_id.clone(),
tempfile::tempdir() tempfile::tempdir()
.unwrap() .unwrap()
.path() .path()
@@ -442,12 +448,19 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
assert!(early_receive.is_err()); assert!(early_receive.is_err());
*gate.lock().unwrap() = true; *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(); create.join().unwrap().unwrap();
let output = tempfile::tempdir().unwrap(); let output = tempfile::tempdir().unwrap();
bob.core() 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(); .unwrap();
assert_eq!( assert_eq!(
std::fs::read(output.path().join("payload.txt")).unwrap(), 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 charlie_output = tempfile::tempdir().unwrap();
let leaked = charlie let leaked = charlie.core().receive_targeted_transfer(
.core() transfer_id,
.receive_targeted_transfer(auth, charlie_output.path().to_string_lossy().into_owned()); charlie_output.path().to_string_lossy().into_owned(),
);
assert!( assert!(
leaked.is_err(), 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, bob: &ProtectedNode,
payload: &[u8], payload: &[u8],
name: &str, name: &str,
) -> (crate::TargetedTransfer, String) { ) -> crate::TargetedTransfer {
let bob_id = bob.core().status().endpoint_id.clone(); let bob_id = bob.core().status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join(name); let source_path = source_dir.path().join(name);
@@ -537,8 +551,12 @@ fn approve_one(
Some(name.to_string()), Some(name.to_string()),
) )
.unwrap(); .unwrap();
let auth = accept.join().unwrap().expect("authorization"); let response = accept.join().unwrap();
(transfer, auth) assert!(matches!(
response,
crate::TargetedOfferResponse::Approved { .. }
));
transfer
} }
#[test] #[test]
@@ -546,15 +564,19 @@ fn protocol_ops_are_idempotent_for_stable_transfer_id() {
let alice = ProtectedNode::new(); let alice = ProtectedNode::new();
let bob = ProtectedNode::new(); let bob = ProtectedNode::new();
establish_saved(&alice, &bob, 11_001); 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 let again = bob
.core() .core()
.respond_to_targeted_offer(transfer.id.clone(), true) .respond_to_targeted_offer(transfer.id.clone(), true)
.unwrap() .unwrap();
.expect("idempotent authorization"); assert_eq!(
assert_eq!(again, auth); again,
crate::TargetedOfferResponse::AlreadySettled {
transfer_id: transfer.id.clone()
}
);
let listed = bob let listed = bob
.core() .core()
@@ -627,7 +649,7 @@ fn approved_transfer_resumes_after_restart_without_reapproval() {
let alice = ProtectedNode::new(); let alice = ProtectedNode::new();
let bob = ProtectedNode::new(); let bob = ProtectedNode::new();
establish_saved(&alice, &bob, 11_020); 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 let bob_before = bob
.core() .core()
@@ -678,8 +700,8 @@ fn manifest_change_requires_new_transfer_identity() {
let alice = ProtectedNode::new(); let alice = ProtectedNode::new();
let bob = ProtectedNode::new(); let bob = ProtectedNode::new();
establish_saved(&alice, &bob, 11_030); establish_saved(&alice, &bob, 11_030);
let (first, _) = approve_one(&alice, &bob, b"first manifest", "a.txt"); let first = approve_one(&alice, &bob, b"first manifest", "a.txt");
let (second, _) = approve_one(&alice, &bob, b"second manifest", "b.txt"); let second = approve_one(&alice, &bob, b"second manifest", "b.txt");
assert_ne!(first.id, second.id); assert_ne!(first.id, second.id);
assert_ne!(first.manifest_id, second.manifest_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 alice = ProtectedNode::new();
let bob = ProtectedNode::new(); let bob = ProtectedNode::new();
establish_saved(&alice, &bob, 11_040); 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 alice
.core() .core()
@@ -705,7 +727,7 @@ fn cancel_revokes_access_and_stops_streaming() {
let output = tempfile::tempdir().unwrap(); let output = tempfile::tempdir().unwrap();
let receive = bob let receive = bob
.core() .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!( assert!(
receive.is_err(), receive.is_err(),
"cancelled transfer must not remain receivable" "cancelled transfer must not remain receivable"
@@ -717,7 +739,7 @@ fn delete_removes_authorization_and_resumable_state() {
let alice = ProtectedNode::new(); let alice = ProtectedNode::new();
let bob = ProtectedNode::new(); let bob = ProtectedNode::new();
establish_saved(&alice, &bob, 11_050); 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() bob.core()
.delete_targeted_transfer(transfer.id.clone()) .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"); assert!(resume.is_err(), "deleted transfer must not resume");
let receive = bob.core().receive_targeted_transfer( let receive = bob.core().receive_targeted_transfer(
auth, transfer.id.clone(),
tempfile::tempdir() tempfile::tempdir()
.unwrap() .unwrap()
.path() .path()
.to_string_lossy() .to_string_lossy()
.into_owned(), .into_owned(),
); );
// Auth blob may still decode, but durable resume path is gone; receive may assert!(
// still attempt content pull if sender serves — sender delete is separate. receive.is_err(),
let _ = receive; "deleted transfer must not remain receivable by id"
);
alice alice
.core() .core()
@@ -772,8 +795,8 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() {
// Design allows only one unresolved offer per sender; approve sequentially, // Design allows only one unresolved offer per sender; approve sequentially,
// then prove independent approved transfers do not corrupt each other. // then prove independent approved transfers do not corrupt each other.
let (first, first_auth) = approve_one(&alice, &bob, b"alpha", "one.txt"); let first = approve_one(&alice, &bob, b"alpha", "one.txt");
let (second, second_auth) = approve_one(&alice, &bob, b"beta-payload", "two.txt"); let second = approve_one(&alice, &bob, b"beta-payload", "two.txt");
assert_ne!(first.id, second.id); assert_ne!(first.id, second.id);
alice alice
@@ -783,7 +806,7 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() {
assert_eq!( assert_eq!(
alice alice
.core() .core()
.get_targeted_transfer(first.id) .get_targeted_transfer(first.id.clone())
.unwrap() .unwrap()
.unwrap() .unwrap()
.state, .state,
@@ -801,7 +824,10 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() {
let output = tempfile::tempdir().unwrap(); let output = tempfile::tempdir().unwrap();
bob.core() 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(); .unwrap();
assert_eq!( assert_eq!(
std::fs::read(output.path().join("two.txt")).unwrap(), std::fs::read(output.path().join("two.txt")).unwrap(),
@@ -812,7 +838,7 @@ fn concurrent_independent_transfers_between_same_devices_are_isolated() {
assert!(bob assert!(bob
.core() .core()
.receive_targeted_transfer( .receive_targeted_transfer(
first_auth, first.id,
cancelled_output.path().to_string_lossy().into_owned(), cancelled_output.path().to_string_lossy().into_owned(),
) )
.is_err()); .is_err());
@@ -842,10 +868,16 @@ fn complete_targeted_roundtrip(alice: &ProtectedNode, bob: &ProtectedNode, trans
Some("payload.txt".to_string()), Some("payload.txt".to_string()),
) )
.unwrap(); .unwrap();
let auth = accept.join().unwrap().expect("authorization"); assert!(matches!(
accept.join().unwrap(),
crate::TargetedOfferResponse::Approved { .. }
));
let output = tempfile::tempdir().unwrap(); let output = tempfile::tempdir().unwrap();
bob.core() 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(); .unwrap();
assert_eq!( assert_eq!(
std::fs::read(output.path().join("payload.txt")).unwrap(), std::fs::read(output.path().join("payload.txt")).unwrap(),
@@ -1097,3 +1129,133 @@ fn start_loopback_relay() -> LoopbackRelay {
thread: Some(thread), thread: Some(thread),
} }
} }
#[derive(Default)]
struct MemoryOutputSink {
files: Mutex<std::collections::HashMap<String, Vec<u8>>>,
}
impl MemoryOutputSink {
fn file(&self, relative_path: &str) -> Vec<u8> {
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<u8>) -> 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<u8>) -> Result<(), VnidropError> {
ReceiveOutputSink::write_chunk(self, relative_path, bytes)
}
fn finish_file(&self, relative_path: String) -> Result<PublishedOutput, VnidropError> {
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
);
}