feat(core): establish saved device domain seam

This commit is contained in:
2026-08-09 18:01:02 +02:00
parent 0ec0daef2a
commit 564b86c28c
5 changed files with 289 additions and 5 deletions

View File

@@ -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,

View File

@@ -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 },
}
}

View File

@@ -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;

View 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",
}
}
}

View 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"
);
}
}