diff --git a/.github/workflows/rust-core.yml b/.github/workflows/rust-core.yml index 90dda17..dd7cd52 100644 --- a/.github/workflows/rust-core.yml +++ b/.github/workflows/rust-core.yml @@ -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 diff --git a/Makefile b/Makefile index 1cc1502..2929f8d 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 1f8be31..2f3ca39 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -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`, diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index e121bb5..472fe67 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -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, diff --git a/crates/vnidrop/src/tests/targeted_lifecycle.rs b/crates/vnidrop/src/tests/targeted_lifecycle.rs index f2358fa..aba4a52 100644 --- a/crates/vnidrop/src/tests/targeted_lifecycle.rs +++ b/crates/vnidrop/src/tests/targeted_lifecycle.rs @@ -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, release: Mutex>, + 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] diff --git a/crates/vnidrop/src/tests/targeted_transfer.rs b/crates/vnidrop/src/tests/targeted_transfer.rs index b887a67..2c19372 100644 --- a/crates/vnidrop/src/tests/targeted_transfer.rs +++ b/crates/vnidrop/src/tests/targeted_transfer.rs @@ -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::>(); + let bob_invitation_requests = bob + .core() + .list_receiver_requests(10_020) + .unwrap() + .into_iter() + .map(|request| (request.id, request.status, request.completed_at)) + .collect::>(); 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::>(), + 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::>(), + 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(); diff --git a/crates/vnidrop/tests/approval.rs b/crates/vnidrop/tests/approval.rs index 726fa4d..d21e08a 100644 --- a/crates/vnidrop/tests/approval.rs +++ b/crates/vnidrop/tests/approval.rs @@ -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();