fix(core): harden targeted transfer recovery

This commit is contained in:
2026-08-12 18:48:13 +02:00
parent 4f09e474c5
commit 2ac9166b34
25 changed files with 3164 additions and 1509 deletions

View File

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

View File

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

View File

@@ -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<usize, VnidropError> {
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<bool, VnidropError> {
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<crate::api::PendingTargetedOffer> {
self.block_on(self.inner.list_pending_targeted_offers())
}

View File

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

View File

@@ -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<Option<JoinHandle<()>>>,
pub(super) delivery_receipt_notify: Notify,
pub(super) delivery_receipt_task: TokioMutex<Option<JoinHandle<()>>>,
pub(super) targeted_completion_task: TokioMutex<Option<JoinHandle<()>>>,
pub(super) targeted_reconciliation_task: TokioMutex<Option<JoinHandle<()>>>,
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<Vec<String>>,
#[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::<u64, ActiveTransfer>::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");
}

View File

@@ -182,196 +182,10 @@ impl CoreInner {
.await
}
pub(super) async fn receive_targeted_payload(
self: &Arc<Self>,
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<Self>,
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,

File diff suppressed because it is too large Load Diff

View File

@@ -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<Self>,
receiver_endpoint_id: String,
sources: Vec<ShareSource>,
transfer_name: Option<String>,
) -> Result<TargetedTransfer, VnidropError> {
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")))
}
}

View File

@@ -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<Self>,
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(())
}
}

View File

@@ -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<Self>,
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<Self>,
transfer_id: String,
output_sink: Arc<dyn crate::ReceiveOutputSink>,
) -> Result<(), VnidropError> {
self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSink(output_sink))
.await
}
pub(super) async fn receive_targeted_transfer_with_output_sink_v2(
self: &Arc<Self>,
transfer_id: String,
output_sink: Arc<dyn crate::ReceiveOutputSinkV2>,
) -> Result<(), VnidropError> {
self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSinkV2(output_sink))
.await
}
pub(super) async fn resume_targeted_transfer(
self: &Arc<Self>,
id: String,
output_dir: String,
) -> Result<(), VnidropError> {
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<Self>,
id: String,
output_sink: Arc<dyn crate::ReceiveOutputSink>,
) -> Result<(), VnidropError> {
self.resume_targeted_to_target(id, ReceiveTarget::OutputSink(output_sink))
.await
}
pub(super) async fn resume_targeted_transfer_with_output_sink_v2(
self: &Arc<Self>,
id: String,
output_sink: Arc<dyn crate::ReceiveOutputSinkV2>,
) -> Result<(), VnidropError> {
self.resume_targeted_to_target(id, ReceiveTarget::OutputSinkV2(output_sink))
.await
}
async fn receive_targeted_to_target(
self: &Arc<Self>,
transfer_id: String,
target: ReceiveTarget,
) -> Result<(), VnidropError> {
let auth = self.load_receiver_authorization(&transfer_id).await?;
self.run_targeted_receive(&auth, target).await
}
async fn resume_targeted_to_target(
self: &Arc<Self>,
id: String,
target: ReceiveTarget,
) -> Result<(), VnidropError> {
let store = self.targeted_store();
let row = store.get_row(&id).await?.ok_or_else(|| {
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<TargetedAuthorization, VnidropError> {
let store = self.targeted_store();
let row = store.get_row(transfer_id).await?.ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!("unknown targeted transfer"))
})?;
let encoded = self.load_stored_authorization(&row).await?.ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!(
"targeted transfer has no durable authorization"
))
})?;
let auth = TargetedAuthorization::decode(&encoded)?;
auth.verify_for_receiver(&self.endpoint.id().to_string())?;
Ok(auth)
}
async fn run_targeted_receive(
self: &Arc<Self>,
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<Self>,
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<Option<String>, 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()?))
}
}

View File

@@ -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<Self>) {
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<bool, VnidropError> {
#[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")
}
}
}
}
}

View File

@@ -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<SecretMaterial, VnidropError> {
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<String>,
) -> Result<u64, VnidropError> {
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

View File

@@ -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<PendingTargetedOffer> {
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<Option<String>, 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)
}
}

View File

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

View File

@@ -43,10 +43,15 @@ pub(crate) struct TargetedTransferProtocol {
access_policy: std::sync::Arc<crate::access_policy::AccessPolicy>,
cleanup:
std::sync::Arc<dyn Fn(super::TargetedTransferRow) -> TargetedCleanupFuture + Send + Sync>,
persist_authorization: std::sync::Arc<
dyn Fn(TargetedAuthorization) -> TargetedAuthorizationPersistFuture + Send + Sync,
>,
}
pub(crate) type TargetedCleanupFuture =
Pin<Box<dyn Future<Output = Result<(), VnidropError>> + Send>>;
pub(crate) type TargetedAuthorizationPersistFuture =
Pin<Box<dyn Future<Output = Result<bool, VnidropError>> + 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::<iroh_blobs::ticket::BlobTicket>() 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
}

View File

@@ -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::<String, _>(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::<String, _>(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::<i64, _>(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(())
}

View File

@@ -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::<String, _>(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::<String, _>(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<Vec<(String, String)>, 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::<i64, _>(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<bool, VnidropError> {
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<Vec<TargetedTransferRow>, 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<Vec<TargetedTransferRow>, 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<bool, VnidropError> {
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::<i64, _>(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<bool, VnidropError> {
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<Vec<TargetedTransferRow>, 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<Vec<String>, 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<Vec<(String, String)>, 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<Vec<TargetedTransferRow>, 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<Vec<String>, VnidropError> {
let now = now_ms();
let rows = sqlx::query(
@@ -743,7 +734,9 @@ fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<TargetedTransfer, Vni
})
}
fn row_to_full(row: sqlx::sqlite::SqliteRow) -> Result<TargetedTransferRow, VnidropError> {
pub(super) fn row_to_full(
row: sqlx::sqlite::SqliteRow,
) -> Result<TargetedTransferRow, VnidropError> {
Ok(TargetedTransferRow {
id: row.get("id"),
protocol_transfer_id: row.get::<i64, _>("protocol_transfer_id") as u64,

View File

@@ -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<Vec<TargetedTransferRow>, 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<bool, VnidropError> {
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<Vec<TargetedTransferRow>, 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<Vec<TargetedTransferRow>, 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<std::collections::HashSet<String>, 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<bool, VnidropError> {
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)
}
}

View File

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

View File

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