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 run: rustup component add clippy rustfmt
- name: Check Rust core - name: Check Rust core
run: make check-rust run: make check-rust
- name: Saved devices production release gate
run: make test-rust-saved-devices
- name: Install cargo-audit - name: Install cargo-audit
run: cargo install cargo-audit --locked run: cargo install cargo-audit --locked
- name: Audit Rust dependencies - 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: help doctor setup setup-localization setup-docs setup-diagnostics
.PHONY: format test check check-rust audit-rust test-rust test-rust-all .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: 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: 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 .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. test-rust-output-sink: ## Run Rust output-sink integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test output_sink 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. check-shared: ## Test and compile the shared Android/JVM module.
cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS) 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 5. Only `vnd1:` VniDrop tickets are accepted. Raw iroh `BlobTicket` strings are
rejected at parse time so receive always runs the approval handshake. 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 ## Core States And Events
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`, - 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 /// Blocks until the saved receiver approves or declines. On approval the
/// receiver stores bound authorization locally via /// 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( pub fn create_targeted_transfer(
&self, &self,
receiver_endpoint_id: String, receiver_endpoint_id: String,

View File

@@ -1,7 +1,10 @@
use std::{ use std::{
path::Path, path::Path,
str::FromStr, str::FromStr,
sync::{mpsc, Arc, Mutex}, sync::{
atomic::{AtomicBool, Ordering},
mpsc, Arc, Mutex,
},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
@@ -28,11 +31,15 @@ struct EventGateSink {
kind: &'static str, kind: &'static str,
observed: mpsc::SyncSender<CoreEvent>, observed: mpsc::SyncSender<CoreEvent>,
release: Mutex<mpsc::Receiver<()>>, release: Mutex<mpsc::Receiver<()>>,
triggered: AtomicBool,
} }
impl CoreEventSink for EventGateSink { impl CoreEventSink for EventGateSink {
fn on_event(&self, event: CoreEvent) { 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.observed.send(event).unwrap();
self.release.lock().unwrap().recv().unwrap(); self.release.lock().unwrap().recv().unwrap();
} }
@@ -284,6 +291,7 @@ fn accepted_event_is_emitted_only_after_receiver_snapshot_is_durable() {
kind: "approved", kind: "approved",
observed: observed_tx, observed: observed_tx,
release: Mutex::new(release_rx), release: Mutex::new(release_rx),
triggered: AtomicBool::new(false),
})); }));
establish_saved(&sender, &receiver, 21_002); establish_saved(&sender, &receiver, 21_002);
@@ -344,6 +352,10 @@ fn terminal_events_have_ordered_revisions_and_no_later_progress() {
assert!(events assert!(events
.windows(2) .windows(2)
.all(|pair| pair[0].revision < pair[1].revision)); .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 let completed = events
.iter() .iter()
.position(|event| event.kind == "completed") .position(|event| event.kind == "completed")
@@ -351,15 +363,62 @@ fn terminal_events_have_ordered_revisions_and_no_later_progress() {
assert!(events[completed + 1..] assert!(events[completed + 1..]
.iter() .iter()
.all(|event| event.kind != "progress")); .all(|event| event.kind != "progress"));
assert_eq!( let completed_snapshot = receiver
receiver .core()
.core() .get_targeted_transfer(transfer.id)
.get_targeted_transfer(transfer.id) .unwrap()
.unwrap() .unwrap();
.unwrap() assert_eq!(completed_snapshot.state, TargetedTransferState::Completed);
.state, assert_eq!(completed_snapshot.verified_bytes, transfer.total_size);
TargetedTransferState::Completed }
#[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] #[test]

View File

@@ -416,9 +416,37 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
establish_saved(&alice, &bob, 10_020); establish_saved(&alice, &bob, 10_020);
let alice_invitation_count = alice.core().list_transfers().unwrap().len(); let alice_invitation_count = alice.core().list_transfers().unwrap().len();
let bob_invitation_count = bob.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 alice_eligibility_count = alice.core().list_pairing_eligibilities().unwrap().len();
let bob_eligibility_count = bob.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 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_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("payload.txt"); 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(), std::fs::read(output.path().join("payload.txt")).unwrap(),
payload 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!( assert_eq!(
alice.core().list_transfers().unwrap().len(), alice.core().list_transfers().unwrap().len(),
alice_invitation_count alice_invitation_count
@@ -516,8 +560,50 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
bob_eligibility_count bob_eligibility_count
); );
assert_eq!( assert_eq!(
bob.core().list_received_artifacts().unwrap().len(), alice
bob_artifacts_before.len() .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(); 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] #[test]
fn unrelated_endpoint_cannot_fetch_a_leaked_targeted_blob_ticket() { fn unrelated_endpoint_cannot_fetch_a_leaked_targeted_blob_ticket() {
let alice = ProtectedNode::new(); let alice = ProtectedNode::new();

View File

@@ -137,6 +137,61 @@ fn approval_required_denies_then_allows_receiver() {
.any(|request| request.status == "completed")); .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] #[test]
fn receiver_can_cancel_while_waiting_for_approval() { fn receiver_can_cancel_while_waiting_for_approval() {
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();