test(core): add saved devices production release gate

This commit is contained in:
2026-08-12 17:19:03 +02:00
parent 2c941dc623
commit 4f09e474c5
7 changed files with 295 additions and 14 deletions

View File

@@ -45,6 +45,8 @@ jobs:
run: rustup component add clippy rustfmt
- name: Check Rust core
run: make check-rust
- name: Saved devices production release gate
run: make test-rust-saved-devices
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: Audit Rust dependencies

View File

@@ -10,7 +10,7 @@ include $(ROOT)/make/release.mk
.PHONY: help doctor setup setup-localization setup-docs setup-diagnostics
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink test-rust-saved-devices
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
.PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate
@@ -108,6 +108,11 @@ test-rust-lifecycle: ## Run Rust lifecycle integration tests.
test-rust-output-sink: ## Run Rust output-sink integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test output_sink
test-rust-saved-devices: ## Run the Saved devices production-core release gate.
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --lib
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test saved_device_domain
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test transfer --test approval --test lifecycle --test output_sink
check-shared: ## Test and compile the shared Android/JVM module.
cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS)

View File

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

View File

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

View File

@@ -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
let completed_snapshot = receiver
.core()
.get_targeted_transfer(transfer.id)
.unwrap()
.unwrap()
.state,
TargetedTransferState::Completed
.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]

View File

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

View File

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