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

@@ -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.

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<Option<String>, VnidropError> {
) -> Result<crate::api::TargetedOfferResponse, VnidropError> {
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<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))
}
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> {
self.block_on(self.inner.repository.list_transfers())
.map_err(VnidropError::repository)

View File

@@ -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<Self>,
transfer_id: String,
accepted: bool,
) -> Result<Option<String>, VnidropError> {
if let Some(auth) = self
.targeted_offers
.settled_authorization(&transfer_id)
.await
{
return Ok(Some(auth));
) -> Result<TargetedOfferResponse, VnidropError> {
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<Self>,
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<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(
self: &Arc<Self>,
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<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> {
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<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(|| {
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<Self>,
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(()) => {

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.
pub(crate) async fn respond(
&self,

View File

@@ -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<TargetedOfferResponse, irpc::Error> {
) -> Result<WireOfferResponse, irpc::Error> {
self.inner.rpc(offer).await
}
@@ -356,7 +354,7 @@ impl TargetedTransferClient {
pub(crate) async fn cancel_offer(
&self,
cancel: CancelTargetedOffer,
) -> Result<CancelTargetedOfferResponse, irpc::Error> {
) -> Result<CancelWireOfferResponse, irpc::Error> {
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<ChallengeResponse>)]
RequestChallenge(RequestChallenge),
#[rpc(tx = oneshot::Sender<TargetedOfferResponse>)]
#[rpc(tx = oneshot::Sender<WireOfferResponse>)]
SubmitTargetedOffer(SubmitTargetedOffer),
#[rpc(tx = oneshot::Sender<DeliverAuthorizationResponse>)]
DeliverTargetedAuthorization(DeliverTargetedAuthorization),
#[rpc(tx = oneshot::Sender<CancelTargetedOfferResponse>)]
#[rpc(tx = oneshot::Sender<CancelWireOfferResponse>)]
CancelTargetedOffer(CancelTargetedOffer),
}

View File

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

View File

@@ -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<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]
fn locked_or_invalidated_identity_blocks_restart() {
let mut alice = AndroidContractNode::new();

View File

@@ -508,7 +508,7 @@ fn approve_one(
bob: &Arc<VnidropCore>,
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();

View File

@@ -448,7 +448,7 @@ fn approve_one(
bob: &Arc<VnidropCore>,
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();

View File

@@ -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.

View File

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