From 2ac9166b34f9dddd679ea0c9dbb1c071b53f96b1 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 12 Aug 2026 18:48:13 +0200 Subject: [PATCH] fix(core): harden targeted transfer recovery --- DESIGN-DEVICE-HISTORY.md | 16 +- crates/vnidrop/AGENTS.md | 6 +- crates/vnidrop/CORE_FLOW.md | 3 +- crates/vnidrop/src/runtime/facade.rs | 131 +- crates/vnidrop/src/runtime/lifecycle.rs | 2 +- crates/vnidrop/src/runtime/mod.rs | 147 ++- crates/vnidrop/src/runtime/receive.rs | 196 +-- crates/vnidrop/src/runtime/targeted.rs | 1101 +++-------------- crates/vnidrop/src/runtime/targeted_create.rs | 400 ++++++ .../vnidrop/src/runtime/targeted_payload.rs | 197 +++ .../vnidrop/src/runtime/targeted_receive.rs | 381 ++++++ .../src/runtime/targeted_reconciliation.rs | 214 ++++ crates/vnidrop/src/secure_secret.rs | 35 + crates/vnidrop/src/targeted_transfer/inbox.rs | 58 +- crates/vnidrop/src/targeted_transfer/mod.rs | 5 +- .../vnidrop/src/targeted_transfer/protocol.rs | 95 +- .../vnidrop/src/targeted_transfer/schema.rs | 167 +++ crates/vnidrop/src/targeted_transfer/store.rs | 595 +++++---- .../src/targeted_transfer/store_outbox.rs | 311 +++++ crates/vnidrop/src/tests/persistence.rs | 65 + crates/vnidrop/src/tests/targeted_transfer.rs | 512 +++++++- .../com/vnidrop/app/CoreNativeLoadTest.kt | 8 +- .../com/vnidrop/app/HostTestAssumptions.kt | 8 + .../app/core/CoreRepositoryStorageTest.kt | 3 +- .../SavedDeviceCoreContractJvmSmokeTest.kt | 17 +- 25 files changed, 3164 insertions(+), 1509 deletions(-) create mode 100644 crates/vnidrop/src/runtime/targeted_create.rs create mode 100644 crates/vnidrop/src/runtime/targeted_payload.rs create mode 100644 crates/vnidrop/src/runtime/targeted_receive.rs create mode 100644 crates/vnidrop/src/runtime/targeted_reconciliation.rs create mode 100644 crates/vnidrop/src/targeted_transfer/schema.rs create mode 100644 crates/vnidrop/src/targeted_transfer/store_outbox.rs create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/HostTestAssumptions.kt diff --git a/DESIGN-DEVICE-HISTORY.md b/DESIGN-DEVICE-HISTORY.md index 675ab77..71b674d 100644 --- a/DESIGN-DEVICE-HISTORY.md +++ b/DESIGN-DEVICE-HISTORY.md @@ -1,11 +1,11 @@ # Design — Saved devices and targeted transfers -Status: **experimental foundation for the 0.3.x line**. +Status: **production Rust core capability; product UI graduation is deferred**. The unreleased contact/held-offer/polling prototype has been removed. The implementation on this branch is the versioned saved-device, device-relationship, -and targeted-transfer foundation described below. Its wire protocol is -experimental and versioned; product UI remains deferred. +and targeted-transfer core described below. Its wire protocol and public core +surface are production contracts; KMP and Apple product UI remain deferred. The feature lets two VniDrop installations remember one another after a successful transfer, with explicit consent on both devices. A saved device can @@ -432,8 +432,8 @@ 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. +wake-up: refresh durable state via list/get APIs. Targeted progress persists +monotonic `verified_bytes`; event payloads remain advisory. **`pairing`** @@ -510,8 +510,10 @@ capabilities. A peer without compatible support cannot be paired or receive a targeted transfer and falls back to the existing invitation flow. A targeted transfer must never be reinterpreted as an ordinary share for compatibility. -The feature is gated as experimental in the 0.3.x line. The wire protocol is -versioned from its first merge. Removing the experimental gate requires: +The Rust core feature has passed its production release gate. Its wire protocol +is versioned from its first merge. KMP and Apple Saved-device UI graduation, +including their existing experimental preference gates, is a separate release +decision. Future core protocol revisions continue to require: - Stable migrations from every released database version. - Compatible Apple, Android, Windows, and Linux credential-store adapters. diff --git a/crates/vnidrop/AGENTS.md b/crates/vnidrop/AGENTS.md index c910128..ef77541 100644 --- a/crates/vnidrop/AGENTS.md +++ b/crates/vnidrop/AGENTS.md @@ -55,7 +55,11 @@ src/ lifecycle.rs # cancel share, delete, status, access mode, shutdown provider.rs # provider messages, per-peer transfer progress saved_devices.rs # saved-device pairing, forget, block - targeted.rs # saved-device targeted transfers + targeted.rs # targeted lifecycle and restart restoration + targeted_create.rs # targeted import, offer, and sender approval + targeted_receive.rs # targeted download, resume, and completion + targeted_payload.rs # targeted blob fetch and export bridge + targeted_reconciliation.rs # durable delivery and cleanup retries persistence.rs # AppDataStores / persistence open (domain stores) invitation/ # invitation-transfer domain store (type name: Repository) pairing_eligibility/ # eligibility service + store diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 2f3ca39..0d6bda1 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -79,7 +79,8 @@ bytes through Kotlin memory. ### Pairing and targeted-transfer event catalog Treat every event as a wake-up: refresh durable state via list/get APIs. -Mid-transfer progress polish may follow. +Targeted progress updates persist monotonic `verified_bytes`; event payloads are +advisory and the durable targeted-transfer snapshot is authoritative. **`pairing`:** `eligibility-available`, `eligibility-removed`, `relationship-changed`, `relationship-grant-rotated`, `saved-device-forgotten`, diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 472fe67..124a4f2 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -186,6 +186,135 @@ impl VnidropCore { .store(suppress, std::sync::atomic::Ordering::SeqCst); } + pub(crate) fn suppress_targeted_authorization_delivery_for_test(&self, suppress: bool) { + self.inner + .suppress_targeted_authorization_delivery + .store(suppress, std::sync::atomic::Ordering::SeqCst); + } + + pub(crate) fn accept_targeted_offer_without_waiting_for_test( + &self, + id: String, + ) -> Result<(), VnidropError> { + self.block_on(async { + let offer = self + .inner + .targeted_offers + .pending_for_acceptance(&id) + .await + .ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")) + })?; + self.inner + .targeted_store() + .persist_accepted_offer_intent(&offer) + .await?; + self.inner + .targeted_offers + .accept_live(&id) + .await + .map_err(|_| { + VnidropError::device_unavailable(anyhow::anyhow!( + "sender disconnected before acceptance" + )) + }) + }) + } + + pub(crate) fn persist_block_without_cleanup_for_test( + &self, + endpoint_id: String, + ) -> Result<(), VnidropError> { + self.block_on(async { + self.inner + .blocked_devices + .block_endpoint(&endpoint_id, crate::util::now_ms()) + .await + .map_err(VnidropError::repository) + }) + } + + pub(crate) fn corrupt_targeted_content_hash_for_test( + &self, + id: String, + ) -> Result<(), VnidropError> { + self.block_on( + self.inner + .targeted_store() + .corrupt_content_hash_for_test(&id), + ) + } + + pub(crate) fn create_orphaned_targeted_authorization_for_test( + &self, + ) -> Result<(), VnidropError> { + self.block_on(async { + let custody = self.inner.secret_custody.as_ref().ok_or_else(|| { + VnidropError::SecureStorageUnavailable { + reason: "test custody unavailable".to_string(), + } + })?; + custody + .create_orphaned_targeted_authorization_for_test() + .await + }) + } + + pub(crate) fn targeted_authorization_handle_count_for_test( + &self, + ) -> Result { + self.block_on(async { + let custody = self.inner.secret_custody.as_ref().ok_or_else(|| { + VnidropError::SecureStorageUnavailable { + reason: "test custody unavailable".to_string(), + } + })?; + Ok(custody + .list_active_handles(crate::secure_secret::SecretKind::TargetedAuthorization) + .await? + .len()) + }) + } + + pub(crate) fn redeliver_targeted_authorization_for_test( + &self, + id: String, + ) -> Result { + self.block_on(async { + let row = self + .inner + .targeted_store() + .get_row(&id) + .await? + .ok_or_else(|| VnidropError::invalid_input(anyhow::anyhow!("unknown transfer")))?; + self.inner.deliver_stored_targeted_authorization(&row).await + }) + } + + pub(crate) fn targeted_authorization_delivery_attempts_for_test(&self) -> u64 { + self.inner + .targeted_authorization_delivery_attempts + .load(std::sync::atomic::Ordering::SeqCst) + } + + pub(crate) fn hold_all_transfer_slots_for_test(&self) -> tokio::sync::oneshot::Sender<()> { + let inner = self.inner.clone(); + let permits = inner.limits.max_concurrent_transfers as u32; + let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + self.runtime.handle().spawn(async move { + let _permits = inner + .transfer_slots + .acquire_many(permits) + .await + .expect("transfer limiter open"); + ready_tx.send(()).expect("slot holder ready"); + let _ = release_rx.await; + }); + ready_rx.recv().expect("slot holder started"); + release_tx + } + pub(crate) fn targeted_payload_is_registered_for_test( &self, id: String, @@ -639,7 +768,7 @@ impl VnidropCore { )) } - /// Offline-only pending offers awaiting explicit local approval. + /// Ticket-free pending offers awaiting explicit local approval. pub fn list_pending_targeted_offers(&self) -> Vec { self.block_on(self.inner.list_pending_targeted_offers()) } diff --git a/crates/vnidrop/src/runtime/lifecycle.rs b/crates/vnidrop/src/runtime/lifecycle.rs index e1ea33a..0d1c9fa 100644 --- a/crates/vnidrop/src/runtime/lifecycle.rs +++ b/crates/vnidrop/src/runtime/lifecycle.rs @@ -191,7 +191,7 @@ impl CoreInner { task.abort(); let _ = task.await; } - if let Some(task) = self.targeted_completion_task.lock().await.take() { + if let Some(task) = self.targeted_reconciliation_task.lock().await.take() { task.abort(); let _ = task.await; } diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index 0a52727..734bbb7 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -18,6 +18,10 @@ mod saved_devices; mod share; mod storage; mod targeted; +mod targeted_create; +mod targeted_payload; +mod targeted_receive; +mod targeted_reconciliation; pub use facade::VnidropCore; #[cfg(test)] @@ -122,13 +126,17 @@ pub(super) struct CoreInner { pub(super) provider_task: TokioMutex>>, pub(super) delivery_receipt_notify: Notify, pub(super) delivery_receipt_task: TokioMutex>>, - pub(super) targeted_completion_task: TokioMutex>>, + pub(super) targeted_reconciliation_task: TokioMutex>>, pub(super) shutdown_started: AtomicBool, /// Test-only log of peers passed to [`Self::cancel_targeted_transfers_for_peer`]. #[cfg(test)] targeted_cancel_log: std::sync::Mutex>, #[cfg(test)] suppress_targeted_completion: AtomicBool, + #[cfg(test)] + suppress_targeted_authorization_delivery: AtomicBool, + #[cfg(test)] + targeted_authorization_delivery_attempts: std::sync::atomic::AtomicU64, } pub(super) struct ActiveTransfer { @@ -398,6 +406,15 @@ impl CoreInner { limits.max_saved_devices, limits.pairing_timeout_ms, )); + if let Some(custody) = &secret_custody { + let referenced = targeted_transfers.authorization_secret_handles().await?; + let removed = custody + .remove_orphaned_targeted_authorizations(&referenced) + .await?; + if removed > 0 { + tracing::warn!(removed, "removed orphaned targeted authorization secrets"); + } + } let active_transfers = Arc::new(std::sync::Mutex::new(HashMap::::new())); let active_targeted_transfers = Arc::new(std::sync::Mutex::new(HashMap::< @@ -449,6 +466,125 @@ impl CoreInner { Ok(()) }) as crate::targeted_transfer::protocol::TargetedCleanupFuture }); + let authorization_custody = secret_custody.clone(); + let authorization_store = targeted_transfers.clone(); + let authorization_repository = repository.clone(); + let authorization_events = event_hub.clone(); + let persist_targeted_authorization = Arc::new( + move |authorization: crate::targeted_transfer::TargetedAuthorization| { + let custody = authorization_custody.clone(); + let transfers = authorization_store.clone(); + let repository = authorization_repository.clone(); + let events = authorization_events.clone(); + Box::pin(async move { + let custody = custody.ok_or_else(|| { + crate::error::VnidropError::SecureStorageUnavailable { + reason: "targeted authorization requires protected custody".to_string(), + } + })?; + if let Some(row) = transfers.get_row(&authorization.transfer_id).await? { + let exact = row.role + == crate::targeted_transfer::TargetedTransferRole::Receiver + && matches!( + row.state, + crate::api::TargetedTransferState::Approved + | crate::api::TargetedTransferState::Connecting + | crate::api::TargetedTransferState::Transferring + | crate::api::TargetedTransferState::Interrupted + | crate::api::TargetedTransferState::Completed + ) + && row.protocol_transfer_id == authorization.protocol_transfer_id + && row.sender_endpoint_id == authorization.sender_endpoint_id + && row.receiver_endpoint_id == authorization.receiver_endpoint_id + && row.manifest_id == authorization.manifest_id + && row.content_hash == authorization.content_hash + && row.transfer_name == authorization.transfer_name + && row.file_count == authorization.file_count + && row.total_size == authorization.total_size + && row.blob_ticket.as_deref() + == Some(authorization.blob_ticket.as_str()); + let Some(handle) = row.authorization_secret_handle else { + return Err(crate::error::VnidropError::SecureStorageMissing { + reason: "receiver authorization handle is missing".to_string(), + }); + }; + if !exact { + return Err(crate::error::VnidropError::permission(anyhow::anyhow!( + "targeted authorization conflicts with receiver state" + ))); + } + let material = custody + .load(&crate::secure_secret::SecretHandle::from_stored(handle)) + .await?; + let rebuilt = crate::targeted_transfer::reconstruct_authorization( + crate::targeted_transfer::TargetedAuthorizationDraft { + transfer_id: row.id, + protocol_transfer_id: row.protocol_transfer_id, + sender_endpoint_id: row.sender_endpoint_id, + receiver_endpoint_id: row.receiver_endpoint_id, + manifest_id: row.manifest_id, + content_hash: row.content_hash, + file_count: row.file_count, + total_size: row.total_size, + protocol_version: authorization.protocol_version, + transfer_name: row.transfer_name, + blob_ticket: row.blob_ticket.expect("checked blob ticket"), + }, + &material, + )?; + if rebuilt.encode()? != authorization.encode()? { + return Err(crate::error::VnidropError::permission(anyhow::anyhow!( + "protected receiver authorization does not match delivery" + ))); + } + return Ok(false); + } + let invitation_collision = repository + .list_transfers() + .await + .map_err(crate::error::VnidropError::repository)? + .into_iter() + .any(|transfer| transfer.transfer_id == authorization.protocol_transfer_id); + if invitation_collision { + return Err(crate::error::VnidropError::invalid_input(anyhow::anyhow!( + "targeted transfer protocol id collides with invitation work" + ))); + } + let handle = custody + .protect( + crate::secure_secret::SecretKind::TargetedAuthorization, + crate::targeted_transfer::auth_secret_material(&authorization)?, + None, + ) + .await?; + let created = match transfers + .persist_receiver_authorization_and_consume_intent( + &authorization, + handle.as_str(), + ) + .await + { + Ok(created) => created, + Err(error) => { + if let Err(cleanup_error) = custody.remove(&handle).await { + tracing::warn!(%cleanup_error, "failed to roll back receiver authorization secret"); + } + return Err(error); + } + }; + if created { + // Protocol callbacks are durable wake-ups, including restart recovery. + events.emit_endpoint( + "targeted_transfer", + "approved", + serde_json::json!({ "targeted_transfer_id": authorization.transfer_id }), + ); + } + Ok(created) + }) + as crate::targeted_transfer::protocol::TargetedAuthorizationPersistFuture + }, + ); let router = Router::builder(endpoint.clone()) .accept(iroh_blobs::ALPN, blobs) .accept(HandshakeService::ALPN, handshake) @@ -469,6 +605,7 @@ impl CoreInner { event_hub.clone(), access_policy.clone(), targeted_cleanup, + persist_targeted_authorization, ), ) .spawn(); @@ -501,12 +638,16 @@ impl CoreInner { provider_task: TokioMutex::new(None), delivery_receipt_notify: Notify::new(), delivery_receipt_task: TokioMutex::new(None), - targeted_completion_task: TokioMutex::new(None), + targeted_reconciliation_task: TokioMutex::new(None), shutdown_started: AtomicBool::new(false), #[cfg(test)] targeted_cancel_log: std::sync::Mutex::new(Vec::new()), #[cfg(test)] suppress_targeted_completion: AtomicBool::new(false), + #[cfg(test)] + suppress_targeted_authorization_delivery: AtomicBool::new(false), + #[cfg(test)] + targeted_authorization_delivery_attempts: std::sync::atomic::AtomicU64::new(0), }); // In-flight connecting/transferring transfers become Interrupted across restart. @@ -536,7 +677,7 @@ impl CoreInner { ); inner.spawn_provider_event_task(event_rx).await; inner.spawn_delivery_receipt_task().await; - inner.spawn_targeted_completion_task().await; + inner.spawn_targeted_reconciliation_task().await; if let Err(error) = inner.pairing_eligibility.reconcile().await { tracing::warn!(%error, "failed to reconcile pairing eligibility"); } diff --git a/crates/vnidrop/src/runtime/receive.rs b/crates/vnidrop/src/runtime/receive.rs index 69b0461..bafc228 100644 --- a/crates/vnidrop/src/runtime/receive.rs +++ b/crates/vnidrop/src/runtime/receive.rs @@ -182,196 +182,10 @@ impl CoreInner { .await } - pub(super) async fn receive_targeted_payload( - self: &Arc, - targeted_transfer_id: &str, - transfer_id: u64, - expected_file_count: u64, - expected_payload_size: u64, - mut blob_ticket: BlobTicket, - target: ReceiveTarget, - ) -> Result<()> { - let _permit = self - .transfer_slots - .acquire() - .await - .context("transfer limiter is closed") - .map_err(VnidropError::internal)?; - let sender_addr = filter_peer_addr_for_relay_mode( - blob_ticket.addr(), - self.relay_mode, - &self.custom_relay_urls, - ) - .map_err(VnidropError::network)?; - blob_ticket = BlobTicket::new(sender_addr, blob_ticket.hash(), blob_ticket.format()); - let (cancel, mut cancelled) = oneshot::channel(); - self.active_targeted_transfers - .lock() - .expect("active_targeted_transfers") - .insert( - targeted_transfer_id.to_string(), - ActiveTransfer { - direction: TransferDirection::Receive, - cancel, - }, - ); - let result = tokio::select! { - biased; - _ = &mut cancelled => Err(VnidropError::cancelled("transfer cancelled").into()), - result = self.download_targeted_payload( - targeted_transfer_id, - transfer_id, - expected_file_count, - expected_payload_size, - blob_ticket, - target, - ) => result, - }; - self.active_targeted_transfers - .lock() - .expect("active_targeted_transfers") - .remove(targeted_transfer_id); - result - } - - async fn download_targeted_payload( - &self, - targeted_transfer_id: &str, - transfer_id: u64, - expected_file_count: u64, - expected_payload_size: u64, - blob_ticket: BlobTicket, - target: ReceiveTarget, - ) -> Result<()> { - if let ReceiveTarget::Directory(output_dir) = &target { - tokio::fs::create_dir_all(output_dir) - .await - .map_err(VnidropError::filesystem)?; - } - let connection = self - .endpoint - .connect(blob_ticket.addr().clone(), iroh_blobs::ALPN) - .await - .map_err(VnidropError::network)?; - let hash_and_format = blob_ticket.hash_and_format(); - let (hash_seq, sizes) = - get_hash_seq_and_sizes(&connection, &hash_and_format.hash, 1024 * 1024 * 32, None) - .await - .context("failed to get targeted payload sizes") - .map_err(VnidropError::network)?; - let remote_size = sizes - .iter() - .try_fold(0u64, |total, size| total.checked_add(*size)) - .context("remote collection size overflow")?; - let total_files = sizes.len().saturating_sub(1) as u64; - if total_files != expected_file_count { - anyhow::bail!( - "targeted payload file count {total_files} does not match authorized count {expected_file_count}" - ); - } - let hash_sequence_bytes = hash_seq.len() as u64 * 32; - let collection_metadata_bytes = sizes.first().copied().unwrap_or(0); - let payload_size = sizes - .iter() - .skip(1) - .try_fold(0u64, |total, size| total.checked_add(*size)) - .context("remote targeted payload size overflow")?; - if payload_size != expected_payload_size { - anyhow::bail!( - "targeted payload size {payload_size} does not match authorized size {expected_payload_size}" - ); - } - if total_files > self.limits.max_collection_files { - anyhow::bail!( - "remote collection has {total_files} files, limit is {}", - self.limits.max_collection_files - ); - } - if remote_size > self.limits.max_total_bytes { - anyhow::bail!( - "remote collection size {remote_size} exceeds limit {}", - self.limits.max_total_bytes - ); - } - let download_tag = self.store.tags().temp_tag(hash_and_format).await?; - let get = self.store.remote().fetch(connection, hash_and_format); - let mut stream = get.stream(); - loop { - let Some(item) = stream.next().await else { - anyhow::bail!("targeted download ended without completion"); - }; - match item { - GetProgressItem::Progress(_) => { - let verified = self - .store - .remote() - .local(hash_and_format) - .await? - .local_bytes() - .saturating_sub(hash_sequence_bytes) - .saturating_sub(collection_metadata_bytes) - .min(payload_size); - if self - .targeted_store() - .advance_verified_bytes(targeted_transfer_id, verified) - .await? - { - self.emit_transfer( - transfer_id, - "receive", - "download", - "progress", - json!({ "downloaded": verified, "total_size": payload_size }), - ); - self.emit_targeted_lifecycle(targeted_transfer_id, "progress"); - } - } - GetProgressItem::Done(_) => { - let verified = self - .store - .remote() - .local(hash_and_format) - .await? - .local_bytes() - .saturating_sub(hash_sequence_bytes) - .saturating_sub(collection_metadata_bytes) - .min(payload_size); - if verified != payload_size { - anyhow::bail!( - "targeted payload completed with {verified} verified bytes, expected {payload_size}" - ); - } - if self - .targeted_store() - .advance_verified_bytes(targeted_transfer_id, verified) - .await? - { - self.emit_transfer( - transfer_id, - "receive", - "download", - "progress", - json!({ "downloaded": verified, "total_size": payload_size }), - ); - self.emit_targeted_lifecycle(targeted_transfer_id, "progress"); - } - break; - } - GetProgressItem::Error(error) => { - return Err(VnidropError::network(anyhow::anyhow!( - "targeted download failed: {error}" - )) - .into()); - } - } - } - let collection = Collection::load(hash_and_format.hash, self.store.as_ref()).await?; - self.export_collection_untracked(transfer_id, total_files, target, collection) - .await?; - drop(download_tag); - Ok(()) - } - + #[allow( + clippy::too_many_arguments, + reason = "targeted receive binds immutable authorization fields plus its cancel token" + )] pub(super) async fn receive_to_target( self: &Arc, ticket: String, @@ -783,7 +597,7 @@ impl CoreInner { Ok(()) } - async fn export_collection_untracked( + pub(super) async fn export_collection_untracked( &self, transfer_id: u64, total_files: u64, diff --git a/crates/vnidrop/src/runtime/targeted.rs b/crates/vnidrop/src/runtime/targeted.rs index 1b28bf7..759f5ca 100644 --- a/crates/vnidrop/src/runtime/targeted.rs +++ b/crates/vnidrop/src/runtime/targeted.rs @@ -2,29 +2,21 @@ use std::sync::Arc; -use anyhow::{Context, Result}; -use iroh_blobs::{ticket::BlobTicket, BlobFormat}; -use uuid::Uuid; +use anyhow::Result; +use iroh_blobs::ticket::BlobTicket; -use super::{receive::ReceiveTarget, targeted_tag_name, CoreInner}; +use super::{targeted_tag_name, CoreInner}; use crate::{ api::{ - saved_device_capabilities, PendingTargetedOffer, ShareSource, TargetedOfferResponse, - TargetedTransfer, TargetedTransferState, TransferAccessMode, + PendingTargetedOffer, TargetedOfferResponse, TargetedTransfer, TargetedTransferState, + TransferAccessMode, }, error::VnidropError, - secure_secret::{SecretHandle, SecretKind}, + secure_secret::SecretHandle, targeted_transfer::{ - auth_secret_material, - protocol::{ - map_offer_refuse_reason, CancelTargetedOffer, CompleteTargetedTransfer, - CompletionResponse, DeliverTargetedAuthorization, SubmitTargetedOffer, - TargetedTransferProtocol, WireOfferResponse, - }, - reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft, - TargetedTransferRole, TargetedTransferRow, + protocol::{CancelTargetedOffer, TargetedTransferProtocol}, + TargetedTransferRole, }, - util::{non_empty, now_ms}, }; impl CoreInner { @@ -36,79 +28,14 @@ impl CoreInner { ); } - fn connection_timeout(&self) -> std::time::Duration { + pub(super) fn connection_timeout(&self) -> std::time::Duration { std::time::Duration::from_millis(self.limits.connection_timeout_ms) } - fn offer_wait_timeout(&self) -> std::time::Duration { + pub(super) fn offer_wait_timeout(&self) -> std::time::Duration { std::time::Duration::from_millis(self.limits.offer_timeout_ms) } - pub(super) async fn spawn_targeted_completion_task(self: &Arc) { - let core = self.clone(); - let task = tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(2)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - interval.tick().await; - core.release_completed_targeted_payloads().await; - core.retry_targeted_completions().await; - } - }); - *self.targeted_completion_task.lock().await = Some(task); - } - - async fn retry_targeted_completions(&self) { - let Ok(rows) = self.targeted_store().list_pending_completions().await else { - return; - }; - for row in rows.into_iter().take(1) { - let Ok(Some(encoded)) = self.load_stored_authorization(&row).await else { - let _ = self - .targeted_store() - .defer_pending_completion(&row.id, now_ms() + 30_000) - .await; - continue; - }; - let Ok(auth) = TargetedAuthorization::decode(&encoded) else { - let _ = self - .targeted_store() - .defer_pending_completion(&row.id, now_ms() + 30_000) - .await; - continue; - }; - if self.acknowledge_targeted_completion(&auth).await.is_ok() { - let _ = self - .targeted_store() - .clear_pending_completion(&row.id) - .await; - } else { - let _ = self - .targeted_store() - .defer_pending_completion(&row.id, now_ms() + 5_000) - .await; - } - } - } - - async fn release_completed_targeted_payloads(&self) { - let Ok(rows) = self.targeted_store().list_completed_sender_rows().await else { - return; - }; - for row in rows { - if self - .try_teardown_targeted_payload(row.protocol_transfer_id, Some(&row.id)) - .await - .is_ok() - { - let _ = self - .targeted_store() - .clear_pending_payload_release(&row.id) - .await; - } - } - } - pub(super) fn targeted_store(&self) -> crate::targeted_transfer::TargetedTransferStore { self.targeted_transfers.clone() } @@ -132,21 +59,134 @@ impl CoreInner { pub(crate) async fn restore_targeted_transfer_access(&self) -> Result<(), VnidropError> { let mut active_tags = std::collections::HashSet::new(); + for (id, sender) in self.targeted_store().list_accepted_intent_senders().await? { + if self + .device_relationships + .require_saved(&sender) + .await + .is_err() + { + self.targeted_store() + .clear_accepted_intent_if_sender(&id, &sender) + .await?; + } + } + let mut revoked_peers = std::collections::HashSet::new(); + for row in self.targeted_store().list_resumable_rows().await? { + let peer = match row.role { + TargetedTransferRole::Sender => &row.receiver_endpoint_id, + TargetedTransferRole::Receiver => &row.sender_endpoint_id, + }; + let blocked = self + .blocked_devices + .is_blocked(peer) + .await + .map_err(VnidropError::repository)?; + if (blocked || self.device_relationships.require_saved(peer).await.is_err()) + && revoked_peers.insert(peer.clone()) + { + self.cancel_targeted_transfers_for_peer(peer).await?; + } + } + for row in self.targeted_store().authorization_rows().await? { + let blocked = self + .blocked_devices + .is_blocked(match row.role { + TargetedTransferRole::Sender => &row.receiver_endpoint_id, + TargetedTransferRole::Receiver => &row.sender_endpoint_id, + }) + .await + .map_err(VnidropError::repository)?; + let terminal = matches!( + row.state, + TargetedTransferState::Cancelled + | TargetedTransferState::Deleted + | TargetedTransferState::Failed + ); + let revoked_receiver = row.role == TargetedTransferRole::Receiver + && (blocked + || self + .device_relationships + .require_saved(&row.sender_endpoint_id) + .await + .is_err()); + if terminal || revoked_receiver { + if let Some(handle) = row.authorization_secret_handle { + if let Some(custody) = &self.secret_custody { + custody.remove(&SecretHandle::from_stored(handle)).await?; + } + self.targeted_store().clear_authorization(&row.id).await?; + } + } + } for row in self.targeted_store().list_resumable_sender_rows().await? { - let root_hash = row - .content_hash - .parse::() - .map_err(|error| VnidropError::transfer(anyhow::anyhow!(error)))?; - let collection = - iroh_blobs::format::collection::Collection::load(root_hash, self.store.as_ref()) - .await - .map_err(VnidropError::transfer)?; + let blocked = self + .blocked_devices + .is_blocked(&row.receiver_endpoint_id) + .await + .map_err(VnidropError::repository)?; + let saved = self + .device_relationships + .require_saved(&row.receiver_endpoint_id) + .await + .is_ok(); + if blocked || !saved { + if self + .targeted_store() + .transition_terminal(&row.id, TargetedTransferState::Cancelled, false) + .await? + { + self.emit_targeted_lifecycle(&row.id, "cancelled"); + } + self.teardown_targeted_payload(row.protocol_transfer_id, Some(&row.id)) + .await; + continue; + } let tag_name = targeted_tag_name(&row.id); - self.store - .tags() - .set(&tag_name, (root_hash, iroh_blobs::BlobFormat::HashSeq)) + let restored = async { + if row.content_hash.len() != iroh_blobs::Hash::new([0; 32]).to_string().len() { + return Err(VnidropError::transfer(anyhow::anyhow!( + "invalid targeted content hash" + ))); + } + let root_hash = row + .content_hash + .parse::() + .map_err(|error| VnidropError::transfer(anyhow::anyhow!(error)))?; + let collection = iroh_blobs::format::collection::Collection::load( + root_hash, + self.store.as_ref(), + ) .await .map_err(VnidropError::transfer)?; + self.store + .tags() + .set(&tag_name, (root_hash, iroh_blobs::BlobFormat::HashSeq)) + .await + .map_err(VnidropError::transfer)?; + Ok::<_, VnidropError>((root_hash, collection)) + } + .await; + let (root_hash, collection) = match restored { + Ok(restored) => restored, + Err(error) => { + tracing::warn!(transfer_id = %row.id, %error, "failed to restore targeted payload; marking transfer failed"); + match self + .targeted_store() + .fail_resumable_and_clear_delivery(&row.id) + .await + { + Ok(true) => self.emit_targeted_lifecycle(&row.id, "failed"), + Ok(false) => {} + Err(fail_error) => { + tracing::warn!(transfer_id = %row.id, %fail_error, "failed to mark unrestorable targeted payload failed") + } + } + self.teardown_targeted_payload(row.protocol_transfer_id, Some(&row.id)) + .await; + continue; + } + }; self.register_share_hashes( row.protocol_transfer_id, std::iter::once(root_hash).chain(collection.iter().map(|(_, hash)| *hash)), @@ -205,6 +245,10 @@ impl CoreInner { .targeted_store() .sender_payloads_for_peer(peer_endpoint_id) .await?; + let authorizations = self + .targeted_store() + .authorizations_for_peer(peer_endpoint_id) + .await?; let active_ids = self.targeted_store().ids_for_peer(peer_endpoint_id).await?; for id in active_ids { let _ = self.signal_targeted_transfer_cancel_by_id(&id); @@ -224,6 +268,11 @@ impl CoreInner { self.teardown_targeted_payload(protocol_transfer_id, Some(&id)) .await; } + for (_id, handle) in authorizations { + if let Some(custody) = &self.secret_custody { + custody.remove(&SecretHandle::from_stored(handle)).await?; + } + } Ok(cancelled.len() as u64) } @@ -240,11 +289,12 @@ impl CoreInner { let Some(row) = store.get_row(&id).await? else { // Still drop any live-session offer under this id. self.targeted_offers.discard(&id).await; + store.clear_accepted_offer_intent(&id).await?; return Ok(()); }; self.targeted_offers.discard(&id).await; let changed = store - .transition_terminal(&id, TargetedTransferState::Cancelled, false) + .transition_terminal(&id, TargetedTransferState::Cancelled, true) .await?; if changed { self.emit_targeted_lifecycle(&id, "cancelled"); @@ -254,14 +304,11 @@ impl CoreInner { .await; self.teardown_targeted_payload(row.protocol_transfer_id, Some(&row.id)) .await; - if row.role == TargetedTransferRole::Receiver { - if let Some(handle) = &row.authorization_secret_handle { - if let Some(custody) = &self.secret_custody { - custody - .remove(&SecretHandle::from_stored(handle.clone())) - .await?; - } - store.clear_authorization(&id).await?; + if let Some(handle) = &row.authorization_secret_handle { + if let Some(custody) = &self.secret_custody { + custody + .remove(&SecretHandle::from_stored(handle.clone())) + .await?; } } // Best-effort idempotent peer teardown for both pre- and post-approval work. @@ -292,6 +339,7 @@ impl CoreInner { let store = self.targeted_store(); let Some(row) = store.get_row(&id).await? else { self.targeted_offers.discard(&id).await; + store.clear_accepted_offer_intent(&id).await?; return Ok(()); }; let already_deleted = row.state == TargetedTransferState::Deleted; @@ -309,7 +357,7 @@ impl CoreInner { false } else { store - .transition_terminal(&id, TargetedTransferState::Deleted, false) + .transition_terminal(&id, TargetedTransferState::Deleted, true) .await? }; if changed { @@ -326,10 +374,7 @@ impl CoreInner { custody .remove(&SecretHandle::from_stored(handle.clone())) .await?; - store.clear_authorization(&id).await?; } - } else { - store.clear_authorization(&id).await?; } if !already_deleted { if let Ok(addr) = self.device_relationships.peer_addr(peer_id).await { @@ -374,25 +419,38 @@ impl CoreInner { } } + if accepted { + let offer = self + .targeted_offers + .pending_for_acceptance(&transfer_id) + .await + .ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")) + })?; + self.targeted_store() + .persist_accepted_offer_intent(&offer) + .await?; + } + match self.targeted_offers.respond(&transfer_id, accepted).await { - Ok(Some(auth)) => match self.persist_receiver_authorization(&auth).await { - Ok(()) => { - self.emit_targeted_lifecycle(&transfer_id, "approved"); + Ok(Some(_auth)) => match self.targeted_store().get_row(&transfer_id).await? { + Some(row) + if row.role == TargetedTransferRole::Receiver + && row.state == TargetedTransferState::Approved + && row.authorization_secret_handle.is_some() => + { Ok(TargetedOfferResponse::Approved { transfer_id }) } - Err(error) => { - if self - .targeted_store() - .set_state_from_any(&transfer_id, TargetedTransferState::Failed) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_id, "failed"); - } - Err(error) - } + _ => Err(VnidropError::internal(anyhow::anyhow!( + "receiver acknowledged authorization without durable custody" + ))), }, - Ok(None) => Ok(TargetedOfferResponse::Declined), + Ok(None) => { + self.targeted_store() + .clear_accepted_offer_intent(&transfer_id) + .await?; + Ok(TargetedOfferResponse::Declined) + } Err(crate::targeted_transfer::RespondError::Unknown) => Err( VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")), ), @@ -409,444 +467,11 @@ impl CoreInner { } } - pub(super) async fn create_targeted_transfer( - self: &Arc, - receiver_endpoint_id: String, - sources: Vec, - transfer_name: Option, - ) -> Result { - self.device_relationships - .require_saved(&receiver_endpoint_id) - .await?; - - let (transfer_uuid, protocol_transfer_id) = loop { - let transfer_uuid = Uuid::new_v4().to_string(); - let protocol_transfer_id = allocate_protocol_transfer_id(&transfer_uuid); - let invitation_collision = self - .repository - .list_transfers() - .await - .map_err(VnidropError::repository)? - .into_iter() - .any(|transfer| transfer.transfer_id == protocol_transfer_id); - if !invitation_collision - && !self - .targeted_store() - .contains_protocol_id(protocol_transfer_id) - .await? - { - break (transfer_uuid, protocol_transfer_id); - } - }; - let sender_endpoint_id = self.endpoint.id().to_string(); - let now = now_ms(); - - if sources.is_empty() { - return Err(VnidropError::invalid_input(anyhow::anyhow!( - "at least one source is required" - ))); - } - if sources.len() as u64 > self.limits.max_sources { - return Err(VnidropError::invalid_input(anyhow::anyhow!( - "source count {} exceeds limit {}", - sources.len(), - self.limits.max_sources - ))); - } - self.limits - .validate_metadata_text("transfer name", transfer_name.as_deref()) - .map_err(VnidropError::invalid_input)?; - let import = self - .import_sources(protocol_transfer_id, sources) - .await - .map_err(VnidropError::transfer)?; - let payload_name = transfer_name - .and_then(non_empty) - .unwrap_or_else(|| import.default_name.clone()); - let blob_ticket = - BlobTicket::new(self.endpoint.addr(), import.root_hash, BlobFormat::HashSeq); - self.store - .tags() - .set( - targeted_tag_name(&transfer_uuid), - (import.root_hash, BlobFormat::HashSeq), - ) - .await - .map_err(VnidropError::transfer)?; - self.register_share_hashes( - protocol_transfer_id, - std::iter::once(import.root_hash).chain(import.member_hashes.iter().copied()), - ) - .await; - self.access_policy - .set_mode(protocol_transfer_id, TransferAccessMode::ApprovalRequired) - .await; - drop(import.tag); - - let store = self.targeted_store(); - let row = TargetedTransferRow { - id: transfer_uuid.clone(), - protocol_transfer_id, - sender_endpoint_id: sender_endpoint_id.clone(), - receiver_endpoint_id: receiver_endpoint_id.clone(), - manifest_id: blob_ticket.hash().to_string(), - content_hash: blob_ticket.hash().to_string(), - transfer_name: payload_name.clone(), - file_count: import.file_count, - total_size: import.total_size, - verified_bytes: 0, - blob_ticket: None, - authorization_secret_handle: None, - role: TargetedTransferRole::Sender, - state: TargetedTransferState::Preparing, - created_at: now, - updated_at: now, - }; - if let Err(error) = store.insert(&row).await { - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - self.emit_targeted_lifecycle(&transfer_uuid, "created"); - if let Err(error) = store - .set_state( - &transfer_uuid, - TargetedTransferState::Preparing, - TargetedTransferState::Offering, - ) - .await - { - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - self.emit_targeted_lifecycle(&transfer_uuid, "offering"); - - let addr = match self - .device_relationships - .peer_addr(&receiver_endpoint_id) - .await - { - Ok(addr) => addr, - Err(error) => { - if store - .set_state( - &transfer_uuid, - TargetedTransferState::Offering, - TargetedTransferState::Failed, - ) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - }; - let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr); - let challenge = - match tokio::time::timeout(self.connection_timeout(), client.request_challenge()).await - { - Ok(Ok(challenge)) => challenge, - Ok(Err(error)) => { - if store - .set_state( - &transfer_uuid, - TargetedTransferState::Offering, - TargetedTransferState::Failed, - ) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(map_connect_failure(error)); - } - Err(_) => { - if store - .set_state( - &transfer_uuid, - TargetedTransferState::Offering, - TargetedTransferState::Failed, - ) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(VnidropError::device_unavailable(anyhow::anyhow!( - "device did not answer in time" - ))); - } - }; - - let (proof, generation, relationship_protocol_version) = match self - .device_relationships - .prove_saved_possession(&receiver_endpoint_id, &challenge) - .await - { - Ok(proof) => proof, - Err(error) => { - if store - .set_state( - &transfer_uuid, - TargetedTransferState::Offering, - TargetedTransferState::Failed, - ) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - }; - - let protocol_version = saved_device_capabilities().targeted_transfer_protocol_version; - if let Err(error) = store - .set_state( - &transfer_uuid, - TargetedTransferState::Offering, - TargetedTransferState::AwaitingApproval, - ) - .await - { - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - self.emit_targeted_lifecycle(&transfer_uuid, "awaiting-approval"); - - let response = match tokio::time::timeout( - self.connection_timeout() + self.offer_wait_timeout(), - client.submit_offer(SubmitTargetedOffer { - proof, - generation, - relationship_protocol_version, - protocol_version, - transfer_id: transfer_uuid.clone(), - sender_endpoint_id: sender_endpoint_id.clone(), - receiver_endpoint_id: receiver_endpoint_id.clone(), - manifest_id: blob_ticket.hash().to_string(), - content_hash: blob_ticket.hash().to_string(), - transfer_name: payload_name.clone(), - file_count: import.file_count, - total_size: import.total_size, - relay_mode: self.relay_mode, - relay_urls: self - .custom_relay_urls - .iter() - .map(ToString::to_string) - .collect(), - }), - ) - .await - { - Ok(Ok(response)) => response, - Ok(Err(error)) => { - if store - .set_state( - &transfer_uuid, - TargetedTransferState::AwaitingApproval, - TargetedTransferState::Failed, - ) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(map_connect_failure(error)); - } - Err(_) => { - if store - .set_state( - &transfer_uuid, - TargetedTransferState::AwaitingApproval, - TargetedTransferState::Failed, - ) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(VnidropError::offer_timeout(anyhow::anyhow!( - "offer timed out" - ))); - } - }; - - match response { - WireOfferResponse::Accepted => {} - WireOfferResponse::Declined { reason } => { - if store - .set_state( - &transfer_uuid, - TargetedTransferState::AwaitingApproval, - TargetedTransferState::Declined, - ) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "offer-declined"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(VnidropError::permission(anyhow::anyhow!( - "targeted offer declined: {reason}" - ))); - } - WireOfferResponse::Refused { reason } => { - if store - .set_state( - &transfer_uuid, - TargetedTransferState::AwaitingApproval, - TargetedTransferState::Failed, - ) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(map_offer_refuse_reason(&reason)); - } - } - - // Permanent until cancel/delete — approved targeted transfers must resume. - self.access_policy - .approve_endpoint_until(protocol_transfer_id, receiver_endpoint_id.clone(), None) - .await; - - let authorization = match TargetedAuthorization::issue(TargetedAuthorizationDraft { - transfer_id: transfer_uuid.clone(), - protocol_transfer_id, - sender_endpoint_id, - receiver_endpoint_id, - manifest_id: blob_ticket.hash().to_string(), - content_hash: blob_ticket.hash().to_string(), - file_count: import.file_count, - total_size: import.total_size, - protocol_version, - transfer_name: payload_name, - blob_ticket: blob_ticket.to_string(), - }) { - Ok(authorization) => authorization, - Err(error) => { - if store - .set_state_from_any(&transfer_uuid, TargetedTransferState::Failed) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - }; - if let Err(error) = self - .persist_authorization_secret(&transfer_uuid, &authorization) - .await - { - if store - .set_state_from_any(&transfer_uuid, TargetedTransferState::Failed) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - let encoded = match authorization.encode() { - Ok(encoded) => encoded, - Err(error) => { - if store - .set_state_from_any(&transfer_uuid, TargetedTransferState::Failed) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - }; - - if let Err(error) = store - .set_state( - &transfer_uuid, - TargetedTransferState::AwaitingApproval, - TargetedTransferState::Approved, - ) - .await - { - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - self.emit_targeted_lifecycle(&transfer_uuid, "approved"); - - let deliver = match client - .deliver_authorization(DeliverTargetedAuthorization { - transfer_id: transfer_uuid.clone(), - authorization: encoded, - }) - .await - .context("failed to deliver targeted authorization") - .map_err(VnidropError::network) - { - Ok(deliver) => deliver, - Err(error) => { - if store - .set_state_from_any(&transfer_uuid, TargetedTransferState::Failed) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(error); - } - }; - if deliver != crate::targeted_transfer::protocol::DeliverAuthorizationResponse::Stored { - if store - .set_state_from_any(&transfer_uuid, TargetedTransferState::Failed) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&transfer_uuid, "failed"); - } - self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) - .await; - return Err(VnidropError::network(anyhow::anyhow!( - "receiver rejected authorization delivery" - ))); - } - - store - .get(&transfer_uuid) - .await? - .ok_or_else(|| VnidropError::internal(anyhow::anyhow!("targeted transfer missing"))) - } - - async fn teardown_targeted_payload(&self, protocol_transfer_id: u64, id: Option<&str>) { + pub(super) async fn teardown_targeted_payload( + &self, + protocol_transfer_id: u64, + id: Option<&str>, + ) { if let Err(error) = self .try_teardown_targeted_payload(protocol_transfer_id, id) .await @@ -855,7 +480,7 @@ impl CoreInner { } } - async fn try_teardown_targeted_payload( + pub(super) async fn try_teardown_targeted_payload( &self, protocol_transfer_id: u64, id: Option<&str>, @@ -873,357 +498,9 @@ impl CoreInner { } Ok(()) } - - pub(super) async fn receive_targeted_transfer( - self: &Arc, - transfer_id: String, - output_dir: String, - ) -> Result<(), VnidropError> { - let output_dir = - crate::filesystem::platform_path(&output_dir).map_err(VnidropError::filesystem)?; - self.receive_targeted_to_target(transfer_id, ReceiveTarget::Directory(output_dir)) - .await - } - - pub(super) async fn receive_targeted_transfer_with_output_sink( - self: &Arc, - transfer_id: String, - output_sink: Arc, - ) -> Result<(), VnidropError> { - self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSink(output_sink)) - .await - } - - pub(super) async fn receive_targeted_transfer_with_output_sink_v2( - self: &Arc, - transfer_id: String, - output_sink: Arc, - ) -> Result<(), VnidropError> { - self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSinkV2(output_sink)) - .await - } - - pub(super) async fn resume_targeted_transfer( - self: &Arc, - id: String, - output_dir: String, - ) -> Result<(), VnidropError> { - let output_dir = - crate::filesystem::platform_path(&output_dir).map_err(VnidropError::filesystem)?; - self.resume_targeted_to_target(id, ReceiveTarget::Directory(output_dir)) - .await - } - - pub(super) async fn resume_targeted_transfer_with_output_sink( - self: &Arc, - id: String, - output_sink: Arc, - ) -> Result<(), VnidropError> { - self.resume_targeted_to_target(id, ReceiveTarget::OutputSink(output_sink)) - .await - } - - pub(super) async fn resume_targeted_transfer_with_output_sink_v2( - self: &Arc, - id: String, - output_sink: Arc, - ) -> Result<(), VnidropError> { - self.resume_targeted_to_target(id, ReceiveTarget::OutputSinkV2(output_sink)) - .await - } - - async fn receive_targeted_to_target( - self: &Arc, - transfer_id: String, - target: ReceiveTarget, - ) -> Result<(), VnidropError> { - let auth = self.load_receiver_authorization(&transfer_id).await?; - self.run_targeted_receive(&auth, target).await - } - - async fn resume_targeted_to_target( - self: &Arc, - id: String, - target: ReceiveTarget, - ) -> Result<(), VnidropError> { - let store = self.targeted_store(); - let row = store.get_row(&id).await?.ok_or_else(|| { - VnidropError::invalid_input(anyhow::anyhow!("unknown targeted transfer")) - })?; - if !matches!( - row.state, - TargetedTransferState::Approved - | TargetedTransferState::Connecting - | TargetedTransferState::Transferring - | TargetedTransferState::Interrupted - ) { - return Err(VnidropError::InvalidTransition { - reason: format!( - "cannot resume from {}", - crate::targeted_transfer::state_as_str(row.state) - ), - }); - } - let auth = self.load_receiver_authorization(&id).await?; - self.run_targeted_receive(&auth, target).await - } - - async fn load_receiver_authorization( - &self, - transfer_id: &str, - ) -> Result { - let store = self.targeted_store(); - let row = store.get_row(transfer_id).await?.ok_or_else(|| { - VnidropError::invalid_input(anyhow::anyhow!("unknown targeted transfer")) - })?; - let encoded = self.load_stored_authorization(&row).await?.ok_or_else(|| { - VnidropError::invalid_input(anyhow::anyhow!( - "targeted transfer has no durable authorization" - )) - })?; - let auth = TargetedAuthorization::decode(&encoded)?; - auth.verify_for_receiver(&self.endpoint.id().to_string())?; - Ok(auth) - } - - async fn run_targeted_receive( - self: &Arc, - auth: &TargetedAuthorization, - target: ReceiveTarget, - ) -> Result<(), VnidropError> { - let store = self.targeted_store(); - if let Ok(Some(row)) = store.get_row(&auth.transfer_id).await { - match row.state { - TargetedTransferState::Approved | TargetedTransferState::Interrupted => { - store - .set_state( - &auth.transfer_id, - row.state, - TargetedTransferState::Connecting, - ) - .await?; - self.emit_targeted_lifecycle(&auth.transfer_id, "connecting"); - store - .set_state( - &auth.transfer_id, - TargetedTransferState::Connecting, - TargetedTransferState::Transferring, - ) - .await?; - self.emit_targeted_lifecycle(&auth.transfer_id, "transferring"); - } - TargetedTransferState::Connecting | TargetedTransferState::Transferring => { - return Err(VnidropError::InvalidTransition { - reason: "targeted receive is already active".to_string(), - }); - } - other => { - return Err(VnidropError::InvalidTransition { - reason: format!( - "cannot receive from {}", - crate::targeted_transfer::state_as_str(other) - ), - }); - } - } - } - - let blob_ticket = BlobTicket::from_str_compat(&auth.blob_ticket) - .map_err(|error| VnidropError::ticket(anyhow::anyhow!(error)))?; - let receive_result = self - .receive_targeted_payload( - &auth.transfer_id, - auth.protocol_transfer_id, - auth.file_count, - auth.total_size, - blob_ticket, - target, - ) - .await; - - match receive_result { - Ok(()) => { - let row = store.get_row(&auth.transfer_id).await?.ok_or_else(|| { - VnidropError::internal(anyhow::anyhow!("targeted transfer missing")) - })?; - store - .complete_receiver_and_enqueue(&auth.transfer_id, row.total_size) - .await?; - self.emit_targeted_lifecycle(&auth.transfer_id, "completed"); - if self.acknowledge_targeted_completion(auth).await.is_ok() { - store.clear_pending_completion(&auth.transfer_id).await?; - } - Ok(()) - } - Err(error) => { - if let Ok(Some(row)) = store.get_row(&auth.transfer_id).await { - if matches!( - row.state, - TargetedTransferState::Connecting | TargetedTransferState::Transferring - ) && store - .set_state_from_any(&auth.transfer_id, TargetedTransferState::Interrupted) - .await - .is_ok() - { - self.emit_targeted_lifecycle(&auth.transfer_id, "interrupted"); - } - } - Err(VnidropError::transfer(error)) - } - } - } - - async fn acknowledge_targeted_completion( - &self, - auth: &TargetedAuthorization, - ) -> Result<(), VnidropError> { - #[cfg(test)] - if self - .suppress_targeted_completion - .load(std::sync::atomic::Ordering::SeqCst) - { - return Err(VnidropError::device_unavailable(anyhow::anyhow!( - "completion delivery suppressed by test" - ))); - } - let addr = self - .device_relationships - .peer_addr(&auth.sender_endpoint_id) - .await?; - let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr); - let response = tokio::time::timeout( - self.connection_timeout(), - client.complete_transfer(CompleteTargetedTransfer { - transfer_id: auth.transfer_id.clone(), - verified_bytes: auth.total_size, - authorization: auth.encode()?, - }), - ) - .await - .map_err(|_| VnidropError::device_unavailable(anyhow::anyhow!("completion timed out")))? - .map_err(|error| VnidropError::network(anyhow::anyhow!(error)))?; - if response != CompletionResponse::Recorded { - return Err(VnidropError::permission(anyhow::anyhow!( - "sender rejected targeted completion" - ))); - } - Ok(()) - } - - async fn persist_authorization_secret( - &self, - transfer_id: &str, - authorization: &TargetedAuthorization, - ) -> Result<(), VnidropError> { - let custody = - self.secret_custody - .as_ref() - .ok_or_else(|| VnidropError::SecureStorageUnavailable { - reason: "targeted authorization requires protected custody".to_string(), - })?; - let material = auth_secret_material(authorization)?; - let handle = custody - .protect(SecretKind::TargetedAuthorization, material, None) - .await?; - if let Err(error) = self - .targeted_store() - .store_authorization(transfer_id, &authorization.blob_ticket, handle.as_str()) - .await - { - if let Err(cleanup_error) = custody.remove(&handle).await { - tracing::warn!(%cleanup_error, "failed to roll back targeted authorization secret"); - } - return Err(error); - } - Ok(()) - } - - async fn persist_receiver_authorization(&self, encoded: &str) -> Result<(), VnidropError> { - let auth = TargetedAuthorization::decode(encoded)?; - let store = self.targeted_store(); - if store.get_row(&auth.transfer_id).await?.is_none() { - let invitation_collision = self - .repository - .list_transfers() - .await - .map_err(VnidropError::repository)? - .into_iter() - .any(|transfer| transfer.transfer_id == auth.protocol_transfer_id); - if invitation_collision - || store - .contains_protocol_id(auth.protocol_transfer_id) - .await? - { - return Err(VnidropError::invalid_input(anyhow::anyhow!( - "targeted transfer protocol id collides with local work" - ))); - } - let now = now_ms(); - store - .insert(&TargetedTransferRow { - id: auth.transfer_id.clone(), - protocol_transfer_id: auth.protocol_transfer_id, - sender_endpoint_id: auth.sender_endpoint_id.clone(), - receiver_endpoint_id: auth.receiver_endpoint_id.clone(), - manifest_id: auth.manifest_id.clone(), - content_hash: auth.content_hash.clone(), - transfer_name: auth.transfer_name.clone(), - file_count: auth.file_count, - total_size: auth.total_size, - verified_bytes: 0, - blob_ticket: Some(auth.blob_ticket.clone()), - authorization_secret_handle: None, - role: TargetedTransferRole::Receiver, - state: TargetedTransferState::Approved, - created_at: now, - updated_at: now, - }) - .await?; - } - self.persist_authorization_secret(&auth.transfer_id, &auth) - .await - } - - pub(crate) async fn load_stored_authorization( - &self, - row: &TargetedTransferRow, - ) -> Result, VnidropError> { - let (Some(handle), Some(blob_ticket)) = ( - row.authorization_secret_handle.as_ref(), - row.blob_ticket.as_ref(), - ) else { - return Ok(None); - }; - let custody = - self.secret_custody - .as_ref() - .ok_or_else(|| VnidropError::SecureStorageUnavailable { - reason: "targeted authorization requires protected custody".to_string(), - })?; - let material = custody - .load(&SecretHandle::from_stored(handle.clone())) - .await?; - let auth = reconstruct_authorization( - TargetedAuthorizationDraft { - transfer_id: row.id.clone(), - protocol_transfer_id: row.protocol_transfer_id, - sender_endpoint_id: row.sender_endpoint_id.clone(), - receiver_endpoint_id: row.receiver_endpoint_id.clone(), - manifest_id: row.manifest_id.clone(), - content_hash: row.content_hash.clone(), - file_count: row.file_count, - total_size: row.total_size, - protocol_version: saved_device_capabilities().targeted_transfer_protocol_version, - transfer_name: row.transfer_name.clone(), - blob_ticket: blob_ticket.clone(), - }, - &material, - )?; - Ok(Some(auth.encode()?)) - } } -fn allocate_protocol_transfer_id(transfer_uuid: &str) -> u64 { +pub(super) fn allocate_protocol_transfer_id(transfer_uuid: &str) -> u64 { let hash = blake3::hash(transfer_uuid.as_bytes()); let mut bytes = [0u8; 8]; bytes.copy_from_slice(&hash.as_bytes()[..8]); @@ -1236,7 +513,7 @@ fn allocate_protocol_transfer_id(transfer_uuid: &str) -> u64 { } } -fn map_connect_failure(error: irpc::Error) -> VnidropError { +pub(super) fn map_connect_failure(error: irpc::Error) -> VnidropError { let rendered = error.to_string(); // ALPN / protocol negotiation failures are distinguishable from offline peers. if rendered.contains("ALPN") @@ -1251,7 +528,7 @@ fn map_connect_failure(error: irpc::Error) -> VnidropError { VnidropError::device_unavailable(anyhow::anyhow!("device is not reachable: {rendered}")) } -trait BlobTicketParse { +pub(super) trait BlobTicketParse { fn from_str_compat(value: &str) -> Result; } diff --git a/crates/vnidrop/src/runtime/targeted_create.rs b/crates/vnidrop/src/runtime/targeted_create.rs new file mode 100644 index 0000000..e4b4081 --- /dev/null +++ b/crates/vnidrop/src/runtime/targeted_create.rs @@ -0,0 +1,400 @@ +//! Sender-side targeted-transfer creation and offer approval. + +use std::sync::Arc; + +use iroh_blobs::{ticket::BlobTicket, BlobFormat}; +use uuid::Uuid; + +use super::{ + targeted::{allocate_protocol_transfer_id, map_connect_failure}, + targeted_tag_name, CoreInner, +}; +use crate::{ + api::{ + saved_device_capabilities, ShareSource, TargetedTransfer, TargetedTransferState, + TransferAccessMode, + }, + error::VnidropError, + targeted_transfer::{ + protocol::{ + map_offer_refuse_reason, SubmitTargetedOffer, TargetedTransferProtocol, + WireOfferResponse, + }, + TargetedAuthorization, TargetedAuthorizationDraft, TargetedTransferRole, + TargetedTransferRow, + }, + util::{non_empty, now_ms}, +}; + +impl CoreInner { + pub(super) async fn create_targeted_transfer( + self: &Arc, + receiver_endpoint_id: String, + sources: Vec, + transfer_name: Option, + ) -> Result { + self.device_relationships + .require_saved(&receiver_endpoint_id) + .await?; + + let (transfer_uuid, protocol_transfer_id) = loop { + let transfer_uuid = Uuid::new_v4().to_string(); + let protocol_transfer_id = allocate_protocol_transfer_id(&transfer_uuid); + let invitation_collision = self + .repository + .list_transfers() + .await + .map_err(VnidropError::repository)? + .into_iter() + .any(|transfer| transfer.transfer_id == protocol_transfer_id); + if !invitation_collision + && !self + .targeted_store() + .contains_protocol_id(protocol_transfer_id) + .await? + { + break (transfer_uuid, protocol_transfer_id); + } + }; + let sender_endpoint_id = self.endpoint.id().to_string(); + let now = now_ms(); + + if sources.is_empty() { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "at least one source is required" + ))); + } + if sources.len() as u64 > self.limits.max_sources { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "source count {} exceeds limit {}", + sources.len(), + self.limits.max_sources + ))); + } + self.limits + .validate_metadata_text("transfer name", transfer_name.as_deref()) + .map_err(VnidropError::invalid_input)?; + let import = self + .import_sources(protocol_transfer_id, sources) + .await + .map_err(VnidropError::transfer)?; + let payload_name = transfer_name + .and_then(non_empty) + .unwrap_or_else(|| import.default_name.clone()); + let blob_ticket = + BlobTicket::new(self.endpoint.addr(), import.root_hash, BlobFormat::HashSeq); + self.store + .tags() + .set( + targeted_tag_name(&transfer_uuid), + (import.root_hash, BlobFormat::HashSeq), + ) + .await + .map_err(VnidropError::transfer)?; + self.register_share_hashes( + protocol_transfer_id, + std::iter::once(import.root_hash).chain(import.member_hashes.iter().copied()), + ) + .await; + self.access_policy + .set_mode(protocol_transfer_id, TransferAccessMode::ApprovalRequired) + .await; + drop(import.tag); + + let store = self.targeted_store(); + let row = TargetedTransferRow { + id: transfer_uuid.clone(), + protocol_transfer_id, + sender_endpoint_id: sender_endpoint_id.clone(), + receiver_endpoint_id: receiver_endpoint_id.clone(), + manifest_id: blob_ticket.hash().to_string(), + content_hash: blob_ticket.hash().to_string(), + transfer_name: payload_name.clone(), + file_count: import.file_count, + total_size: import.total_size, + verified_bytes: 0, + blob_ticket: None, + authorization_secret_handle: None, + role: TargetedTransferRole::Sender, + state: TargetedTransferState::Preparing, + created_at: now, + updated_at: now, + }; + if let Err(error) = store.insert(&row).await { + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(error); + } + self.emit_targeted_lifecycle(&transfer_uuid, "created"); + if let Err(error) = store + .set_state( + &transfer_uuid, + TargetedTransferState::Preparing, + TargetedTransferState::Offering, + ) + .await + { + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(error); + } + self.emit_targeted_lifecycle(&transfer_uuid, "offering"); + + let addr = match self + .device_relationships + .peer_addr(&receiver_endpoint_id) + .await + { + Ok(addr) => addr, + Err(error) => { + if store + .set_state( + &transfer_uuid, + TargetedTransferState::Offering, + TargetedTransferState::Failed, + ) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "failed"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(error); + } + }; + let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr); + let challenge = + match tokio::time::timeout(self.connection_timeout(), client.request_challenge()).await + { + Ok(Ok(challenge)) => challenge, + Ok(Err(error)) => { + if store + .set_state( + &transfer_uuid, + TargetedTransferState::Offering, + TargetedTransferState::Failed, + ) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "failed"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(map_connect_failure(error)); + } + Err(_) => { + if store + .set_state( + &transfer_uuid, + TargetedTransferState::Offering, + TargetedTransferState::Failed, + ) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "failed"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(VnidropError::device_unavailable(anyhow::anyhow!( + "device did not answer in time" + ))); + } + }; + + let (proof, generation, relationship_protocol_version) = match self + .device_relationships + .prove_saved_possession(&receiver_endpoint_id, &challenge) + .await + { + Ok(proof) => proof, + Err(error) => { + if store + .set_state( + &transfer_uuid, + TargetedTransferState::Offering, + TargetedTransferState::Failed, + ) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "failed"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(error); + } + }; + + let protocol_version = saved_device_capabilities().targeted_transfer_protocol_version; + if let Err(error) = store + .set_state( + &transfer_uuid, + TargetedTransferState::Offering, + TargetedTransferState::AwaitingApproval, + ) + .await + { + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(error); + } + self.emit_targeted_lifecycle(&transfer_uuid, "awaiting-approval"); + + let response = match tokio::time::timeout( + self.connection_timeout() + self.offer_wait_timeout(), + client.submit_offer(SubmitTargetedOffer { + proof, + generation, + relationship_protocol_version, + protocol_version, + transfer_id: transfer_uuid.clone(), + sender_endpoint_id: sender_endpoint_id.clone(), + receiver_endpoint_id: receiver_endpoint_id.clone(), + manifest_id: blob_ticket.hash().to_string(), + content_hash: blob_ticket.hash().to_string(), + transfer_name: payload_name.clone(), + file_count: import.file_count, + total_size: import.total_size, + relay_mode: self.relay_mode, + relay_urls: self + .custom_relay_urls + .iter() + .map(ToString::to_string) + .collect(), + }), + ) + .await + { + Ok(Ok(response)) => response, + Ok(Err(error)) => { + if store + .set_state( + &transfer_uuid, + TargetedTransferState::AwaitingApproval, + TargetedTransferState::Failed, + ) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "failed"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(map_connect_failure(error)); + } + Err(_) => { + if store + .set_state( + &transfer_uuid, + TargetedTransferState::AwaitingApproval, + TargetedTransferState::Failed, + ) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "failed"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(VnidropError::offer_timeout(anyhow::anyhow!( + "offer timed out" + ))); + } + }; + + match response { + WireOfferResponse::Accepted => {} + WireOfferResponse::Declined { reason } => { + if store + .set_state( + &transfer_uuid, + TargetedTransferState::AwaitingApproval, + TargetedTransferState::Declined, + ) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "offer-declined"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(VnidropError::permission(anyhow::anyhow!( + "targeted offer declined: {reason}" + ))); + } + WireOfferResponse::Refused { reason } => { + if store + .set_state( + &transfer_uuid, + TargetedTransferState::AwaitingApproval, + TargetedTransferState::Failed, + ) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "failed"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(map_offer_refuse_reason(&reason)); + } + } + + // Permanent until cancel/delete — approved targeted transfers must resume. + self.access_policy + .approve_endpoint_until(protocol_transfer_id, receiver_endpoint_id.clone(), None) + .await; + + let authorization = match TargetedAuthorization::issue(TargetedAuthorizationDraft { + transfer_id: transfer_uuid.clone(), + protocol_transfer_id, + sender_endpoint_id, + receiver_endpoint_id, + manifest_id: blob_ticket.hash().to_string(), + content_hash: blob_ticket.hash().to_string(), + file_count: import.file_count, + total_size: import.total_size, + protocol_version, + transfer_name: payload_name, + blob_ticket: blob_ticket.to_string(), + }) { + Ok(authorization) => authorization, + Err(error) => { + if store + .set_state_from_any(&transfer_uuid, TargetedTransferState::Failed) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "failed"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(error); + } + }; + if let Err(error) = self + .persist_sender_authorization_and_approve(&authorization) + .await + { + if store + .set_state_from_any(&transfer_uuid, TargetedTransferState::Failed) + .await + .is_ok() + { + self.emit_targeted_lifecycle(&transfer_uuid, "failed"); + } + self.teardown_targeted_payload(protocol_transfer_id, Some(&transfer_uuid)) + .await; + return Err(error); + } + self.emit_targeted_lifecycle(&transfer_uuid, "approved"); + + store + .get(&transfer_uuid) + .await? + .ok_or_else(|| VnidropError::internal(anyhow::anyhow!("targeted transfer missing"))) + } +} diff --git a/crates/vnidrop/src/runtime/targeted_payload.rs b/crates/vnidrop/src/runtime/targeted_payload.rs new file mode 100644 index 0000000..e426bf4 --- /dev/null +++ b/crates/vnidrop/src/runtime/targeted_payload.rs @@ -0,0 +1,197 @@ +//! Shared blob download and export path for targeted transfers. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures_lite::StreamExt; +use iroh_blobs::{ + api::remote::GetProgressItem, format::collection::Collection, + get::request::get_hash_seq_and_sizes, ticket::BlobTicket, +}; +use serde_json::json; +use tokio::sync::oneshot; + +use super::{receive::ReceiveTarget, CoreInner}; +use crate::{error::VnidropError, ticket::filter_peer_addr_for_relay_mode}; + +impl CoreInner { + #[allow( + clippy::too_many_arguments, + reason = "targeted receive binds immutable authorization fields plus its cancel token" + )] + pub(super) async fn receive_targeted_payload( + self: &Arc, + targeted_transfer_id: &str, + transfer_id: u64, + expected_file_count: u64, + expected_payload_size: u64, + mut blob_ticket: BlobTicket, + target: ReceiveTarget, + mut cancelled: oneshot::Receiver<()>, + ) -> Result<()> { + let sender_addr = filter_peer_addr_for_relay_mode( + blob_ticket.addr(), + self.relay_mode, + &self.custom_relay_urls, + ) + .map_err(VnidropError::network)?; + blob_ticket = BlobTicket::new(sender_addr, blob_ticket.hash(), blob_ticket.format()); + let _permit = tokio::select! { + biased; + _ = &mut cancelled => return Err(VnidropError::cancelled("transfer cancelled").into()), + permit = self.transfer_slots.acquire() => permit + .context("transfer limiter is closed") + .map_err(VnidropError::internal)?, + }; + tokio::select! { + biased; + _ = &mut cancelled => Err(VnidropError::cancelled("transfer cancelled").into()), + result = self.download_targeted_payload( + targeted_transfer_id, + transfer_id, + expected_file_count, + expected_payload_size, + blob_ticket, + target, + ) => result, + } + } + + async fn download_targeted_payload( + &self, + targeted_transfer_id: &str, + transfer_id: u64, + expected_file_count: u64, + expected_payload_size: u64, + blob_ticket: BlobTicket, + target: ReceiveTarget, + ) -> Result<()> { + if let ReceiveTarget::Directory(output_dir) = &target { + tokio::fs::create_dir_all(output_dir) + .await + .map_err(VnidropError::filesystem)?; + } + let connection = self + .endpoint + .connect(blob_ticket.addr().clone(), iroh_blobs::ALPN) + .await + .map_err(VnidropError::network)?; + let hash_and_format = blob_ticket.hash_and_format(); + let (hash_seq, sizes) = + get_hash_seq_and_sizes(&connection, &hash_and_format.hash, 1024 * 1024 * 32, None) + .await + .context("failed to get targeted payload sizes") + .map_err(VnidropError::network)?; + let remote_size = sizes + .iter() + .try_fold(0u64, |total, size| total.checked_add(*size)) + .context("remote collection size overflow")?; + let total_files = sizes.len().saturating_sub(1) as u64; + if total_files != expected_file_count { + anyhow::bail!( + "targeted payload file count {total_files} does not match authorized count {expected_file_count}" + ); + } + let hash_sequence_bytes = hash_seq.len() as u64 * 32; + let collection_metadata_bytes = sizes.first().copied().unwrap_or(0); + let payload_size = sizes + .iter() + .skip(1) + .try_fold(0u64, |total, size| total.checked_add(*size)) + .context("remote targeted payload size overflow")?; + if payload_size != expected_payload_size { + anyhow::bail!( + "targeted payload size {payload_size} does not match authorized size {expected_payload_size}" + ); + } + if total_files > self.limits.max_collection_files { + anyhow::bail!( + "remote collection has {total_files} files, limit is {}", + self.limits.max_collection_files + ); + } + if remote_size > self.limits.max_total_bytes { + anyhow::bail!( + "remote collection size {remote_size} exceeds limit {}", + self.limits.max_total_bytes + ); + } + let download_tag = self.store.tags().temp_tag(hash_and_format).await?; + let get = self.store.remote().fetch(connection, hash_and_format); + let mut stream = get.stream(); + loop { + let Some(item) = stream.next().await else { + anyhow::bail!("targeted download ended without completion"); + }; + match item { + GetProgressItem::Progress(_) => { + let verified = self + .store + .remote() + .local(hash_and_format) + .await? + .local_bytes() + .saturating_sub(hash_sequence_bytes) + .saturating_sub(collection_metadata_bytes) + .min(payload_size); + if self + .targeted_store() + .advance_verified_bytes(targeted_transfer_id, verified) + .await? + { + self.emit_transfer( + transfer_id, + "receive", + "download", + "progress", + json!({ "downloaded": verified, "total_size": payload_size }), + ); + self.emit_targeted_lifecycle(targeted_transfer_id, "progress"); + } + } + GetProgressItem::Done(_) => { + let verified = self + .store + .remote() + .local(hash_and_format) + .await? + .local_bytes() + .saturating_sub(hash_sequence_bytes) + .saturating_sub(collection_metadata_bytes) + .min(payload_size); + if verified != payload_size { + anyhow::bail!( + "targeted payload completed with {verified} verified bytes, expected {payload_size}" + ); + } + if self + .targeted_store() + .advance_verified_bytes(targeted_transfer_id, verified) + .await? + { + self.emit_transfer( + transfer_id, + "receive", + "download", + "progress", + json!({ "downloaded": verified, "total_size": payload_size }), + ); + self.emit_targeted_lifecycle(targeted_transfer_id, "progress"); + } + break; + } + GetProgressItem::Error(error) => { + return Err(VnidropError::network(anyhow::anyhow!( + "targeted download failed: {error}" + )) + .into()); + } + } + } + let collection = Collection::load(hash_and_format.hash, self.store.as_ref()).await?; + self.export_collection_untracked(transfer_id, total_files, target, collection) + .await?; + drop(download_tag); + Ok(()) + } +} diff --git a/crates/vnidrop/src/runtime/targeted_receive.rs b/crates/vnidrop/src/runtime/targeted_receive.rs new file mode 100644 index 0000000..e219535 --- /dev/null +++ b/crates/vnidrop/src/runtime/targeted_receive.rs @@ -0,0 +1,381 @@ +//! Receiver-side targeted-transfer execution and completion. + +use std::sync::Arc; + +use anyhow::Result; +use iroh_blobs::ticket::BlobTicket; + +use super::{receive::ReceiveTarget, targeted::BlobTicketParse, CoreInner}; +use crate::{ + api::{saved_device_capabilities, TargetedTransferState}, + error::VnidropError, + secure_secret::{SecretHandle, SecretKind}, + targeted_transfer::{ + auth_secret_material, + protocol::{CompleteTargetedTransfer, CompletionResponse, TargetedTransferProtocol}, + reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft, + TargetedTransferRow, + }, +}; + +impl CoreInner { + pub(super) async fn receive_targeted_transfer( + self: &Arc, + transfer_id: String, + output_dir: String, + ) -> Result<(), VnidropError> { + let output_dir = + crate::filesystem::platform_path(&output_dir).map_err(VnidropError::filesystem)?; + self.receive_targeted_to_target(transfer_id, ReceiveTarget::Directory(output_dir)) + .await + } + + pub(super) async fn receive_targeted_transfer_with_output_sink( + self: &Arc, + transfer_id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSink(output_sink)) + .await + } + + pub(super) async fn receive_targeted_transfer_with_output_sink_v2( + self: &Arc, + transfer_id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSinkV2(output_sink)) + .await + } + + pub(super) async fn resume_targeted_transfer( + self: &Arc, + id: String, + output_dir: String, + ) -> Result<(), VnidropError> { + let output_dir = + crate::filesystem::platform_path(&output_dir).map_err(VnidropError::filesystem)?; + self.resume_targeted_to_target(id, ReceiveTarget::Directory(output_dir)) + .await + } + + pub(super) async fn resume_targeted_transfer_with_output_sink( + self: &Arc, + id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.resume_targeted_to_target(id, ReceiveTarget::OutputSink(output_sink)) + .await + } + + pub(super) async fn resume_targeted_transfer_with_output_sink_v2( + self: &Arc, + id: String, + output_sink: Arc, + ) -> Result<(), VnidropError> { + self.resume_targeted_to_target(id, ReceiveTarget::OutputSinkV2(output_sink)) + .await + } + + async fn receive_targeted_to_target( + self: &Arc, + transfer_id: String, + target: ReceiveTarget, + ) -> Result<(), VnidropError> { + let auth = self.load_receiver_authorization(&transfer_id).await?; + self.run_targeted_receive(&auth, target).await + } + + async fn resume_targeted_to_target( + self: &Arc, + id: String, + target: ReceiveTarget, + ) -> Result<(), VnidropError> { + let store = self.targeted_store(); + let row = store.get_row(&id).await?.ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("unknown targeted transfer")) + })?; + if !matches!( + row.state, + TargetedTransferState::Approved + | TargetedTransferState::Connecting + | TargetedTransferState::Transferring + | TargetedTransferState::Interrupted + ) { + return Err(VnidropError::InvalidTransition { + reason: format!( + "cannot resume from {}", + crate::targeted_transfer::state_as_str(row.state) + ), + }); + } + let auth = self.load_receiver_authorization(&id).await?; + self.run_targeted_receive(&auth, target).await + } + + async fn load_receiver_authorization( + &self, + transfer_id: &str, + ) -> Result { + let store = self.targeted_store(); + let row = store.get_row(transfer_id).await?.ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("unknown targeted transfer")) + })?; + let encoded = self.load_stored_authorization(&row).await?.ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!( + "targeted transfer has no durable authorization" + )) + })?; + let auth = TargetedAuthorization::decode(&encoded)?; + auth.verify_for_receiver(&self.endpoint.id().to_string())?; + Ok(auth) + } + + async fn run_targeted_receive( + self: &Arc, + auth: &TargetedAuthorization, + target: ReceiveTarget, + ) -> Result<(), VnidropError> { + let (cancel, cancelled) = tokio::sync::oneshot::channel(); + { + let mut active = self + .active_targeted_transfers + .lock() + .expect("active_targeted_transfers"); + match active.entry(auth.transfer_id.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(crate::runtime::ActiveTransfer { + direction: crate::transfer_state::TransferDirection::Receive, + cancel, + }); + } + std::collections::hash_map::Entry::Occupied(_) => { + return Err(VnidropError::InvalidTransition { + reason: "targeted receive is already active".to_string(), + }); + } + } + } + let result = self + .run_registered_targeted_receive(auth, target, cancelled) + .await; + self.active_targeted_transfers + .lock() + .expect("active_targeted_transfers") + .remove(&auth.transfer_id); + result + } + + async fn run_registered_targeted_receive( + self: &Arc, + auth: &TargetedAuthorization, + target: ReceiveTarget, + cancelled: tokio::sync::oneshot::Receiver<()>, + ) -> Result<(), VnidropError> { + let store = self.targeted_store(); + if let Some(row) = store.get_row(&auth.transfer_id).await? { + match row.state { + TargetedTransferState::Approved | TargetedTransferState::Interrupted => { + store + .set_state( + &auth.transfer_id, + row.state, + TargetedTransferState::Connecting, + ) + .await?; + self.emit_targeted_lifecycle(&auth.transfer_id, "connecting"); + store + .set_state( + &auth.transfer_id, + TargetedTransferState::Connecting, + TargetedTransferState::Transferring, + ) + .await?; + self.emit_targeted_lifecycle(&auth.transfer_id, "transferring"); + } + TargetedTransferState::Connecting | TargetedTransferState::Transferring => { + return Err(VnidropError::InvalidTransition { + reason: "targeted receive is already active".to_string(), + }); + } + other => { + return Err(VnidropError::InvalidTransition { + reason: format!( + "cannot receive from {}", + crate::targeted_transfer::state_as_str(other) + ), + }); + } + } + } + + let blob_ticket = BlobTicket::from_str_compat(&auth.blob_ticket) + .map_err(|error| VnidropError::ticket(anyhow::anyhow!(error)))?; + let receive_result = self + .receive_targeted_payload( + &auth.transfer_id, + auth.protocol_transfer_id, + auth.file_count, + auth.total_size, + blob_ticket, + target, + cancelled, + ) + .await; + + match receive_result { + Ok(()) => { + let row = store.get_row(&auth.transfer_id).await?.ok_or_else(|| { + VnidropError::internal(anyhow::anyhow!("targeted transfer missing")) + })?; + store + .complete_receiver_and_enqueue(&auth.transfer_id, row.total_size) + .await?; + self.emit_targeted_lifecycle(&auth.transfer_id, "completed"); + if self.acknowledge_targeted_completion(auth).await.is_ok() { + store.clear_pending_completion(&auth.transfer_id).await?; + } + Ok(()) + } + Err(error) => { + match store.get_row(&auth.transfer_id).await { + Ok(Some(row)) + if matches!( + row.state, + TargetedTransferState::Connecting | TargetedTransferState::Transferring + ) => + { + match store + .set_state_from_any( + &auth.transfer_id, + TargetedTransferState::Interrupted, + ) + .await + { + Ok(()) => { + self.emit_targeted_lifecycle(&auth.transfer_id, "interrupted") + } + Err(state_error) => { + tracing::warn!(transfer_id = %auth.transfer_id, %state_error, "failed to persist targeted receive interruption") + } + } + } + Ok(_) => {} + Err(state_error) => { + tracing::warn!(transfer_id = %auth.transfer_id, %state_error, "failed to load targeted receive state after error") + } + } + Err(VnidropError::transfer(error)) + } + } + } + + pub(super) async fn acknowledge_targeted_completion( + &self, + auth: &TargetedAuthorization, + ) -> Result<(), VnidropError> { + #[cfg(test)] + if self + .suppress_targeted_completion + .load(std::sync::atomic::Ordering::SeqCst) + { + return Err(VnidropError::device_unavailable(anyhow::anyhow!( + "completion delivery suppressed by test" + ))); + } + let addr = self + .device_relationships + .peer_addr(&auth.sender_endpoint_id) + .await?; + let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr); + let response = tokio::time::timeout( + self.connection_timeout(), + client.complete_transfer(CompleteTargetedTransfer { + transfer_id: auth.transfer_id.clone(), + verified_bytes: auth.total_size, + authorization: auth.encode()?, + }), + ) + .await + .map_err(|_| VnidropError::device_unavailable(anyhow::anyhow!("completion timed out")))? + .map_err(|error| VnidropError::network(anyhow::anyhow!(error)))?; + if response != CompletionResponse::Recorded { + return Err(VnidropError::permission(anyhow::anyhow!( + "sender rejected targeted completion" + ))); + } + Ok(()) + } + + pub(super) async fn persist_sender_authorization_and_approve( + &self, + authorization: &TargetedAuthorization, + ) -> Result<(), VnidropError> { + let custody = + self.secret_custody + .as_ref() + .ok_or_else(|| VnidropError::SecureStorageUnavailable { + reason: "targeted authorization requires protected custody".to_string(), + })?; + let handle = custody + .protect( + SecretKind::TargetedAuthorization, + auth_secret_material(authorization)?, + None, + ) + .await?; + if let Err(error) = self + .targeted_store() + .finalize_sender_authorization_and_enqueue( + &authorization.transfer_id, + &authorization.blob_ticket, + handle.as_str(), + ) + .await + { + if let Err(cleanup_error) = custody.remove(&handle).await { + tracing::warn!(%cleanup_error, "failed to roll back targeted sender authorization secret"); + } + return Err(error); + } + Ok(()) + } + + pub(crate) async fn load_stored_authorization( + &self, + row: &TargetedTransferRow, + ) -> Result, VnidropError> { + let (Some(handle), Some(blob_ticket)) = ( + row.authorization_secret_handle.as_ref(), + row.blob_ticket.as_ref(), + ) else { + return Ok(None); + }; + let custody = + self.secret_custody + .as_ref() + .ok_or_else(|| VnidropError::SecureStorageUnavailable { + reason: "targeted authorization requires protected custody".to_string(), + })?; + let material = custody + .load(&SecretHandle::from_stored(handle.clone())) + .await?; + let auth = reconstruct_authorization( + TargetedAuthorizationDraft { + transfer_id: row.id.clone(), + protocol_transfer_id: row.protocol_transfer_id, + sender_endpoint_id: row.sender_endpoint_id.clone(), + receiver_endpoint_id: row.receiver_endpoint_id.clone(), + manifest_id: row.manifest_id.clone(), + content_hash: row.content_hash.clone(), + file_count: row.file_count, + total_size: row.total_size, + protocol_version: saved_device_capabilities().targeted_transfer_protocol_version, + transfer_name: row.transfer_name.clone(), + blob_ticket: blob_ticket.clone(), + }, + &material, + )?; + Ok(Some(auth.encode()?)) + } +} diff --git a/crates/vnidrop/src/runtime/targeted_reconciliation.rs b/crates/vnidrop/src/runtime/targeted_reconciliation.rs new file mode 100644 index 0000000..d6aabd7 --- /dev/null +++ b/crates/vnidrop/src/runtime/targeted_reconciliation.rs @@ -0,0 +1,214 @@ +//! Durable targeted-transfer background reconciliation. + +use std::sync::Arc; + +use anyhow::Result; + +use super::{targeted::map_connect_failure, CoreInner}; +use crate::{ + error::VnidropError, + targeted_transfer::{ + protocol::{DeliverTargetedAuthorization, TargetedTransferProtocol}, + TargetedAuthorization, TargetedTransferRow, + }, + util::now_ms, +}; + +impl CoreInner { + pub(super) async fn spawn_targeted_reconciliation_task(self: &Arc) { + let core = self.clone(); + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(2)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + core.release_completed_targeted_payloads().await; + core.retry_targeted_authorization_deliveries().await; + core.retry_targeted_completions().await; + } + }); + *self.targeted_reconciliation_task.lock().await = Some(task); + } + + async fn retry_targeted_completions(&self) { + let rows = match self.targeted_store().list_pending_completions().await { + Ok(rows) => rows, + Err(error) => { + tracing::warn!(%error, "failed to load pending targeted completions"); + return; + } + }; + for row in rows.into_iter().take(1) { + let encoded = match self.load_stored_authorization(&row).await { + Ok(Some(encoded)) => encoded, + Ok(None) => { + tracing::warn!(transfer_id = %row.id, "pending targeted completion has no authorization"); + self.defer_targeted_completion_with_log(&row.id, now_ms() + 30_000) + .await; + continue; + } + Err(error) => { + tracing::warn!(transfer_id = %row.id, %error, "failed to load pending targeted completion authorization"); + self.defer_targeted_completion_with_log(&row.id, now_ms() + 30_000) + .await; + continue; + } + }; + let auth = match TargetedAuthorization::decode(&encoded) { + Ok(auth) => auth, + Err(error) => { + tracing::warn!(transfer_id = %row.id, %error, "failed to decode pending targeted completion authorization"); + self.defer_targeted_completion_with_log(&row.id, now_ms() + 30_000) + .await; + continue; + } + }; + match self.acknowledge_targeted_completion(&auth).await { + Ok(()) => { + if let Err(error) = self + .targeted_store() + .clear_pending_completion(&row.id) + .await + { + tracing::warn!(transfer_id = %row.id, %error, "failed to settle targeted completion"); + } + } + Err(error) => { + tracing::warn!(transfer_id = %row.id, %error, "targeted completion retry failed"); + self.defer_targeted_completion_with_log(&row.id, now_ms() + 5_000) + .await; + } + } + } + } + + async fn defer_targeted_completion_with_log(&self, id: &str, next_attempt_at: i64) { + if let Err(error) = self + .targeted_store() + .defer_pending_completion(id, next_attempt_at) + .await + { + tracing::warn!(transfer_id = %id, %error, "failed to defer targeted completion"); + } + } + + async fn retry_targeted_authorization_deliveries(&self) { + let rows = match self + .targeted_store() + .list_pending_authorization_deliveries() + .await + { + Ok(rows) => rows, + Err(error) => { + tracing::warn!(%error, "failed to load pending targeted authorization deliveries"); + return; + } + }; + for row in rows.into_iter().take(1) { + match self.deliver_stored_targeted_authorization(&row).await { + Ok(true) => { + if let Err(error) = self + .targeted_store() + .clear_pending_authorization_delivery(&row.id) + .await + { + tracing::warn!(transfer_id = %row.id, %error, "failed to settle targeted authorization delivery"); + } + } + Ok(false) => { + tracing::warn!(transfer_id = %row.id, "receiver rejected targeted authorization delivery"); + if let Err(error) = self + .targeted_store() + .defer_pending_authorization_delivery(&row.id, now_ms() + 5_000) + .await + { + tracing::warn!(transfer_id = %row.id, %error, "failed to defer rejected targeted authorization delivery"); + } + } + Err(error) => { + tracing::warn!(transfer_id = %row.id, %error, "targeted authorization delivery retry failed"); + if let Err(error) = self + .targeted_store() + .defer_pending_authorization_delivery(&row.id, now_ms() + 5_000) + .await + { + tracing::warn!(transfer_id = %row.id, %error, "failed to defer targeted authorization delivery"); + } + } + } + } + } + + pub(super) async fn deliver_stored_targeted_authorization( + &self, + row: &TargetedTransferRow, + ) -> Result { + #[cfg(test)] + self.targeted_authorization_delivery_attempts + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + #[cfg(test)] + if self + .suppress_targeted_authorization_delivery + .load(std::sync::atomic::Ordering::SeqCst) + { + return Err(VnidropError::device_unavailable(anyhow::anyhow!( + "targeted authorization delivery suppressed by test" + ))); + } + let encoded = self.load_stored_authorization(row).await?.ok_or_else(|| { + VnidropError::SecureStorageMissing { + reason: "targeted authorization is missing".to_string(), + } + })?; + let addr = self + .device_relationships + .peer_addr(&row.receiver_endpoint_id) + .await?; + let response = tokio::time::timeout( + self.connection_timeout(), + TargetedTransferProtocol::client(self.endpoint.clone(), addr).deliver_authorization( + DeliverTargetedAuthorization { + transfer_id: row.id.clone(), + authorization: encoded, + }, + ), + ) + .await + .map_err(|_| { + VnidropError::device_unavailable(anyhow::anyhow!( + "targeted authorization delivery timed out" + )) + })? + .map_err(map_connect_failure)?; + Ok(response == crate::targeted_transfer::protocol::DeliverAuthorizationResponse::Stored) + } + + async fn release_completed_targeted_payloads(&self) { + let rows = match self.targeted_store().list_completed_sender_rows().await { + Ok(rows) => rows, + Err(error) => { + tracing::warn!(%error, "failed to load pending targeted payload releases"); + return; + } + }; + for row in rows { + match self + .try_teardown_targeted_payload(row.protocol_transfer_id, Some(&row.id)) + .await + { + Ok(()) => { + if let Err(error) = self + .targeted_store() + .clear_pending_payload_release(&row.id) + .await + { + tracing::warn!(transfer_id = %row.id, %error, "failed to settle targeted payload release"); + } + } + Err(error) => { + tracing::warn!(transfer_id = %row.id, %error, "targeted payload release retry failed") + } + } + } + } +} diff --git a/crates/vnidrop/src/secure_secret.rs b/crates/vnidrop/src/secure_secret.rs index f4ec338..67a336e 100644 --- a/crates/vnidrop/src/secure_secret.rs +++ b/crates/vnidrop/src/secure_secret.rs @@ -419,6 +419,19 @@ impl SecretCustody { Ok(handle) } + #[cfg(test)] + pub(crate) async fn create_orphaned_targeted_authorization_for_test( + &self, + ) -> Result<(), VnidropError> { + self.protect( + SecretKind::TargetedAuthorization, + SecretMaterial::new(vec![7; 32])?, + None, + ) + .await?; + Ok(()) + } + pub(crate) async fn load(&self, handle: &SecretHandle) -> Result { let metadata = self.metadata.find(handle).await?.ok_or_else(|| { VnidropError::SecureStorageMissing { @@ -629,6 +642,24 @@ impl SecretCustody { Ok(summary) } + pub(crate) async fn remove_orphaned_targeted_authorizations( + &self, + referenced_handles: &HashSet, + ) -> Result { + let entries = self.metadata.list().await?; + let mut removed = 0; + for entry in entries { + if entry.kind == SecretKind::TargetedAuthorization + && entry.state == SecretMetadataState::Active + && !referenced_handles.contains(entry.handle.as_str()) + { + self.remove(&entry.handle).await?; + removed += 1; + } + } + Ok(removed) + } + async fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> { match self.store_delete_raw(handle.clone()).await? { Ok(()) | Err(SecureSecretStoreError::Missing) => Ok(()), @@ -825,6 +856,10 @@ impl FaultInjectingSecretStore { self.corrupted.lock().unwrap().push(handle.clone()); } + pub(crate) fn stored_value_count_for_test(&self) -> usize { + self.values.lock().unwrap().len() + } + pub(crate) fn only_handle_for_test(&self) -> SecretHandle { let handles = self .values diff --git a/crates/vnidrop/src/targeted_transfer/inbox.rs b/crates/vnidrop/src/targeted_transfer/inbox.rs index b5a3129..aed3506 100644 --- a/crates/vnidrop/src/targeted_transfer/inbox.rs +++ b/crates/vnidrop/src/targeted_transfer/inbox.rs @@ -218,6 +218,7 @@ impl TargetedOfferInbox { .map(|entry| entry.offer.clone()) } + #[cfg(test)] pub(crate) async fn authorization_matches_pending( &self, auth: &crate::targeted_transfer::TargetedAuthorization, @@ -271,16 +272,14 @@ impl TargetedOfferInbox { let Some(sender_endpoint_id) = sender_endpoint_id else { return Err(RespondError::Unknown); }; - let waiter = { - let decisions = self.decisions.lock().await; - decisions - .get(transfer_id) - .map(|entry| entry.decision.clone()) - }; - let Some(decision_tx) = waiter else { - return Err(RespondError::Unknown); - }; if !accepted { + let decision_tx = { + let decisions = self.decisions.lock().await; + decisions + .get(transfer_id) + .map(|entry| entry.decision.clone()) + .ok_or(RespondError::Unknown)? + }; let _ = decision_tx.send(Some(false)); self.discard(transfer_id).await; self.cooldown.record_decline(&sender_endpoint_id); @@ -298,17 +297,52 @@ impl TargetedOfferInbox { return Ok(None); } - let (auth_tx, mut auth_rx) = watch::channel(None); + self.accept_live(transfer_id).await?; + self.wait_for_authorization(transfer_id).await + } + + pub(crate) async fn pending_for_acceptance( + &self, + transfer_id: &str, + ) -> Option { + self.get_pending(transfer_id).await + } + + pub(crate) async fn accept_live(&self, transfer_id: &str) -> Result<(), RespondError> { + let decision_tx = { + let decisions = self.decisions.lock().await; + decisions + .get(transfer_id) + .map(|entry| entry.decision.clone()) + .ok_or(RespondError::Unknown)? + }; + let (auth_tx, auth_rx) = watch::channel(None); self.auths .lock() .await .insert(transfer_id.to_string(), AuthWaiter { auth: auth_tx }); if decision_tx.send(Some(true)).is_err() { self.auths.lock().await.remove(transfer_id); - self.discard(transfer_id).await; return Err(RespondError::SenderGone); } + drop(auth_rx); + Ok(()) + } + pub(crate) async fn wait_for_authorization( + &self, + transfer_id: &str, + ) -> Result, RespondError> { + if let Some(auth) = self.settled_authorization(transfer_id).await { + return Ok(Some(auth)); + } + let mut auth_rx = { + let auths = self.auths.lock().await; + auths + .get(transfer_id) + .map(|entry| entry.auth.subscribe()) + .ok_or(RespondError::Unknown)? + }; let wait_auth = async { loop { if let Some(auth) = auth_rx.borrow_and_update().clone() { @@ -333,7 +367,7 @@ impl TargetedOfferInbox { Ok(Some(auth)) } Ok(Err(())) | Err(_) => { - self.discard(transfer_id).await; + self.auths.lock().await.remove(transfer_id); Err(RespondError::AuthorizationTimeout) } } diff --git a/crates/vnidrop/src/targeted_transfer/mod.rs b/crates/vnidrop/src/targeted_transfer/mod.rs index 32d8795..7fd892e 100644 --- a/crates/vnidrop/src/targeted_transfer/mod.rs +++ b/crates/vnidrop/src/targeted_transfer/mod.rs @@ -6,8 +6,10 @@ mod auth; pub(crate) mod inbox; pub(crate) mod protocol; +mod schema; mod state; mod store; +mod store_outbox; pub(crate) use auth::{ auth_secret_material, reconstruct_authorization, TargetedAuthorization, @@ -15,6 +17,7 @@ pub(crate) use auth::{ }; pub(crate) use inbox::{RespondError, TargetedOfferInbox}; pub(crate) use protocol::TargetedTransferProtocol; +pub(crate) use schema::ensure_schema; pub(crate) use store::{ - ensure_schema, state_as_str, TargetedTransferRole, TargetedTransferRow, TargetedTransferStore, + state_as_str, TargetedTransferRole, TargetedTransferRow, TargetedTransferStore, }; diff --git a/crates/vnidrop/src/targeted_transfer/protocol.rs b/crates/vnidrop/src/targeted_transfer/protocol.rs index 1c9591e..a1b1b67 100644 --- a/crates/vnidrop/src/targeted_transfer/protocol.rs +++ b/crates/vnidrop/src/targeted_transfer/protocol.rs @@ -43,10 +43,15 @@ pub(crate) struct TargetedTransferProtocol { access_policy: std::sync::Arc, cleanup: std::sync::Arc TargetedCleanupFuture + Send + Sync>, + persist_authorization: std::sync::Arc< + dyn Fn(TargetedAuthorization) -> TargetedAuthorizationPersistFuture + Send + Sync, + >, } pub(crate) type TargetedCleanupFuture = Pin> + Send>>; +pub(crate) type TargetedAuthorizationPersistFuture = + Pin> + Send>>; impl fmt::Debug for TargetedTransferProtocol { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -71,6 +76,9 @@ impl TargetedTransferProtocol { cleanup: std::sync::Arc< dyn Fn(super::TargetedTransferRow) -> TargetedCleanupFuture + Send + Sync, >, + persist_authorization: std::sync::Arc< + dyn Fn(TargetedAuthorization) -> TargetedAuthorizationPersistFuture + Send + Sync, + >, ) -> Self { Self { relationships, @@ -83,6 +91,7 @@ impl TargetedTransferProtocol { event_hub, access_policy, cleanup, + persist_authorization, } } @@ -251,6 +260,14 @@ impl TargetedTransferProtocol { remote_endpoint_id: &str, delivery: DeliverTargetedAuthorization, ) -> DeliverAuthorizationResponse { + if self + .relationships + .require_saved(remote_endpoint_id) + .await + .is_err() + { + return DeliverAuthorizationResponse::Rejected; + } let Ok(auth) = TargetedAuthorization::decode(&delivery.authorization) else { return DeliverAuthorizationResponse::Rejected; }; @@ -260,57 +277,23 @@ impl TargetedTransferProtocol { { return DeliverAuthorizationResponse::Rejected; } - if let Ok(Some(row)) = self.store.get_row(&delivery.transfer_id).await { - if row.authorization_secret_handle.is_some() - && row.protocol_transfer_id == auth.protocol_transfer_id - && row.sender_endpoint_id == auth.sender_endpoint_id - && row.receiver_endpoint_id == auth.receiver_endpoint_id - && row.manifest_id == auth.manifest_id - && row.content_hash == auth.content_hash - && row.transfer_name == auth.transfer_name - && row.file_count == auth.file_count - && row.total_size == auth.total_size - && row.blob_ticket.as_deref() == Some(auth.blob_ticket.as_str()) - && auth.protocol_version - == saved_device_capabilities().targeted_transfer_protocol_version - { - return DeliverAuthorizationResponse::Stored; - } - if row.authorization_secret_handle.is_some() { - return DeliverAuthorizationResponse::Rejected; - } - } - if !self.inbox.authorization_matches_pending(&auth).await { + if auth.protocol_version != saved_device_capabilities().targeted_transfer_protocol_version { return DeliverAuthorizationResponse::Rejected; } - if self - .inbox - .deliver_authorization(&delivery.transfer_id, delivery.authorization) - .await - { - let deadline = tokio::time::Instant::now() - + std::time::Duration::from_millis(self.limits.offer_timeout_ms); - loop { - if let Ok(Some(row)) = self.store.get_row(&delivery.transfer_id).await { - if row.authorization_secret_handle.is_some() { - return DeliverAuthorizationResponse::Stored; - } - if matches!( - row.state, - TargetedTransferState::Failed - | TargetedTransferState::Cancelled - | TargetedTransferState::Deleted - ) { - return DeliverAuthorizationResponse::Rejected; - } - } - if tokio::time::Instant::now() >= deadline { - return DeliverAuthorizationResponse::Rejected; - } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - } else { + let Ok(ticket) = auth.blob_ticket.parse::() else { + return DeliverAuthorizationResponse::Rejected; + }; + if ticket.hash().to_string() != auth.manifest_id || auth.manifest_id != auth.content_hash { + return DeliverAuthorizationResponse::Rejected; + } + if (self.persist_authorization)(auth).await.is_err() { DeliverAuthorizationResponse::Rejected + } else { + let _ = self + .inbox + .deliver_authorization(&delivery.transfer_id, delivery.authorization) + .await; + DeliverAuthorizationResponse::Stored } } @@ -324,6 +307,14 @@ impl TargetedTransferProtocol { return CancelWireOfferResponse::Rejected; } self.inbox.discard(&cancel.transfer_id).await; + if self + .store + .clear_accepted_offer_intent(&cancel.transfer_id) + .await + .is_err() + { + return CancelWireOfferResponse::Rejected; + } return CancelWireOfferResponse::Cancelled; } if let Ok(Some(row)) = self.store.get_row(&cancel.transfer_id).await { @@ -382,6 +373,14 @@ impl TargetedTransferProtocol { } return CancelWireOfferResponse::Cancelled; } + if self + .store + .clear_accepted_intent_if_sender(&cancel.transfer_id, remote_endpoint_id) + .await + .is_err() + { + return CancelWireOfferResponse::Rejected; + } CancelWireOfferResponse::Cancelled } diff --git a/crates/vnidrop/src/targeted_transfer/schema.rs b/crates/vnidrop/src/targeted_transfer/schema.rs new file mode 100644 index 0000000..6772cf6 --- /dev/null +++ b/crates/vnidrop/src/targeted_transfer/schema.rs @@ -0,0 +1,167 @@ +//! Targeted-transfer SQLite schema and one-time migrations. + +use sqlx::{Row, SqlitePool}; + +use crate::util::now_ms; + +pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS targeted_transfers ( + id TEXT PRIMARY KEY, + protocol_transfer_id INTEGER NOT NULL UNIQUE, + sender_endpoint_id TEXT NOT NULL, + receiver_endpoint_id TEXT NOT NULL, + manifest_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + transfer_name TEXT NOT NULL, + file_count INTEGER NOT NULL, + total_size INTEGER NOT NULL, + verified_bytes INTEGER NOT NULL DEFAULT 0, + blob_ticket TEXT, + authorization_secret_handle TEXT, + role TEXT NOT NULL DEFAULT 'sender', + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + "#, + ) + .execute(pool) + .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS targeted_accepted_offer_intents ( + transfer_id TEXT PRIMARY KEY, + sender_endpoint_id TEXT NOT NULL, + receiver_endpoint_id TEXT NOT NULL, + manifest_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + transfer_name TEXT NOT NULL, + file_count INTEGER NOT NULL, + total_size INTEGER NOT NULL, + protocol_version INTEGER NOT NULL, + accepted_at INTEGER NOT NULL + ); + "#, + ) + .execute(pool) + .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS targeted_authorization_delivery_outbox ( + transfer_id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + next_attempt_at INTEGER NOT NULL, + FOREIGN KEY(transfer_id) REFERENCES targeted_transfers(id) ON DELETE CASCADE + ); + "#, + ) + .execute(pool) + .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS targeted_completion_outbox ( + transfer_id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + next_attempt_at INTEGER NOT NULL, + FOREIGN KEY(transfer_id) REFERENCES targeted_transfers(id) ON DELETE CASCADE + ); + "#, + ) + .execute(pool) + .await?; + let completion_columns = sqlx::query("PRAGMA table_info(targeted_completion_outbox)") + .fetch_all(pool) + .await?; + if !completion_columns + .iter() + .any(|row| row.get::(1) == "next_attempt_at") + { + sqlx::query( + "ALTER TABLE targeted_completion_outbox ADD COLUMN next_attempt_at INTEGER NOT NULL DEFAULT 0", + ) + .execute(pool) + .await?; + } + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS targeted_payload_release_outbox ( + transfer_id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + FOREIGN KEY(transfer_id) REFERENCES targeted_transfers(id) ON DELETE CASCADE + ); + "#, + ) + .execute(pool) + .await?; + let columns = sqlx::query("PRAGMA table_info(targeted_transfers)") + .fetch_all(pool) + .await?; + let has = |name: &str| columns.iter().any(|row| row.get::(1) == name); + if !has("verified_bytes") { + sqlx::query( + "ALTER TABLE targeted_transfers ADD COLUMN verified_bytes INTEGER NOT NULL DEFAULT 0", + ) + .execute(pool) + .await?; + } + if !has("blob_ticket") { + sqlx::query("ALTER TABLE targeted_transfers ADD COLUMN blob_ticket TEXT") + .execute(pool) + .await?; + } + if !has("authorization_secret_handle") { + sqlx::query("ALTER TABLE targeted_transfers ADD COLUMN authorization_secret_handle TEXT") + .execute(pool) + .await?; + } + if !has("role") { + sqlx::query( + "ALTER TABLE targeted_transfers ADD COLUMN role TEXT NOT NULL DEFAULT 'sender'", + ) + .execute(pool) + .await?; + } + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS targeted_schema_migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + ) + "#, + ) + .execute(pool) + .await?; + let mut transaction = pool.begin().await?; + let applied = sqlx::query( + "SELECT EXISTS(SELECT 1 FROM targeted_schema_migrations WHERE name = 'authorization-delivery-outbox-v1')", + ) + .fetch_one(&mut *transaction) + .await? + .get::(0) + != 0; + if !applied { + // Pre-outbox Approved sender rows already hold a receiver-bound authorization. + // Re-delivery is idempotent and cannot create consent on a receiver. + sqlx::query( + r#" + INSERT OR IGNORE INTO targeted_authorization_delivery_outbox + (transfer_id, created_at, next_attempt_at) + SELECT id, updated_at, 0 FROM targeted_transfers + WHERE role = 'sender' AND state = 'approved' + AND blob_ticket IS NOT NULL AND authorization_secret_handle IS NOT NULL + "#, + ) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO targeted_schema_migrations (name, applied_at) VALUES ('authorization-delivery-outbox-v1', ?1)", + ) + .bind(now_ms()) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(()) +} diff --git a/crates/vnidrop/src/targeted_transfer/store.rs b/crates/vnidrop/src/targeted_transfer/store.rs index 81bfcd5..609fa8a 100644 --- a/crates/vnidrop/src/targeted_transfer/store.rs +++ b/crates/vnidrop/src/targeted_transfer/store.rs @@ -11,101 +11,9 @@ use crate::{ util::now_ms, }; -pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS targeted_transfers ( - id TEXT PRIMARY KEY, - protocol_transfer_id INTEGER NOT NULL UNIQUE, - sender_endpoint_id TEXT NOT NULL, - receiver_endpoint_id TEXT NOT NULL, - manifest_id TEXT NOT NULL, - content_hash TEXT NOT NULL, - transfer_name TEXT NOT NULL, - file_count INTEGER NOT NULL, - total_size INTEGER NOT NULL, - verified_bytes INTEGER NOT NULL DEFAULT 0, - blob_ticket TEXT, - authorization_secret_handle TEXT, - role TEXT NOT NULL DEFAULT 'sender', - state TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - "#, - ) - .execute(pool) - .await?; - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS targeted_completion_outbox ( - transfer_id TEXT PRIMARY KEY, - created_at INTEGER NOT NULL, - next_attempt_at INTEGER NOT NULL, - FOREIGN KEY(transfer_id) REFERENCES targeted_transfers(id) ON DELETE CASCADE - ); - "#, - ) - .execute(pool) - .await?; - let completion_columns = sqlx::query("PRAGMA table_info(targeted_completion_outbox)") - .fetch_all(pool) - .await?; - if !completion_columns - .iter() - .any(|row| row.get::(1) == "next_attempt_at") - { - sqlx::query( - "ALTER TABLE targeted_completion_outbox ADD COLUMN next_attempt_at INTEGER NOT NULL DEFAULT 0", - ) - .execute(pool) - .await?; - } - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS targeted_payload_release_outbox ( - transfer_id TEXT PRIMARY KEY, - created_at INTEGER NOT NULL, - FOREIGN KEY(transfer_id) REFERENCES targeted_transfers(id) ON DELETE CASCADE - ); - "#, - ) - .execute(pool) - .await?; - let columns = sqlx::query("PRAGMA table_info(targeted_transfers)") - .fetch_all(pool) - .await?; - let has = |name: &str| columns.iter().any(|row| row.get::(1) == name); - if !has("verified_bytes") { - sqlx::query( - "ALTER TABLE targeted_transfers ADD COLUMN verified_bytes INTEGER NOT NULL DEFAULT 0", - ) - .execute(pool) - .await?; - } - if !has("blob_ticket") { - sqlx::query("ALTER TABLE targeted_transfers ADD COLUMN blob_ticket TEXT") - .execute(pool) - .await?; - } - if !has("authorization_secret_handle") { - sqlx::query("ALTER TABLE targeted_transfers ADD COLUMN authorization_secret_handle TEXT") - .execute(pool) - .await?; - } - if !has("role") { - sqlx::query( - "ALTER TABLE targeted_transfers ADD COLUMN role TEXT NOT NULL DEFAULT 'sender'", - ) - .execute(pool) - .await?; - } - Ok(()) -} - #[derive(Clone)] pub(crate) struct TargetedTransferStore { - pool: SqlitePool, + pub(super) pool: SqlitePool, } impl TargetedTransferStore { @@ -113,6 +21,88 @@ impl TargetedTransferStore { Self { pool } } + pub(crate) async fn persist_accepted_offer_intent( + &self, + offer: &crate::api::PendingTargetedOffer, + ) -> Result<(), VnidropError> { + let result = sqlx::query( + r#" + INSERT INTO targeted_accepted_offer_intents ( + transfer_id, sender_endpoint_id, receiver_endpoint_id, manifest_id, + content_hash, transfer_name, file_count, total_size, protocol_version, accepted_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(transfer_id) DO UPDATE SET accepted_at = excluded.accepted_at + WHERE sender_endpoint_id = excluded.sender_endpoint_id + AND receiver_endpoint_id = excluded.receiver_endpoint_id + AND manifest_id = excluded.manifest_id + AND content_hash = excluded.content_hash + AND transfer_name = excluded.transfer_name + AND file_count = excluded.file_count + AND total_size = excluded.total_size + AND protocol_version = excluded.protocol_version + "#, + ) + .bind(&offer.transfer_id) + .bind(&offer.sender_endpoint_id) + .bind(&offer.receiver_endpoint_id) + .bind(&offer.manifest_id) + .bind(&offer.content_hash) + .bind(&offer.transfer_name) + .bind(offer.file_count as i64) + .bind(offer.total_size as i64) + .bind(offer.protocol_version as i64) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + if result.rows_affected() == 0 { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "accepted targeted offer conflicts with durable consent" + ))); + } + Ok(()) + } + + pub(crate) async fn clear_accepted_offer_intent(&self, id: &str) -> Result<(), VnidropError> { + sqlx::query("DELETE FROM targeted_accepted_offer_intents WHERE transfer_id = ?1") + .bind(id) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn clear_accepted_intent_if_sender( + &self, + id: &str, + sender_endpoint_id: &str, + ) -> Result<(), VnidropError> { + sqlx::query( + "DELETE FROM targeted_accepted_offer_intents WHERE transfer_id = ?1 AND sender_endpoint_id = ?2", + ) + .bind(id) + .bind(sender_endpoint_id) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn list_accepted_intent_senders( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + "SELECT transfer_id, sender_endpoint_id FROM targeted_accepted_offer_intents", + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows + .into_iter() + .map(|row| (row.get(0), row.get(1))) + .collect()) + } + pub(crate) async fn contains_protocol_id( &self, protocol_transfer_id: u64, @@ -251,6 +241,15 @@ impl TargetedTransferStore { .map_err(VnidropError::repository)? .get::(0) != 0; + if completed { + sqlx::query( + "DELETE FROM targeted_authorization_delivery_outbox WHERE transfer_id = ?1", + ) + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + } transaction .commit() .await @@ -263,6 +262,11 @@ impl TargetedTransferStore { }) }; } + sqlx::query("DELETE FROM targeted_authorization_delivery_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; sqlx::query( "INSERT OR IGNORE INTO targeted_payload_release_outbox (transfer_id, created_at) VALUES (?1, ?2)", ) @@ -278,189 +282,39 @@ impl TargetedTransferStore { Ok(true) } - pub(crate) async fn complete_receiver_and_enqueue( - &self, - id: &str, - verified_bytes: u64, - ) -> Result<(), VnidropError> { - let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?; - let result = sqlx::query( - r#" - UPDATE targeted_transfers - SET state = 'completed', verified_bytes = ?2, updated_at = ?3 - WHERE id = ?1 AND role = 'receiver' AND state = 'transferring' - "#, - ) - .bind(id) - .bind(verified_bytes as i64) - .bind(now_ms()) - .execute(&mut *transaction) - .await - .map_err(VnidropError::repository)?; - if result.rows_affected() == 0 { - return Err(VnidropError::InvalidTransition { - reason: "receiver transfer cannot be completed".to_string(), - }); - } - sqlx::query( - "INSERT OR IGNORE INTO targeted_completion_outbox (transfer_id, created_at, next_attempt_at) VALUES (?1, ?2, ?2)", - ) - .bind(id) - .bind(now_ms()) - .execute(&mut *transaction) - .await - .map_err(VnidropError::repository)?; - transaction - .commit() - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - pub(crate) async fn advance_verified_bytes( - &self, - id: &str, - verified_bytes: u64, - ) -> Result { - let result = sqlx::query( - r#" - UPDATE targeted_transfers - SET verified_bytes = MIN(total_size, MAX(verified_bytes, ?2)), updated_at = ?3 - WHERE id = ?1 AND role = 'receiver' AND state = 'transferring' - AND ?2 > verified_bytes - "#, - ) - .bind(id) - .bind(verified_bytes as i64) - .bind(now_ms()) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(result.rows_affected() == 1) - } - - pub(crate) async fn list_pending_completions( - &self, - ) -> Result, VnidropError> { - let rows = sqlx::query( - r#" - SELECT t.id, t.protocol_transfer_id, t.sender_endpoint_id, t.receiver_endpoint_id, - t.manifest_id, t.content_hash, t.transfer_name, t.file_count, t.total_size, - t.verified_bytes, t.blob_ticket, t.authorization_secret_handle, t.role, - t.state, t.created_at, t.updated_at - FROM targeted_transfers t - INNER JOIN targeted_completion_outbox o ON o.transfer_id = t.id - WHERE t.role = 'receiver' AND t.state = 'completed' AND o.next_attempt_at <= ?1 - ORDER BY o.next_attempt_at, o.created_at - "#, - ) - .bind(now_ms()) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - rows.into_iter().map(row_to_full).collect() - } - - pub(crate) async fn clear_pending_completion(&self, id: &str) -> Result<(), VnidropError> { - sqlx::query("DELETE FROM targeted_completion_outbox WHERE transfer_id = ?1") - .bind(id) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - pub(crate) async fn defer_pending_completion( - &self, - id: &str, - next_attempt_at: i64, - ) -> Result<(), VnidropError> { - sqlx::query( - "UPDATE targeted_completion_outbox SET next_attempt_at = ?2 WHERE transfer_id = ?1", - ) - .bind(id) - .bind(next_attempt_at) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - pub(crate) async fn list_completed_sender_rows( - &self, - ) -> Result, VnidropError> { - let rows = sqlx::query( - r#" - SELECT t.id, t.protocol_transfer_id, t.sender_endpoint_id, t.receiver_endpoint_id, - t.manifest_id, t.content_hash, t.transfer_name, t.file_count, t.total_size, - t.verified_bytes, t.blob_ticket, t.authorization_secret_handle, t.role, - t.state, t.created_at, t.updated_at - FROM targeted_transfers t - INNER JOIN targeted_payload_release_outbox o ON o.transfer_id = t.id - WHERE t.role = 'sender' AND t.state = 'completed' - "#, - ) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - rows.into_iter().map(row_to_full).collect() - } - - pub(crate) async fn clear_pending_payload_release(&self, id: &str) -> Result<(), VnidropError> { - sqlx::query("DELETE FROM targeted_payload_release_outbox WHERE transfer_id = ?1") - .bind(id) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - pub(crate) async fn store_authorization( + pub(crate) async fn finalize_sender_authorization_and_enqueue( &self, id: &str, blob_ticket: &str, authorization_secret_handle: &str, ) -> Result<(), VnidropError> { - sqlx::query( + let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?; + let now = now_ms(); + let result = sqlx::query( r#" UPDATE targeted_transfers - SET blob_ticket = ?2, - authorization_secret_handle = ?3, - updated_at = ?4 - WHERE id = ?1 + SET state = 'approved', blob_ticket = ?2, + authorization_secret_handle = ?3, updated_at = ?4 + WHERE id = ?1 AND role = 'sender' AND state = 'awaiting_approval' "#, ) .bind(id) .bind(blob_ticket) .bind(authorization_secret_handle) - .bind(now_ms()) - .execute(&self.pool) + .bind(now) + .execute(&mut *transaction) .await .map_err(VnidropError::repository)?; - Ok(()) - } - - pub(crate) async fn clear_authorization(&self, id: &str) -> Result<(), VnidropError> { - let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?; - sqlx::query("DELETE FROM targeted_completion_outbox WHERE transfer_id = ?1") - .bind(id) - .execute(&mut *transaction) - .await - .map_err(VnidropError::repository)?; - sqlx::query("DELETE FROM targeted_payload_release_outbox WHERE transfer_id = ?1") - .bind(id) - .execute(&mut *transaction) - .await - .map_err(VnidropError::repository)?; + if result.rows_affected() == 0 { + return Err(VnidropError::InvalidTransition { + reason: "sender transfer cannot be approved".to_string(), + }); + } sqlx::query( - r#" - UPDATE targeted_transfers - SET blob_ticket = NULL, - authorization_secret_handle = NULL - WHERE id = ?1 - "#, + "INSERT OR REPLACE INTO targeted_authorization_delivery_outbox (transfer_id, created_at, next_attempt_at) VALUES (?1, ?2, ?2)", ) .bind(id) + .bind(now) .execute(&mut *transaction) .await .map_err(VnidropError::repository)?; @@ -471,56 +325,101 @@ impl TargetedTransferStore { Ok(()) } - /// Commit a local terminal denial and optional secret cleanup atomically. - pub(crate) async fn transition_terminal( + pub(crate) async fn persist_receiver_authorization_and_consume_intent( &self, - id: &str, - state: TargetedTransferState, - clear_authorization: bool, + auth: &crate::targeted_transfer::TargetedAuthorization, + authorization_secret_handle: &str, ) -> Result { - let state = match state { - TargetedTransferState::Cancelled => "cancelled", - TargetedTransferState::Deleted => "deleted", - _ => { - return Err(VnidropError::invalid_input(anyhow::anyhow!( - "terminal transition requires cancelled or deleted" - ))) - } - }; let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?; - if clear_authorization { - sqlx::query("DELETE FROM targeted_completion_outbox WHERE transfer_id = ?1") - .bind(id) - .execute(&mut *transaction) - .await - .map_err(VnidropError::repository)?; - sqlx::query("DELETE FROM targeted_payload_release_outbox WHERE transfer_id = ?1") - .bind(id) - .execute(&mut *transaction) - .await - .map_err(VnidropError::repository)?; - } - let result = sqlx::query( + let matches = sqlx::query( r#" - UPDATE targeted_transfers - SET state = ?2, - blob_ticket = CASE WHEN ?3 THEN NULL ELSE blob_ticket END, - authorization_secret_handle = CASE WHEN ?3 THEN NULL ELSE authorization_secret_handle END, - updated_at = ?4 - WHERE id = ?1 AND state != ?2 - AND ( - (?2 = 'deleted' AND state != 'deleted') - OR (?2 = 'cancelled' AND state NOT IN ('completed', 'declined', 'cancelled', 'failed', 'deleted')) - ) + SELECT EXISTS( + SELECT 1 FROM targeted_accepted_offer_intents + WHERE transfer_id = ?1 AND sender_endpoint_id = ?2 AND receiver_endpoint_id = ?3 + AND manifest_id = ?4 AND content_hash = ?5 AND transfer_name = ?6 + AND file_count = ?7 AND total_size = ?8 AND protocol_version = ?9 + ) "#, ) + .bind(&auth.transfer_id) + .bind(&auth.sender_endpoint_id) + .bind(&auth.receiver_endpoint_id) + .bind(&auth.manifest_id) + .bind(&auth.content_hash) + .bind(&auth.transfer_name) + .bind(auth.file_count as i64) + .bind(auth.total_size as i64) + .bind(auth.protocol_version as i64) + .fetch_one(&mut *transaction) + .await + .map_err(VnidropError::repository)? + .get::(0) + != 0; + if !matches { + return Err(VnidropError::permission(anyhow::anyhow!( + "authorization does not match durable receiver consent" + ))); + } + let now = now_ms(); + sqlx::query( + r#" + INSERT INTO targeted_transfers ( + id, protocol_transfer_id, sender_endpoint_id, receiver_endpoint_id, + manifest_id, content_hash, transfer_name, file_count, total_size, + verified_bytes, blob_ticket, authorization_secret_handle, role, + state, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 0, ?10, ?11, 'receiver', 'approved', ?12, ?12) + "#, + ) + .bind(&auth.transfer_id) + .bind(auth.protocol_transfer_id as i64) + .bind(&auth.sender_endpoint_id) + .bind(&auth.receiver_endpoint_id) + .bind(&auth.manifest_id) + .bind(&auth.content_hash) + .bind(&auth.transfer_name) + .bind(auth.file_count as i64) + .bind(auth.total_size as i64) + .bind(&auth.blob_ticket) + .bind(authorization_secret_handle) + .bind(now) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + sqlx::query("DELETE FROM targeted_accepted_offer_intents WHERE transfer_id = ?1") + .bind(&auth.transfer_id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + transaction + .commit() + .await + .map_err(VnidropError::repository)?; + Ok(true) + } + + pub(crate) async fn fail_resumable_and_clear_delivery( + &self, + id: &str, + ) -> Result { + let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?; + let result = sqlx::query( + "UPDATE targeted_transfers SET state = 'failed', updated_at = ?2 WHERE id = ?1 AND state IN ('approved', 'connecting', 'transferring', 'interrupted')", + ) .bind(id) - .bind(state) - .bind(clear_authorization) .bind(now_ms()) .execute(&mut *transaction) .await .map_err(VnidropError::repository)?; + if result.rows_affected() == 1 { + sqlx::query( + "DELETE FROM targeted_authorization_delivery_outbox WHERE transfer_id = ?1", + ) + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + } transaction .commit() .await @@ -590,6 +489,7 @@ impl TargetedTransferStore { FROM targeted_transfers WHERE role = 'sender' AND state IN ('approved', 'connecting', 'transferring', 'interrupted') + ORDER BY created_at, id "#, ) .fetch_all(&self.pool) @@ -598,15 +498,62 @@ impl TargetedTransferStore { rows.into_iter().map(row_to_full).collect() } + pub(crate) async fn list_resumable_rows( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT id, protocol_transfer_id, sender_endpoint_id, receiver_endpoint_id, + manifest_id, content_hash, transfer_name, file_count, total_size, + verified_bytes, blob_ticket, authorization_secret_handle, role, + state, created_at, updated_at + FROM targeted_transfers + WHERE state IN ('approved', 'connecting', 'transferring', 'interrupted') + ORDER BY created_at, id + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + rows.into_iter().map(row_to_full).collect() + } + + #[cfg(test)] + pub(crate) async fn corrupt_content_hash_for_test(&self, id: &str) -> Result<(), VnidropError> { + sqlx::query("UPDATE targeted_transfers SET content_hash = ?2 WHERE id = ?1") + .bind(id) + .bind(iroh_blobs::Hash::new([0xff; 32]).to_string()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + pub(crate) async fn cancel_by_peer( &self, peer_endpoint_id: &str, ) -> Result, VnidropError> { let now = now_ms(); + let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?; + sqlx::query( + "DELETE FROM targeted_accepted_offer_intents WHERE sender_endpoint_id = ?1 OR receiver_endpoint_id = ?1", + ) + .bind(peer_endpoint_id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + sqlx::query( + "DELETE FROM targeted_authorization_delivery_outbox WHERE transfer_id IN (SELECT id FROM targeted_transfers WHERE sender_endpoint_id = ?1 OR receiver_endpoint_id = ?1)", + ) + .bind(peer_endpoint_id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; let rows = sqlx::query( r#" UPDATE targeted_transfers - SET state = 'cancelled', updated_at = ?2 + SET state = 'cancelled', blob_ticket = NULL, + authorization_secret_handle = NULL, updated_at = ?2 WHERE (sender_endpoint_id = ?1 OR receiver_endpoint_id = ?1) AND state NOT IN ('completed', 'declined', 'cancelled', 'failed', 'deleted') RETURNING id @@ -614,9 +561,13 @@ impl TargetedTransferStore { ) .bind(peer_endpoint_id) .bind(now) - .fetch_all(&self.pool) + .fetch_all(&mut *transaction) .await .map_err(VnidropError::repository)?; + transaction + .commit() + .await + .map_err(VnidropError::repository)?; Ok(rows.into_iter().map(|row| row.get("id")).collect()) } @@ -683,6 +634,46 @@ impl TargetedTransferStore { .collect()) } + pub(crate) async fn authorizations_for_peer( + &self, + peer_endpoint_id: &str, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT id, authorization_secret_handle FROM targeted_transfers + WHERE (sender_endpoint_id = ?1 OR receiver_endpoint_id = ?1) + AND authorization_secret_handle IS NOT NULL + "#, + ) + .bind(peer_endpoint_id) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows + .into_iter() + .map(|row| (row.get(0), row.get(1))) + .collect()) + } + + pub(crate) async fn authorization_rows( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT id, protocol_transfer_id, sender_endpoint_id, receiver_endpoint_id, + manifest_id, content_hash, transfer_name, file_count, total_size, + verified_bytes, blob_ticket, authorization_secret_handle, role, + state, created_at, updated_at + FROM targeted_transfers + WHERE authorization_secret_handle IS NOT NULL + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + rows.into_iter().map(row_to_full).collect() + } + pub(crate) async fn mark_interrupted_in_flight(&self) -> Result, VnidropError> { let now = now_ms(); let rows = sqlx::query( @@ -743,7 +734,9 @@ fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result Result { +pub(super) fn row_to_full( + row: sqlx::sqlite::SqliteRow, +) -> Result { Ok(TargetedTransferRow { id: row.get("id"), protocol_transfer_id: row.get::("protocol_transfer_id") as u64, diff --git a/crates/vnidrop/src/targeted_transfer/store_outbox.rs b/crates/vnidrop/src/targeted_transfer/store_outbox.rs new file mode 100644 index 0000000..25e1cf1 --- /dev/null +++ b/crates/vnidrop/src/targeted_transfer/store_outbox.rs @@ -0,0 +1,311 @@ +//! Durable authorization, completion, release, and terminal transactions. + +use sqlx::Row; + +use super::store::{row_to_full, TargetedTransferStore}; +use super::TargetedTransferRow; +use crate::{api::TargetedTransferState, error::VnidropError, util::now_ms}; + +impl TargetedTransferStore { + pub(crate) async fn list_pending_authorization_deliveries( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT t.id, t.protocol_transfer_id, t.sender_endpoint_id, t.receiver_endpoint_id, + t.manifest_id, t.content_hash, t.transfer_name, t.file_count, t.total_size, + t.verified_bytes, t.blob_ticket, t.authorization_secret_handle, t.role, + t.state, t.created_at, t.updated_at + FROM targeted_transfers t + INNER JOIN targeted_authorization_delivery_outbox o ON o.transfer_id = t.id + WHERE t.role = 'sender' AND t.state = 'approved' AND o.next_attempt_at <= ?1 + ORDER BY o.next_attempt_at, o.created_at + "#, + ) + .bind(now_ms()) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + rows.into_iter().map(row_to_full).collect() + } + + pub(crate) async fn clear_pending_authorization_delivery( + &self, + id: &str, + ) -> Result<(), VnidropError> { + sqlx::query("DELETE FROM targeted_authorization_delivery_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn defer_pending_authorization_delivery( + &self, + id: &str, + next_attempt_at: i64, + ) -> Result<(), VnidropError> { + sqlx::query( + "UPDATE targeted_authorization_delivery_outbox SET next_attempt_at = ?2 WHERE transfer_id = ?1", + ) + .bind(id) + .bind(next_attempt_at) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn complete_receiver_and_enqueue( + &self, + id: &str, + verified_bytes: u64, + ) -> Result<(), VnidropError> { + let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?; + let result = sqlx::query( + r#" + UPDATE targeted_transfers + SET state = 'completed', verified_bytes = ?2, updated_at = ?3 + WHERE id = ?1 AND role = 'receiver' AND state = 'transferring' + "#, + ) + .bind(id) + .bind(verified_bytes as i64) + .bind(now_ms()) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + if result.rows_affected() == 0 { + return Err(VnidropError::InvalidTransition { + reason: "receiver transfer cannot be completed".to_string(), + }); + } + sqlx::query( + "INSERT OR IGNORE INTO targeted_completion_outbox (transfer_id, created_at, next_attempt_at) VALUES (?1, ?2, ?2)", + ) + .bind(id) + .bind(now_ms()) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + transaction + .commit() + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn advance_verified_bytes( + &self, + id: &str, + verified_bytes: u64, + ) -> Result { + let result = sqlx::query( + r#" + UPDATE targeted_transfers + SET verified_bytes = MIN(total_size, MAX(verified_bytes, ?2)), updated_at = ?3 + WHERE id = ?1 AND role = 'receiver' AND state = 'transferring' + AND ?2 > verified_bytes + "#, + ) + .bind(id) + .bind(verified_bytes as i64) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(result.rows_affected() == 1) + } + + pub(crate) async fn list_pending_completions( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT t.id, t.protocol_transfer_id, t.sender_endpoint_id, t.receiver_endpoint_id, + t.manifest_id, t.content_hash, t.transfer_name, t.file_count, t.total_size, + t.verified_bytes, t.blob_ticket, t.authorization_secret_handle, t.role, + t.state, t.created_at, t.updated_at + FROM targeted_transfers t + INNER JOIN targeted_completion_outbox o ON o.transfer_id = t.id + WHERE t.role = 'receiver' AND t.state = 'completed' AND o.next_attempt_at <= ?1 + ORDER BY o.next_attempt_at, o.created_at + "#, + ) + .bind(now_ms()) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + rows.into_iter().map(row_to_full).collect() + } + + pub(crate) async fn clear_pending_completion(&self, id: &str) -> Result<(), VnidropError> { + sqlx::query("DELETE FROM targeted_completion_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn defer_pending_completion( + &self, + id: &str, + next_attempt_at: i64, + ) -> Result<(), VnidropError> { + sqlx::query( + "UPDATE targeted_completion_outbox SET next_attempt_at = ?2 WHERE transfer_id = ?1", + ) + .bind(id) + .bind(next_attempt_at) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn list_completed_sender_rows( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT t.id, t.protocol_transfer_id, t.sender_endpoint_id, t.receiver_endpoint_id, + t.manifest_id, t.content_hash, t.transfer_name, t.file_count, t.total_size, + t.verified_bytes, t.blob_ticket, t.authorization_secret_handle, t.role, + t.state, t.created_at, t.updated_at + FROM targeted_transfers t + INNER JOIN targeted_payload_release_outbox o ON o.transfer_id = t.id + WHERE t.role = 'sender' AND t.state = 'completed' + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + rows.into_iter().map(row_to_full).collect() + } + + pub(crate) async fn clear_pending_payload_release(&self, id: &str) -> Result<(), VnidropError> { + sqlx::query("DELETE FROM targeted_payload_release_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn authorization_secret_handles( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + "SELECT authorization_secret_handle FROM targeted_transfers WHERE authorization_secret_handle IS NOT NULL", + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows.into_iter().map(|row| row.get(0)).collect()) + } + + pub(crate) async fn clear_authorization(&self, id: &str) -> Result<(), VnidropError> { + let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?; + sqlx::query("DELETE FROM targeted_authorization_delivery_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + sqlx::query("DELETE FROM targeted_completion_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + sqlx::query("DELETE FROM targeted_payload_release_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + sqlx::query( + r#" + UPDATE targeted_transfers + SET blob_ticket = NULL, + authorization_secret_handle = NULL + WHERE id = ?1 + "#, + ) + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + transaction + .commit() + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + /// Commit a local terminal denial and optional secret cleanup atomically. + pub(crate) async fn transition_terminal( + &self, + id: &str, + state: TargetedTransferState, + clear_authorization: bool, + ) -> Result { + let state = match state { + TargetedTransferState::Cancelled => "cancelled", + TargetedTransferState::Deleted => "deleted", + _ => { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "terminal transition requires cancelled or deleted" + ))) + } + }; + let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?; + sqlx::query("DELETE FROM targeted_accepted_offer_intents WHERE transfer_id = ?1") + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + sqlx::query("DELETE FROM targeted_authorization_delivery_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + if clear_authorization { + sqlx::query("DELETE FROM targeted_completion_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + sqlx::query("DELETE FROM targeted_payload_release_outbox WHERE transfer_id = ?1") + .bind(id) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + } + let result = sqlx::query( + r#" + UPDATE targeted_transfers + SET state = ?2, + blob_ticket = CASE WHEN ?3 THEN NULL ELSE blob_ticket END, + authorization_secret_handle = CASE WHEN ?3 THEN NULL ELSE authorization_secret_handle END, + updated_at = ?4 + WHERE id = ?1 AND state != ?2 + AND ( + (?2 = 'deleted' AND state != 'deleted') + OR (?2 = 'cancelled' AND state NOT IN ('completed', 'declined', 'cancelled', 'failed', 'deleted')) + ) + "#, + ) + .bind(id) + .bind(state) + .bind(clear_authorization) + .bind(now_ms()) + .execute(&mut *transaction) + .await + .map_err(VnidropError::repository)?; + transaction + .commit() + .await + .map_err(VnidropError::repository)?; + Ok(result.rows_affected() == 1) + } +} diff --git a/crates/vnidrop/src/tests/persistence.rs b/crates/vnidrop/src/tests/persistence.rs index c1bcdc3..60bc829 100644 --- a/crates/vnidrop/src/tests/persistence.rs +++ b/crates/vnidrop/src/tests/persistence.rs @@ -35,6 +35,9 @@ async fn open_all_returns_all_domain_stores_and_schemas() { "protected_secret_refs", "blocked_endpoints", "targeted_transfers", + "targeted_accepted_offer_intents", + "targeted_authorization_delivery_outbox", + "targeted_schema_migrations", "targeted_completion_outbox", "targeted_payload_release_outbox", "transfers", @@ -53,6 +56,68 @@ async fn open_all_returns_all_domain_stores_and_schemas() { } } +#[tokio::test] +async fn approved_sender_rows_backfill_authorization_delivery_once() { + let temp = tempfile::tempdir().unwrap(); + let stores = persistence::open_all(temp.path()).await.unwrap(); + let now = crate::util::now_ms(); + stores + .targeted + .insert(&crate::targeted_transfer::TargetedTransferRow { + id: "legacy-approved".to_string(), + protocol_transfer_id: 991, + sender_endpoint_id: "sender".to_string(), + receiver_endpoint_id: "receiver".to_string(), + manifest_id: "manifest".to_string(), + content_hash: "content".to_string(), + transfer_name: "legacy".to_string(), + file_count: 1, + total_size: 4, + verified_bytes: 0, + blob_ticket: Some("ticket".to_string()), + authorization_secret_handle: Some("handle".to_string()), + role: crate::targeted_transfer::TargetedTransferRole::Sender, + state: crate::TargetedTransferState::Approved, + created_at: now, + updated_at: now, + }) + .await + .unwrap(); + let pool = open_profile_pool(temp.path()).await; + sqlx::query( + "DELETE FROM targeted_schema_migrations WHERE name = 'authorization-delivery-outbox-v1'", + ) + .execute(&pool) + .await + .unwrap(); + drop(pool); + + drop(stores); + let stores = persistence::open_all(temp.path()).await.unwrap(); + assert_eq!( + stores + .targeted + .list_pending_authorization_deliveries() + .await + .unwrap() + .len(), + 1 + ); + stores + .targeted + .clear_pending_authorization_delivery("legacy-approved") + .await + .unwrap(); + drop(stores); + let stores = persistence::open_all(temp.path()).await.unwrap(); + assert!(stores + .targeted + .list_pending_authorization_deliveries() + .await + .unwrap() + .is_empty()); +} + #[tokio::test] async fn open_all_migrates_targeted_completion_retry_schedule() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/vnidrop/src/tests/targeted_transfer.rs b/crates/vnidrop/src/tests/targeted_transfer.rs index 2c19372..91b52dc 100644 --- a/crates/vnidrop/src/tests/targeted_transfer.rs +++ b/crates/vnidrop/src/tests/targeted_transfer.rs @@ -794,6 +794,374 @@ fn approve_one( transfer } +#[test] +fn approved_authorization_delivery_retries_after_sender_restart() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 11_000); + alice + .core() + .suppress_targeted_authorization_delivery_for_test(true); + + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("payload.txt"); + std::fs::write(&source_path, b"durable authorization").unwrap(); + let bob_id = bob.core().status().endpoint_id.clone(); + let alice_core = alice.core(); + let create = std::thread::spawn(move || { + alice_core.create_targeted_transfer( + bob_id, + vec![targeted_source(&source_path)], + Some("payload.txt".to_string()), + ) + }); + let offer = wait_for_pending_offer(&bob.core()); + let bob_core = bob.core(); + let offer_id = offer.transfer_id.clone(); + let accept = std::thread::spawn(move || bob_core.respond_to_targeted_offer(offer_id, true)); + + let transfer = create + .join() + .unwrap() + .expect("sender remains approved while delivery is pending"); + assert_eq!(transfer.state, TargetedTransferState::Approved); + assert!(bob.core().list_targeted_transfers().unwrap().is_empty()); + + let alice = alice.restart(); + let response = accept + .join() + .unwrap() + .expect("sender restart redelivers durable authorization"); + assert!(matches!( + response, + crate::TargetedOfferResponse::Approved { transfer_id } if transfer_id == transfer.id + )); + assert_eq!( + bob.core() + .get_targeted_transfer(transfer.id.clone()) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Approved + ); + assert_eq!( + alice + .core() + .get_targeted_transfer(transfer.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Approved + ); +} + +#[test] +fn accepted_intent_survives_receiver_and_sender_restart_until_delivery() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 110_000); + alice + .core() + .suppress_targeted_authorization_delivery_for_test(true); + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("payload.txt"); + std::fs::write(&source_path, b"restart consent").unwrap(); + let bob_id = bob.core().status().endpoint_id; + let alice_core = alice.core(); + let create = std::thread::spawn(move || { + alice_core.create_targeted_transfer( + bob_id, + vec![targeted_source(&source_path)], + Some("payload.txt".to_string()), + ) + }); + let offer = wait_for_pending_offer(&bob.core()); + bob.core() + .accept_targeted_offer_without_waiting_for_test(offer.transfer_id) + .unwrap(); + let transfer = create.join().unwrap().unwrap(); + let bob = bob.restart(); + let alice = alice.restart(); + let started = Instant::now(); + loop { + if bob + .core() + .get_targeted_transfer(transfer.id.clone()) + .unwrap() + .is_some_and(|row| row.state == TargetedTransferState::Approved) + { + break; + } + assert!(started.elapsed() < Duration::from_secs(15)); + std::thread::sleep(Duration::from_millis(50)); + } + assert_eq!( + alice + .core() + .get_targeted_transfer(transfer.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Approved + ); +} + +#[test] +fn restart_reconciles_targeted_authorization_orphaned_before_domain_commit() { + let node = ProtectedNode::new(); + node.core() + .create_orphaned_targeted_authorization_for_test() + .unwrap(); + assert_eq!( + node.core() + .targeted_authorization_handle_count_for_test() + .unwrap(), + 1 + ); + let node = node.restart(); + assert_eq!( + node.core() + .targeted_authorization_handle_count_for_test() + .unwrap(), + 0 + ); +} + +#[test] +fn terminal_receiver_secret_cleanup_retries_while_relationship_is_saved() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 110_005); + let transfer = approve_one(&alice, &bob, b"cleanup boundary", "payload.txt"); + assert_eq!( + bob.core() + .targeted_authorization_handle_count_for_test() + .unwrap(), + 1 + ); + bob.secret_store + .fail_with(Some(ReferenceStoreFailure::Unavailable)); + assert!(bob + .core() + .cancel_targeted_transfer(transfer.id.clone()) + .is_err()); + assert_eq!( + bob.core() + .get_targeted_transfer(transfer.id.clone()) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Cancelled + ); + bob.secret_store.fail_with(None); + let bob = bob.restart(); + assert_eq!( + bob.core() + .get_targeted_transfer(transfer.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Cancelled + ); + assert_eq!( + bob.core() + .targeted_authorization_handle_count_for_test() + .unwrap(), + 0 + ); +} + +#[test] +fn forgetting_saved_receiver_keeps_sender_denied_when_secret_delete_retries() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 110_006); + let transfer = approve_one(&alice, &bob, b"sender cleanup boundary", "payload.txt"); + assert_eq!( + alice + .core() + .targeted_authorization_handle_count_for_test() + .unwrap(), + 1 + ); + let protected_values_before_forget = alice.secret_store.stored_value_count_for_test(); + + alice + .secret_store + .fail_with(Some(ReferenceStoreFailure::Unavailable)); + assert!(alice + .core() + .forget_saved_device(bob.core().status().endpoint_id) + .is_err()); + assert_eq!( + alice + .core() + .get_targeted_transfer(transfer.id.clone()) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Cancelled + ); + assert!(alice + .core() + .targeted_blob_ticket_for_test(transfer.id.clone()) + .is_err()); + assert_eq!( + alice.secret_store.stored_value_count_for_test(), + protected_values_before_forget, + "failed secure deletion must leave retryable protected material" + ); + + alice.secret_store.fail_with(None); + let alice = alice.restart(); + assert_eq!( + alice + .core() + .get_targeted_transfer(transfer.id.clone()) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Cancelled + ); + assert!(alice + .core() + .targeted_blob_ticket_for_test(transfer.id) + .is_err()); + assert_eq!( + alice + .core() + .targeted_authorization_handle_count_for_test() + .unwrap(), + 0 + ); + assert!( + alice.secret_store.stored_value_count_for_test() < protected_values_before_forget, + "restart reconciliation must delete orphaned protected material" + ); +} + +#[test] +fn cancel_after_accepted_receiver_restart_revokes_durable_consent() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 110_003); + alice + .core() + .suppress_targeted_authorization_delivery_for_test(true); + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("payload.txt"); + std::fs::write(&source_path, b"revoked consent").unwrap(); + let bob_id = bob.core().status().endpoint_id; + let alice_core = alice.core(); + let create = std::thread::spawn(move || { + alice_core.create_targeted_transfer( + bob_id, + vec![targeted_source(&source_path)], + Some("payload.txt".to_string()), + ) + }); + let offer = wait_for_pending_offer(&bob.core()); + bob.core() + .accept_targeted_offer_without_waiting_for_test(offer.transfer_id.clone()) + .unwrap(); + let transfer = create.join().unwrap().unwrap(); + let bob = bob.restart(); + bob.core() + .cancel_targeted_transfer(offer.transfer_id) + .unwrap(); + let alice = alice.restart(); + let started = Instant::now(); + while alice + .core() + .targeted_authorization_delivery_attempts_for_test() + == 0 + { + assert!(started.elapsed() < Duration::from_secs(10)); + std::thread::sleep(Duration::from_millis(25)); + } + assert!(bob.core().list_targeted_transfers().unwrap().is_empty()); + assert_ne!( + alice + .core() + .get_targeted_transfer(transfer.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Completed + ); +} + +#[test] +fn restart_never_restores_targeted_access_for_a_persisted_block() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 110_001); + let transfer = approve_one(&alice, &bob, b"blocked after crash", "payload.txt"); + let bob_id = bob.core().status().endpoint_id; + alice + .core() + .persist_block_without_cleanup_for_test(bob_id) + .unwrap(); + + let alice = alice.restart(); + assert_eq!( + alice + .core() + .get_targeted_transfer(transfer.id.clone()) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Cancelled + ); + assert!(!alice + .core() + .targeted_payload_is_registered_for_test(transfer.id.clone()) + .unwrap()); + let output = tempfile::tempdir().unwrap(); + assert!(bob + .core() + .receive_targeted_transfer(transfer.id, output.path().to_string_lossy().into_owned(),) + .is_err()); + assert!(std::fs::read_dir(output.path()).unwrap().next().is_none()); +} + +#[test] +fn corrupt_restored_target_does_not_strand_later_valid_target() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 110_002); + let corrupt = approve_one(&alice, &bob, b"corrupt", "corrupt.txt"); + let valid = approve_one(&alice, &bob, b"valid", "valid.txt"); + alice + .core() + .corrupt_targeted_content_hash_for_test(corrupt.id.clone()) + .unwrap(); + + let alice = alice.restart(); + assert_eq!( + alice + .core() + .get_targeted_transfer(corrupt.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Failed + ); + assert_eq!( + alice + .core() + .get_targeted_transfer(valid.id.clone()) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Approved + ); + assert!(alice + .core() + .targeted_payload_is_registered_for_test(valid.id) + .unwrap()); +} + #[test] fn protocol_ops_are_idempotent_for_stable_transfer_id() { let alice = ProtectedNode::new(); @@ -823,6 +1191,33 @@ fn protocol_ops_are_idempotent_for_stable_transfer_id() { assert_eq!(listed, 1, "replay must not create duplicate durable rows"); } +#[test] +fn authorization_replay_after_receiver_commit_and_restart_is_stored() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 110_004); + let transfer = approve_one(&alice, &bob, b"lost stored response", "payload.txt"); + let bob = bob.restart(); + + assert!(alice + .core() + .redeliver_targeted_authorization_for_test(transfer.id.clone()) + .unwrap()); + let receiver = bob + .core() + .get_targeted_transfer(transfer.id) + .unwrap() + .unwrap(); + assert_eq!(receiver.state, TargetedTransferState::Approved); + assert_eq!( + bob.core() + .targeted_authorization_handle_count_for_test() + .unwrap(), + 1, + "idempotent replay must not create another protected secret" + ); +} + #[test] fn unapproved_offers_vanish_on_cancel_and_core_restart() { let alice = ProtectedNode::new(); @@ -1081,11 +1476,26 @@ fn cancel_revokes_access_and_stops_streaming() { let bob = ProtectedNode::new(); establish_saved(&alice, &bob, 11_040); let transfer = approve_one(&alice, &bob, b"cancel me", "payload.txt"); + assert_eq!( + alice + .core() + .targeted_authorization_handle_count_for_test() + .unwrap(), + 1 + ); alice .core() .cancel_targeted_transfer(transfer.id.clone()) .unwrap(); + assert_eq!( + alice + .core() + .targeted_authorization_handle_count_for_test() + .unwrap(), + 0, + "sender cancellation must clean protected authorization custody" + ); let cancelled = alice .core() .get_targeted_transfer(transfer.id.clone()) @@ -1855,6 +2265,61 @@ fn targeted_receive_rejects_a_concurrent_second_pull() { receive.join().unwrap().unwrap(); } +#[test] +fn targeted_cancel_while_waiting_for_transfer_slot_never_publishes() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + establish_saved(&alice, &bob, 11_077); + let transfer = approve_one(&alice, &bob, b"slot-starved payload", "queued.txt"); + let release_slots = bob.core().hold_all_transfer_slots_for_test(); + let output = tempfile::tempdir().unwrap(); + let output_path = output.path().to_string_lossy().into_owned(); + let bob_core = bob.core(); + let transfer_id = transfer.id.clone(); + let (finished_tx, finished_rx) = std::sync::mpsc::sync_channel(1); + let receive = std::thread::spawn(move || { + let result = bob_core.receive_targeted_transfer(transfer_id, output_path); + finished_tx.send(result).unwrap(); + }); + + let started = Instant::now(); + loop { + let state = bob + .core() + .get_targeted_transfer(transfer.id.clone()) + .unwrap() + .unwrap() + .state; + if state == TargetedTransferState::Transferring { + break; + } + assert!( + started.elapsed() < Duration::from_secs(10), + "targeted receive never queued behind transfer limiter" + ); + std::thread::sleep(Duration::from_millis(10)); + } + + bob.core() + .cancel_targeted_transfer(transfer.id.clone()) + .unwrap(); + let receive_result = finished_rx + .recv_timeout(Duration::from_secs(2)) + .expect("queued targeted receive must observe cancel before a slot is released"); + assert!(receive_result.is_err()); + let _ = release_slots.send(()); + receive.join().unwrap(); + assert!(!output.path().join("queued.txt").exists()); + assert_eq!( + bob.core() + .get_targeted_transfer(transfer.id) + .unwrap() + .unwrap() + .state, + TargetedTransferState::Cancelled + ); +} + #[test] fn targeted_cancel_aborts_each_sink_exactly_once() { for use_v2 in [false, true] { @@ -1981,7 +2446,7 @@ fn decline_returns_typed_declined_outcome() { } #[test] -fn approval_secret_failure_records_failed_receiver_snapshot_and_wakeup() { +fn approval_secret_failure_keeps_durable_consent_retryable() { let alice = ProtectedNode::new(); let bob = ProtectedNode::new(); establish_saved(&alice, &bob, 11_081); @@ -2000,24 +2465,37 @@ fn approval_secret_failure_records_failed_receiver_snapshot_and_wakeup() { let offer = wait_for_pending_offer(&bob.core()); bob.secret_store .fail_with(Some(ReferenceStoreFailure::Unavailable)); - assert!(bob + bob.core() + .accept_targeted_offer_without_waiting_for_test(offer.transfer_id.clone()) + .unwrap(); + let transfer = create.join().unwrap().unwrap(); + let started = Instant::now(); + while alice .core() - .respond_to_targeted_offer(offer.transfer_id.clone(), true) - .is_err()); - bob.secret_store.fail_with(None); - let failed = bob + .targeted_authorization_delivery_attempts_for_test() + == 0 + { + assert!(started.elapsed() < Duration::from_secs(10)); + std::thread::sleep(Duration::from_millis(25)); + } + assert!(bob .core() .get_targeted_transfer(offer.transfer_id.clone()) .unwrap() - .unwrap(); - assert_eq!(failed.state, TargetedTransferState::Failed); - assert!(bob - .core() - .list_events(None) - .unwrap() - .iter() - .any(|event| event.phase == "targeted_transfer" - && event.kind == "failed" - && event.data_json.contains(&offer.transfer_id))); - assert!(create.join().unwrap().is_err()); + .is_none()); + bob.secret_store.fail_with(None); + let started = Instant::now(); + loop { + if bob + .core() + .get_targeted_transfer(offer.transfer_id.clone()) + .unwrap() + .is_some_and(|row| row.state == TargetedTransferState::Approved) + { + break; + } + assert!(started.elapsed() < Duration::from_secs(15)); + std::thread::sleep(Duration::from_millis(50)); + } + assert_eq!(transfer.state, TargetedTransferState::Approved); } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/CoreNativeLoadTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/CoreNativeLoadTest.kt index 1df3f93..0a86b50 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/CoreNativeLoadTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/CoreNativeLoadTest.kt @@ -19,12 +19,12 @@ class CoreNativeLoadTest { override fun onEvent(event: CoreEvent) = Unit }, ) - } catch (_: VnidropException.SecureStorageUnavailable) { + } catch (error: VnidropException.SecureStorageUnavailable) { coreDir.toFile().deleteRecursively() - return - } catch (_: VnidropException.SecureStorageLocked) { + skipWhenHostCredentialStoreIsUnavailable(error) + } catch (error: VnidropException.SecureStorageLocked) { coreDir.toFile().deleteRecursively() - return + skipWhenHostCredentialStoreIsUnavailable(error) } try { diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/HostTestAssumptions.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/HostTestAssumptions.kt new file mode 100644 index 0000000..7782b8c --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/HostTestAssumptions.kt @@ -0,0 +1,8 @@ +package com.vnidrop.app + +import org.junit.Assume.assumeNoException + +internal fun skipWhenHostCredentialStoreIsUnavailable(error: Throwable): Nothing { + assumeNoException("host protected credential store is unavailable", error) + throw AssertionError("JUnit assumption unexpectedly returned", error) +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt index 1e59f8a..0c9e367 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt @@ -1,5 +1,6 @@ package com.vnidrop.app.core +import com.vnidrop.app.skipWhenHostCredentialStoreIsUnavailable import java.nio.file.Files import kotlin.io.path.createTempDirectory import kotlinx.coroutines.test.runTest @@ -35,7 +36,7 @@ class CoreRepositoryStorageTest { initializationError is VnidropException.SecureStorageUnavailable || initializationError is VnidropException.SecureStorageLocked ) { - return@runTest + skipWhenHostCredentialStoreIsUnavailable(initializationError) } initialized.getOrThrow() val endpointId = repository.state.value.status?.endpointId diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractJvmSmokeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractJvmSmokeTest.kt index 3d445f2..f5938dd 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractJvmSmokeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractJvmSmokeTest.kt @@ -1,5 +1,6 @@ package com.vnidrop.app.core +import com.vnidrop.app.skipWhenHostCredentialStoreIsUnavailable import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals @@ -41,12 +42,12 @@ class SavedDeviceCoreContractJvmSmokeTest { sink, network, ) - } catch (_: VnidropException.SecureStorageUnavailable) { + } catch (error: VnidropException.SecureStorageUnavailable) { coreDir.toFile().deleteRecursively() - return - } catch (_: VnidropException.SecureStorageLocked) { + skipWhenHostCredentialStoreIsUnavailable(error) + } catch (error: VnidropException.SecureStorageLocked) { coreDir.toFile().deleteRecursively() - return + skipWhenHostCredentialStoreIsUnavailable(error) } val endpointId = try { val id = first.status().endpointId @@ -94,14 +95,14 @@ class SavedDeviceCoreContractJvmSmokeTest { defaultCoreLimits(), network, ) - } catch (_: VnidropException.SecureStorageUnavailable) { + } catch (error: VnidropException.SecureStorageUnavailable) { // Desktop hosts may lack a usable credential store; Android Keystore // restart is covered by crates/vnidrop platform_contract_android. coreDir.toFile().deleteRecursively() - return - } catch (_: VnidropException.SecureStorageLocked) { + skipWhenHostCredentialStoreIsUnavailable(error) + } catch (error: VnidropException.SecureStorageLocked) { coreDir.toFile().deleteRecursively() - return + skipWhenHostCredentialStoreIsUnavailable(error) } try {