mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-11 05:09:55 +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_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)]
|
||||
pub enum CoreRelayMode {
|
||||
Automatic,
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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