mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-10 20:59:57 +02:00
feat(core): establish saved device domain seam
This commit is contained in:
@@ -10,6 +10,82 @@ use crate::util::{non_empty, now_ms};
|
|||||||
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
|
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
|
||||||
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
|
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<String>,
|
||||||
|
pub remote_display_name: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub last_authenticated_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||||
pub enum CoreRelayMode {
|
pub enum CoreRelayMode {
|
||||||
Automatic,
|
Automatic,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub enum VnidropError {
|
|||||||
Cancelled { reason: String },
|
Cancelled { reason: String },
|
||||||
#[error("invalid input: {reason}")]
|
#[error("invalid input: {reason}")]
|
||||||
InvalidInput { reason: String },
|
InvalidInput { reason: String },
|
||||||
|
#[error("invalid targeted transfer transition: {reason}")]
|
||||||
|
InvalidTransition { reason: String },
|
||||||
#[error("internal error: {reason}")]
|
#[error("internal error: {reason}")]
|
||||||
Internal { reason: String },
|
Internal { reason: String },
|
||||||
}
|
}
|
||||||
@@ -93,6 +95,7 @@ impl VnidropError {
|
|||||||
Self::Repository { .. } => "repository",
|
Self::Repository { .. } => "repository",
|
||||||
Self::Cancelled { .. } => "cancelled",
|
Self::Cancelled { .. } => "cancelled",
|
||||||
Self::InvalidInput { .. } => "invalid_input",
|
Self::InvalidInput { .. } => "invalid_input",
|
||||||
|
Self::InvalidTransition { .. } => "invalid_transition",
|
||||||
Self::Internal { .. } => "internal",
|
Self::Internal { .. } => "internal",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +114,7 @@ impl VnidropError {
|
|||||||
| Self::Repository { reason }
|
| Self::Repository { reason }
|
||||||
| Self::Cancelled { reason }
|
| Self::Cancelled { reason }
|
||||||
| Self::InvalidInput { reason }
|
| Self::InvalidInput { reason }
|
||||||
|
| Self::InvalidTransition { reason }
|
||||||
| Self::Internal { reason } => reason,
|
| Self::Internal { reason } => reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +167,7 @@ impl VnidropError {
|
|||||||
Self::Repository { .. } => Self::Repository { reason },
|
Self::Repository { .. } => Self::Repository { reason },
|
||||||
Self::Cancelled { .. } => Self::Cancelled { reason },
|
Self::Cancelled { .. } => Self::Cancelled { reason },
|
||||||
Self::InvalidInput { .. } => Self::InvalidInput { reason },
|
Self::InvalidInput { .. } => Self::InvalidInput { reason },
|
||||||
|
Self::InvalidTransition { .. } => Self::InvalidTransition { reason },
|
||||||
Self::Internal { .. } => Self::Internal { reason },
|
Self::Internal { .. } => Self::Internal { reason },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,20 @@ mod pairing;
|
|||||||
mod repository;
|
mod repository;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod secret;
|
mod secret;
|
||||||
|
mod targeted_transfer;
|
||||||
mod ticket;
|
mod ticket;
|
||||||
mod transfer_state;
|
mod transfer_state;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
pub use api::{
|
pub use api::{
|
||||||
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
|
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
|
||||||
ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
|
experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent,
|
||||||
CoreRelayMode, CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer,
|
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage,
|
||||||
PendingPairing, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact,
|
DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities,
|
||||||
ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
|
GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing, PublishedOutput,
|
||||||
ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode,
|
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
||||||
|
RuntimeStatus, SavedDevice, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
|
||||||
|
StoredTransfer, TargetedTransfer, TargetedTransferState, TicketInspection, TransferAccessMode,
|
||||||
TransferMetadata,
|
TransferMetadata,
|
||||||
};
|
};
|
||||||
pub use error::VnidropError;
|
pub use error::VnidropError;
|
||||||
|
|||||||
59
crates/vnidrop/src/targeted_transfer.rs
Normal file
59
crates/vnidrop/src/targeted_transfer.rs
Normal file
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
crates/vnidrop/tests/experimental_domain.rs
Normal file
141
crates/vnidrop/tests/experimental_domain.rs
Normal file
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user