mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-13 05:49:57 +02:00
test(core): add saved devices production release gate
This commit is contained in:
@@ -36,6 +36,35 @@ bytes through Kotlin memory.
|
||||
5. Only `vnd1:` VniDrop tickets are accepted. Raw iroh `BlobTicket` strings are
|
||||
rejected at parse time so receive always runs the approval handshake.
|
||||
|
||||
## Saved Devices And Targeted Transfers
|
||||
|
||||
1. A completed authenticated invitation transfer creates a short-lived pairing
|
||||
eligibility. Both devices must explicitly consent before either relationship
|
||||
becomes `Saved`; decline, forget, block, expiry, or replay cannot save it.
|
||||
2. A saved device's remote display name comes from authenticated transfer
|
||||
metadata and may refresh after later authenticated transfers. A local label
|
||||
is private to this installation, takes display precedence in the UI, and is
|
||||
preserved independently across restart and schema migration.
|
||||
3. `create_targeted_transfer` imports an immutable manifest and sends only a
|
||||
receiver-bound offer. Approval stores protected authorization in core custody;
|
||||
targeted work creates no invitation history, receiver approval request,
|
||||
invitation delivery receipt, received-artifact row, or pairing eligibility.
|
||||
4. Targeted blobs are default-deny and scoped to the intended saved endpoint.
|
||||
Knowing a transfer id, manifest hash, member hash, or blob address does not
|
||||
authorize another device to discover, approve, or download the payload.
|
||||
5. Approved receives use the same streaming, no-overwrite paths, and output-sink
|
||||
terminal-callback guarantees as invitation receives. Verified progress is
|
||||
monotonic and bounded; interrupted work and protected authorization resume
|
||||
after restart without another approval.
|
||||
6. Receiver completion is durable locally and acknowledged to the sender with
|
||||
idempotent retry. Targeted cancel and delete synchronously signal local active
|
||||
work before durable cleanup; forget and block revoke affected relationships
|
||||
and active targeted work within their core operation. All four durably deny
|
||||
reuse and perform idempotent payload/secret cleanup.
|
||||
7. Saved devices, relationships, pairing eligibility, and targeted transfers are
|
||||
shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and
|
||||
their existing experimental preference gates is outside this release gate.
|
||||
|
||||
## Core States And Events
|
||||
|
||||
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
|
||||
|
||||
@@ -623,7 +623,9 @@ impl VnidropCore {
|
||||
///
|
||||
/// Blocks until the saved receiver approves or declines. On approval the
|
||||
/// receiver stores bound authorization locally via
|
||||
/// [`Self::respond_to_targeted_offer`].
|
||||
/// [`Self::respond_to_targeted_offer`]. This path creates no invitation
|
||||
/// transfer, receiver approval request, invitation delivery receipt,
|
||||
/// received-artifact record, or pairing eligibility.
|
||||
pub fn create_targeted_transfer(
|
||||
&self,
|
||||
receiver_endpoint_id: String,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::{
|
||||
path::Path,
|
||||
str::FromStr,
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc, Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
@@ -28,11 +31,15 @@ struct EventGateSink {
|
||||
kind: &'static str,
|
||||
observed: mpsc::SyncSender<CoreEvent>,
|
||||
release: Mutex<mpsc::Receiver<()>>,
|
||||
triggered: AtomicBool,
|
||||
}
|
||||
|
||||
impl CoreEventSink for EventGateSink {
|
||||
fn on_event(&self, event: CoreEvent) {
|
||||
if event.phase == "targeted_transfer" && event.kind == self.kind {
|
||||
if event.phase == "targeted_transfer"
|
||||
&& event.kind == self.kind
|
||||
&& !self.triggered.swap(true, Ordering::SeqCst)
|
||||
{
|
||||
self.observed.send(event).unwrap();
|
||||
self.release.lock().unwrap().recv().unwrap();
|
||||
}
|
||||
@@ -284,6 +291,7 @@ fn accepted_event_is_emitted_only_after_receiver_snapshot_is_durable() {
|
||||
kind: "approved",
|
||||
observed: observed_tx,
|
||||
release: Mutex::new(release_rx),
|
||||
triggered: AtomicBool::new(false),
|
||||
}));
|
||||
establish_saved(&sender, &receiver, 21_002);
|
||||
|
||||
@@ -344,6 +352,10 @@ fn terminal_events_have_ordered_revisions_and_no_later_progress() {
|
||||
assert!(events
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].revision < pair[1].revision));
|
||||
assert!(
|
||||
events.iter().any(|event| event.kind == "progress"),
|
||||
"sizable receive must emit a durable-state progress wake-up"
|
||||
);
|
||||
let completed = events
|
||||
.iter()
|
||||
.position(|event| event.kind == "completed")
|
||||
@@ -351,15 +363,62 @@ fn terminal_events_have_ordered_revisions_and_no_later_progress() {
|
||||
assert!(events[completed + 1..]
|
||||
.iter()
|
||||
.all(|event| event.kind != "progress"));
|
||||
assert_eq!(
|
||||
receiver
|
||||
.core()
|
||||
.get_targeted_transfer(transfer.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.state,
|
||||
TargetedTransferState::Completed
|
||||
let completed_snapshot = receiver
|
||||
.core()
|
||||
.get_targeted_transfer(transfer.id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(completed_snapshot.state, TargetedTransferState::Completed);
|
||||
assert_eq!(completed_snapshot.verified_bytes, transfer.total_size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_wakeup_exposes_monotonic_bounded_durable_snapshot() {
|
||||
let (observed_tx, observed_rx) = mpsc::sync_channel(1);
|
||||
let (release_tx, release_rx) = mpsc::sync_channel(1);
|
||||
let sender = ProtectedNode::new();
|
||||
let receiver = ProtectedNode::with_sink(Arc::new(EventGateSink {
|
||||
kind: "progress",
|
||||
observed: observed_tx,
|
||||
release: Mutex::new(release_rx),
|
||||
triggered: AtomicBool::new(false),
|
||||
}));
|
||||
establish_saved(&sender, &receiver, 21_008);
|
||||
let transfer = approve(
|
||||
&sender,
|
||||
&receiver,
|
||||
"progress.bin",
|
||||
&vec![9; 2 * 1024 * 1024],
|
||||
);
|
||||
let output = tempfile::tempdir().unwrap();
|
||||
let receiver_core = receiver.core();
|
||||
let transfer_id = transfer.id.clone();
|
||||
let output_path = output.path().to_string_lossy().into_owned();
|
||||
let receive = std::thread::spawn(move || {
|
||||
receiver_core.receive_targeted_transfer(transfer_id, output_path)
|
||||
});
|
||||
|
||||
observed_rx
|
||||
.recv_timeout(Duration::from_secs(20))
|
||||
.expect("progress wake-up");
|
||||
let progress = receiver
|
||||
.core()
|
||||
.get_targeted_transfer(transfer.id.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(progress.verified_bytes > 0);
|
||||
assert!(progress.verified_bytes <= progress.total_size);
|
||||
release_tx.send(()).unwrap();
|
||||
receive.join().unwrap().unwrap();
|
||||
|
||||
let completed = receiver
|
||||
.core()
|
||||
.get_targeted_transfer(transfer.id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(completed.state, TargetedTransferState::Completed);
|
||||
assert_eq!(completed.verified_bytes, completed.total_size);
|
||||
assert!(completed.verified_bytes >= progress.verified_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -416,9 +416,37 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
|
||||
establish_saved(&alice, &bob, 10_020);
|
||||
let alice_invitation_count = alice.core().list_transfers().unwrap().len();
|
||||
let bob_invitation_count = bob.core().list_transfers().unwrap().len();
|
||||
let alice_invitation_requests = alice
|
||||
.core()
|
||||
.list_receiver_requests(10_020)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|request| (request.id, request.status, request.completed_at))
|
||||
.collect::<Vec<_>>();
|
||||
let bob_invitation_requests = bob
|
||||
.core()
|
||||
.list_receiver_requests(10_020)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|request| (request.id, request.status, request.completed_at))
|
||||
.collect::<Vec<_>>();
|
||||
let alice_eligibility_count = alice.core().list_pairing_eligibilities().unwrap().len();
|
||||
let bob_eligibility_count = bob.core().list_pairing_eligibilities().unwrap().len();
|
||||
let bob_artifacts_before = bob.core().list_received_artifacts().unwrap();
|
||||
let alice_delivery_events = alice
|
||||
.core()
|
||||
.list_events(None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|event| event.phase == "delivery")
|
||||
.count();
|
||||
let bob_delivery_events = bob
|
||||
.core()
|
||||
.list_events(None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|event| event.phase == "delivery")
|
||||
.count();
|
||||
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("payload.txt");
|
||||
@@ -499,6 +527,22 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
|
||||
std::fs::read(output.path().join("payload.txt")).unwrap(),
|
||||
payload
|
||||
);
|
||||
let completion_started = Instant::now();
|
||||
loop {
|
||||
if alice
|
||||
.core()
|
||||
.get_targeted_transfer(transfer_id.clone())
|
||||
.unwrap()
|
||||
.is_some_and(|row| row.state == TargetedTransferState::Completed)
|
||||
{
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
completion_started.elapsed() < Duration::from_secs(10),
|
||||
"sender never durably acknowledged targeted completion"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
assert_eq!(
|
||||
alice.core().list_transfers().unwrap().len(),
|
||||
alice_invitation_count
|
||||
@@ -516,8 +560,50 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
|
||||
bob_eligibility_count
|
||||
);
|
||||
assert_eq!(
|
||||
bob.core().list_received_artifacts().unwrap().len(),
|
||||
bob_artifacts_before.len()
|
||||
alice
|
||||
.core()
|
||||
.list_receiver_requests(10_020)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|request| (request.id, request.status, request.completed_at))
|
||||
.collect::<Vec<_>>(),
|
||||
alice_invitation_requests,
|
||||
"targeted receive must not create sender invitation approval requests"
|
||||
);
|
||||
assert_eq!(
|
||||
bob.core()
|
||||
.list_receiver_requests(10_020)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|request| (request.id, request.status, request.completed_at))
|
||||
.collect::<Vec<_>>(),
|
||||
bob_invitation_requests,
|
||||
"targeted receive must not create receiver invitation requests"
|
||||
);
|
||||
assert_eq!(
|
||||
bob.core().list_received_artifacts().unwrap(),
|
||||
bob_artifacts_before
|
||||
);
|
||||
assert_eq!(
|
||||
alice
|
||||
.core()
|
||||
.list_events(None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|event| event.phase == "delivery")
|
||||
.count(),
|
||||
alice_delivery_events,
|
||||
"targeted completion must not emit invitation delivery receipts"
|
||||
);
|
||||
assert_eq!(
|
||||
bob.core()
|
||||
.list_events(None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|event| event.phase == "delivery")
|
||||
.count(),
|
||||
bob_delivery_events,
|
||||
"targeted receive must not emit invitation delivery events"
|
||||
);
|
||||
|
||||
let charlie_output = tempfile::tempdir().unwrap();
|
||||
@@ -531,6 +617,49 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_endpoint_cannot_discover_or_approve_another_receivers_offer() {
|
||||
let alice = ProtectedNode::new();
|
||||
let bob = ProtectedNode::new();
|
||||
let charlie = ProtectedNode::new();
|
||||
let bob_id = bob.core().status().endpoint_id.clone();
|
||||
establish_saved(&alice, &bob, 10_024);
|
||||
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("private.txt");
|
||||
std::fs::write(&source_path, b"only Bob may approve").unwrap();
|
||||
let alice_core = alice.core().clone();
|
||||
let create = std::thread::spawn(move || {
|
||||
alice_core.create_targeted_transfer(
|
||||
bob_id,
|
||||
vec![targeted_source(&source_path)],
|
||||
Some("private.txt".to_string()),
|
||||
)
|
||||
});
|
||||
let offer = wait_for_pending_offer(&bob.core());
|
||||
|
||||
assert!(charlie.core().list_pending_targeted_offers().is_empty());
|
||||
assert!(charlie.core().list_targeted_transfers().unwrap().is_empty());
|
||||
let forged = charlie
|
||||
.core()
|
||||
.respond_to_targeted_offer(offer.transfer_id.clone(), true);
|
||||
assert!(
|
||||
forged.is_err(),
|
||||
"an unrelated endpoint must not approve Bob's offer"
|
||||
);
|
||||
assert!(charlie.core().list_pending_targeted_offers().is_empty());
|
||||
assert!(charlie.core().list_targeted_transfers().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
bob.core().list_pending_targeted_offers(),
|
||||
vec![offer.clone()]
|
||||
);
|
||||
|
||||
bob.core()
|
||||
.respond_to_targeted_offer(offer.transfer_id, false)
|
||||
.unwrap();
|
||||
assert!(create.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_endpoint_cannot_fetch_a_leaked_targeted_blob_ticket() {
|
||||
let alice = ProtectedNode::new();
|
||||
|
||||
@@ -137,6 +137,61 @@ fn approval_required_denies_then_allows_receiver() {
|
||||
.any(|request| request.status == "completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_share_authorizes_multiple_receivers_independently() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let first_output = tempfile::tempdir().unwrap();
|
||||
let second_denied_output = tempfile::tempdir().unwrap();
|
||||
let second_allowed_output = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("private-multi.txt");
|
||||
std::fs::write(&source_path, b"independent receiver approval").unwrap();
|
||||
let sender = TestNode::new();
|
||||
let first_receiver = TestNode::new();
|
||||
let second_receiver = TestNode::new();
|
||||
let share = share_path(&sender.core, &source_path, 31, "private-multi.txt", false);
|
||||
|
||||
receive_with_response(
|
||||
&sender.core,
|
||||
share.transfer_id,
|
||||
first_receiver.core.arc(),
|
||||
share.ticket.clone(),
|
||||
first_output.path(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(receive_with_response(
|
||||
&sender.core,
|
||||
share.transfer_id,
|
||||
second_receiver.core.arc(),
|
||||
share.ticket.clone(),
|
||||
second_denied_output.path(),
|
||||
false,
|
||||
)
|
||||
.is_err());
|
||||
receive_with_response(
|
||||
&sender.core,
|
||||
share.transfer_id,
|
||||
second_receiver.core.arc(),
|
||||
share.ticket,
|
||||
second_allowed_output.path(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(first_output.path().join("private-multi.txt")).unwrap(),
|
||||
b"independent receiver approval"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(second_allowed_output.path().join("private-multi.txt")).unwrap(),
|
||||
b"independent receiver approval"
|
||||
);
|
||||
assert!(!second_denied_output
|
||||
.path()
|
||||
.join("private-multi.txt")
|
||||
.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_can_cancel_while_waiting_for_approval() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user