diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index f59f099..b847338 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -10,6 +10,82 @@ use crate::util::{non_empty, now_ms}; pub(crate) const MAX_CUSTOM_RELAYS: usize = 8; pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct ExperimentalSavedDeviceCapabilities { + pub api_version: u16, + pub relationship_protocol_version: u16, + pub targeted_transfer_protocol_version: u16, +} + +#[uniffi::export] +pub fn experimental_saved_device_capabilities() -> ExperimentalSavedDeviceCapabilities { + ExperimentalSavedDeviceCapabilities { + api_version: 1, + relationship_protocol_version: 1, + targeted_transfer_protocol_version: 1, + } +} + +/// A remote VniDrop app-installation identity that completed mutual consent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct SavedDevice { + pub endpoint_id: String, + pub local_label: Option, + pub remote_display_name: Option, + pub created_at: i64, + pub last_authenticated_at: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum DeviceRelationshipState { + PendingOutgoing, + PendingIncoming, + Saved, + Revoked, + Blocked, +} + +/// Public relationship state; directional grant material remains core-private. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct DeviceRelationship { + pub remote_endpoint_id: String, + pub state: DeviceRelationshipState, + pub generation: u64, + pub minimum_protocol_version: u16, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum TargetedTransferState { + Preparing, + Offering, + AwaitingApproval, + Approved, + Connecting, + Transferring, + Interrupted, + Completed, + Declined, + Cancelled, + Failed, + Deleted, +} + +/// Immutable recipient-bound transfer snapshot, separate from an ordinary share. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct TargetedTransfer { + pub id: String, + pub sender_endpoint_id: String, + pub receiver_endpoint_id: String, + pub manifest_id: String, + pub file_count: u64, + pub total_size: u64, + pub state: TargetedTransferState, + pub created_at: i64, + pub updated_at: i64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum CoreRelayMode { Automatic, diff --git a/crates/vnidrop/src/error.rs b/crates/vnidrop/src/error.rs index 2ade7b9..a7669fd 100644 --- a/crates/vnidrop/src/error.rs +++ b/crates/vnidrop/src/error.rs @@ -26,6 +26,8 @@ pub enum VnidropError { Cancelled { reason: String }, #[error("invalid input: {reason}")] InvalidInput { reason: String }, + #[error("invalid targeted transfer transition: {reason}")] + InvalidTransition { reason: String }, #[error("internal error: {reason}")] Internal { reason: String }, } @@ -93,6 +95,7 @@ impl VnidropError { Self::Repository { .. } => "repository", Self::Cancelled { .. } => "cancelled", Self::InvalidInput { .. } => "invalid_input", + Self::InvalidTransition { .. } => "invalid_transition", Self::Internal { .. } => "internal", } } @@ -111,6 +114,7 @@ impl VnidropError { | Self::Repository { reason } | Self::Cancelled { reason } | Self::InvalidInput { reason } + | Self::InvalidTransition { reason } | Self::Internal { reason } => reason, } } @@ -163,6 +167,7 @@ impl VnidropError { Self::Repository { .. } => Self::Repository { reason }, Self::Cancelled { .. } => Self::Cancelled { reason }, Self::InvalidInput { .. } => Self::InvalidInput { reason }, + Self::InvalidTransition { .. } => Self::InvalidTransition { reason }, Self::Internal { .. } => Self::Internal { reason }, } } diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 92f7779..6c54eff 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -14,17 +14,20 @@ mod pairing; mod repository; mod runtime; mod secret; +mod targeted_transfer; mod ticket; mod transfer_state; mod util; pub use api::{ clear_inactive_transfer_cache, default_core_limits, default_core_network_config, - ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, - CoreRelayMode, CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, - PendingPairing, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, - ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, - ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, + experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent, + CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, + DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, + GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing, PublishedOutput, + ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, + RuntimeStatus, SavedDevice, ShareMetadataInput, ShareResult, ShareSource, SourceKind, + StoredTransfer, TargetedTransfer, TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; diff --git a/crates/vnidrop/src/targeted_transfer.rs b/crates/vnidrop/src/targeted_transfer.rs new file mode 100644 index 0000000..3abb3ff --- /dev/null +++ b/crates/vnidrop/src/targeted_transfer.rs @@ -0,0 +1,59 @@ +use crate::{api::TargetedTransferState, error::VnidropError}; + +impl TargetedTransferState { + /// Validates a durable state change without exposing foreign state mutation. + pub fn validate_transition_to(self, next: Self) -> Result<(), VnidropError> { + let allowed = matches!( + (self, next), + ( + Self::Preparing, + Self::Offering | Self::Cancelled | Self::Failed + ) | ( + Self::Offering, + Self::AwaitingApproval | Self::Cancelled | Self::Failed + ) | ( + Self::AwaitingApproval, + Self::Approved | Self::Declined | Self::Cancelled | Self::Failed + ) | ( + Self::Approved, + Self::Connecting | Self::Cancelled | Self::Failed + ) | ( + Self::Connecting, + Self::Transferring | Self::Interrupted | Self::Cancelled | Self::Failed + ) | ( + Self::Transferring, + Self::Completed | Self::Interrupted | Self::Cancelled | Self::Failed + ) | ( + Self::Interrupted, + Self::Connecting | Self::Cancelled | Self::Failed | Self::Deleted + ) | ( + Self::Completed | Self::Declined | Self::Cancelled | Self::Failed, + Self::Deleted + ) + ); + if allowed { + Ok(()) + } else { + Err(VnidropError::InvalidTransition { + reason: format!("{} -> {}", self.as_str(), next.as_str()), + }) + } + } + + const fn as_str(self) -> &'static str { + match self { + Self::Preparing => "preparing", + Self::Offering => "offering", + Self::AwaitingApproval => "awaiting_approval", + Self::Approved => "approved", + Self::Connecting => "connecting", + Self::Transferring => "transferring", + Self::Interrupted => "interrupted", + Self::Completed => "completed", + Self::Declined => "declined", + Self::Cancelled => "cancelled", + Self::Failed => "failed", + Self::Deleted => "deleted", + } + } +} diff --git a/crates/vnidrop/tests/experimental_domain.rs b/crates/vnidrop/tests/experimental_domain.rs new file mode 100644 index 0000000..38a582c --- /dev/null +++ b/crates/vnidrop/tests/experimental_domain.rs @@ -0,0 +1,141 @@ +mod support; + +use support::TestNode; +use vnidrop::{ + experimental_saved_device_capabilities, DeviceRelationship, DeviceRelationshipState, + ExperimentalSavedDeviceCapabilities, SavedDevice, ShareMetadataInput, ShareSource, SourceKind, + TargetedTransfer, TargetedTransferState, TransferAccessMode, VnidropError, +}; + +#[test] +fn saved_device_protocols_are_explicitly_experimental_and_versioned() { + assert_eq!( + experimental_saved_device_capabilities(), + ExperimentalSavedDeviceCapabilities { + api_version: 1, + relationship_protocol_version: 1, + targeted_transfer_protocol_version: 1, + } + ); +} + +#[test] +fn saved_devices_relationships_and_targeted_transfers_are_distinct_contracts() { + let device = SavedDevice { + endpoint_id: "receiver-endpoint".to_string(), + local_label: Some("Kitchen tablet".to_string()), + remote_display_name: Some("Tablet".to_string()), + created_at: 1_000, + last_authenticated_at: Some(2_000), + }; + let relationship = DeviceRelationship { + remote_endpoint_id: device.endpoint_id.clone(), + state: DeviceRelationshipState::Saved, + generation: 4, + minimum_protocol_version: 1, + created_at: 1_000, + updated_at: 2_000, + }; + let transfer = TargetedTransfer { + id: "targeted-transfer-id".to_string(), + sender_endpoint_id: "sender-endpoint".to_string(), + receiver_endpoint_id: device.endpoint_id.clone(), + manifest_id: "immutable-manifest-id".to_string(), + file_count: 2, + total_size: 42, + state: TargetedTransferState::AwaitingApproval, + created_at: 3_000, + updated_at: 3_000, + }; + + assert_eq!(relationship.remote_endpoint_id, device.endpoint_id); + assert_eq!(relationship.state, DeviceRelationshipState::Saved); + assert_eq!( + transfer.receiver_endpoint_id, + relationship.remote_endpoint_id + ); + assert_eq!(transfer.state, TargetedTransferState::AwaitingApproval); +} + +#[test] +fn targeted_transfer_transitions_are_validated_by_the_domain() { + use TargetedTransferState as State; + + let valid = [ + (State::Preparing, State::Offering), + (State::Offering, State::AwaitingApproval), + (State::AwaitingApproval, State::Approved), + (State::AwaitingApproval, State::Declined), + (State::Approved, State::Connecting), + (State::Connecting, State::Transferring), + (State::Connecting, State::Interrupted), + (State::Transferring, State::Completed), + (State::Transferring, State::Interrupted), + (State::Interrupted, State::Connecting), + (State::Completed, State::Deleted), + (State::Declined, State::Deleted), + (State::Cancelled, State::Deleted), + (State::Failed, State::Deleted), + ]; + for (current, next) in valid { + current + .validate_transition_to(next) + .unwrap_or_else(|error| panic!("{current:?} -> {next:?} failed: {error}")); + } + + let error = State::Completed + .validate_transition_to(State::Transferring) + .unwrap_err(); + assert!(matches!(error, VnidropError::InvalidTransition { .. })); + assert_eq!( + error.to_string(), + "invalid targeted transfer transition: completed -> transferring" + ); +} + +#[test] +fn experimental_domain_seam_does_not_change_multi_receiver_shares() { + let source_dir = tempfile::tempdir().unwrap(); + let first_output = tempfile::tempdir().unwrap(); + let second_output = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("shared.txt"); + std::fs::write(&source_path, b"shared with both receivers").unwrap(); + let sender = TestNode::new(); + let first_receiver = TestNode::new(); + let second_receiver = TestNode::new(); + let share = sender + .core + .share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_path.to_string_lossy().into_owned(), + display_name: Some("shared.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: 90_001, + transfer_name: Some("Existing share".to_string()), + sender_name: Some("Sender".to_string()), + access_mode: TransferAccessMode::Public, + }, + ) + .unwrap(); + + for (receiver, output) in [ + (&first_receiver, first_output.path()), + (&second_receiver, second_output.path()), + ] { + receiver + .core + .receive( + share.ticket.clone(), + output.to_string_lossy().into_owned(), + Some("Receiver".to_string()), + ) + .unwrap(); + assert_eq!( + std::fs::read(output.join("shared.txt")).unwrap(), + b"shared with both receivers" + ); + } +}