mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(()) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user