diff --git a/crates/vnidrop/src/access_policy.rs b/crates/vnidrop/src/access_policy.rs index 1099ae0..77ae471 100644 --- a/crates/vnidrop/src/access_policy.rs +++ b/crates/vnidrop/src/access_policy.rs @@ -26,6 +26,13 @@ impl AccessPolicy { self.modes.write().await.insert(transfer_id, mode); } + pub(crate) async fn allows_without_approval(&self, transfer_id: u64) -> bool { + matches!( + self.modes.read().await.get(&transfer_id), + Some(TransferAccessMode::Public) + ) + } + pub(crate) async fn remove_transfer(&self, transfer_id: u64) { self.modes.write().await.remove(&transfer_id); self.approved_sessions diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index fabd451..607c842 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -153,9 +153,10 @@ pub struct ShareMetadataInput { pub transfer_id: u64, pub transfer_name: Option, pub sender_name: Option, + pub access_mode: TransferAccessMode, } -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum TransferAccessMode { Public, ApprovalRequired, @@ -173,6 +174,7 @@ pub struct StoredTransfer { pub ticket: Option, pub file_count: u64, pub total_size: u64, + pub access_mode: TransferAccessMode, pub created_at: i64, pub updated_at: i64, } diff --git a/crates/vnidrop/src/approval.rs b/crates/vnidrop/src/approval.rs index 31382a7..543d337 100644 --- a/crates/vnidrop/src/approval.rs +++ b/crates/vnidrop/src/approval.rs @@ -120,8 +120,17 @@ impl ApprovalService { .await { Ok(true) => { - self.wait_for_sender_decision(remote_endpoint_id, request) + if self + .access_policy + .allows_without_approval(request.transfer_id) .await + { + self.allow_without_sender_decision(remote_endpoint_id, request) + .await + } else { + self.wait_for_sender_decision(remote_endpoint_id, request) + .await + } } Ok(false) => { self.deny(request.transfer_id, remote_endpoint_id, "unknown-transfer") @@ -135,6 +144,26 @@ impl ApprovalService { } } + async fn allow_without_sender_decision( + &self, + remote_endpoint_id: String, + request: RequestTransfer, + ) -> HandshakeResponse { + let token = Uuid::new_v4().to_string(); + let expires_at = now_ms() + APPROVAL_TTL_MS; + self.event_hub.emit_transfer( + request.transfer_id, + "send", + "access", + "receiver-auto-approved", + json!({ + "remote_endpoint_id": remote_endpoint_id, + "expires_at": expires_at, + }), + ); + HandshakeResponse::Approved { token, expires_at } + } + async fn wait_for_sender_decision( &self, remote_endpoint_id: String, diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index 8cd05df..01c6743 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -14,6 +14,7 @@ use sqlx::{ use uuid::Uuid; use crate::{ + access_policy::mode_from_storage, api::{CoreEvent, ReceiverRequest, StoredTransfer}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, util::now_ms, @@ -641,7 +642,7 @@ impl Repository { r#" SELECT transfer_id, direction, status, transfer_name, content_hash, ticket, local_id, protocol_transfer_id, peer_id, - file_count, total_size, created_at, updated_at + file_count, total_size, access_mode, created_at, updated_at FROM transfers ORDER BY updated_at DESC "#, @@ -716,6 +717,7 @@ fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result { ticket: row.get("ticket"), file_count: row.get::("file_count") as u64, total_size: row.get::("total_size") as u64, + access_mode: mode_from_storage(&row.get::("access_mode")), created_at: row.get("created_at"), updated_at: row.get("updated_at"), }) diff --git a/crates/vnidrop/src/runtime.rs b/crates/vnidrop/src/runtime.rs index 0e74216..b748121 100644 --- a/crates/vnidrop/src/runtime.rs +++ b/crates/vnidrop/src/runtime.rs @@ -493,7 +493,7 @@ impl CoreInner { ticket: None, file_count: 0, total_size: 0, - access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired), + access_mode: mode_to_storage(&metadata.access_mode), }) .await?; let (cancel, mut cancelled) = oneshot::channel(); @@ -538,6 +538,7 @@ impl CoreInner { sources: Vec, metadata: ShareMetadataInput, ) -> Result { + let access_mode = metadata.access_mode.clone(); self.emit_transfer( metadata.transfer_id, "send", @@ -579,7 +580,7 @@ impl CoreInner { ticket: Some(&ticket), file_count: import.file_count, total_size: import.total_size, - access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired), + access_mode: mode_to_storage(&access_mode), }) .await?; self.hash_to_transfer @@ -587,7 +588,7 @@ impl CoreInner { .await .insert(content_hash, metadata.transfer_id); self.access_policy - .set_mode(metadata.transfer_id, TransferAccessMode::ApprovalRequired) + .set_mode(metadata.transfer_id, access_mode) .await; self.active_shares .lock() diff --git a/crates/vnidrop/tests/approval.rs b/crates/vnidrop/tests/approval.rs index 6c011b3..2de629f 100644 --- a/crates/vnidrop/tests/approval.rs +++ b/crates/vnidrop/tests/approval.rs @@ -6,7 +6,53 @@ use support::{ receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink, TestNode, }; -use vnidrop::CoreLimits; +use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode}; + +#[test] +fn public_share_receives_without_sender_approval() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("public.txt"); + std::fs::write(&source_path, b"public content").unwrap(); + let sender = TestNode::new(); + let 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("public.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: 30, + transfer_name: Some("Public file".to_string()), + sender_name: Some("Sender".to_string()), + access_mode: TransferAccessMode::Public, + }, + ) + .unwrap(); + + receiver + .core + .receive( + share.ticket, + output_dir.path().to_string_lossy().into_owned(), + Some("Receiver".to_string()), + ) + .unwrap(); + + assert_eq!( + std::fs::read(output_dir.path().join("public.txt")).unwrap(), + b"public content" + ); + assert!(sender + .core + .list_receiver_requests(share.transfer_id) + .unwrap() + .is_empty()); +} #[test] fn approval_required_denies_then_allows_receiver() { diff --git a/crates/vnidrop/tests/lifecycle.rs b/crates/vnidrop/tests/lifecycle.rs index fc599f0..562a9fd 100644 --- a/crates/vnidrop/tests/lifecycle.rs +++ b/crates/vnidrop/tests/lifecycle.rs @@ -6,6 +6,35 @@ use std::time::{Duration, Instant}; use support::{share_path, CoreGuard, RecordingSink, TestNode}; use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode}; +#[test] +fn share_creation_persists_selected_access_mode_atomically() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("public.txt"); + std::fs::write(&source_path, b"public share").unwrap(); + let sender = TestNode::new(); + + sender + .core + .share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_path.to_string_lossy().into_owned(), + display_name: Some("public.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: 9, + transfer_name: Some("Public file".to_string()), + sender_name: Some("Sender".to_string()), + access_mode: TransferAccessMode::Public, + }, + ) + .unwrap(); + + let transfer = sender.core.list_transfers().unwrap().remove(0); + assert_eq!(transfer.access_mode, TransferAccessMode::Public); +} + #[test] fn cancelling_share_updates_status_and_events() { let source_dir = tempfile::tempdir().unwrap(); @@ -92,6 +121,7 @@ fn failed_import_leaves_durable_failed_transfer() { transfer_id, transfer_name: Some("missing".to_string()), sender_name: None, + access_mode: TransferAccessMode::ApprovalRequired, }, ); @@ -128,6 +158,7 @@ fn duplicate_transfer_id_does_not_replace_active_share() { transfer_id: first.transfer_id, transfer_name: Some("second".to_string()), sender_name: None, + access_mode: TransferAccessMode::ApprovalRequired, }, ); @@ -188,6 +219,7 @@ fn source_limit_rejection_creates_no_transfer_state() { transfer_id: 24, transfer_name: Some("too many".to_string()), sender_name: None, + access_mode: TransferAccessMode::ApprovalRequired, }, ); @@ -217,6 +249,7 @@ fn cancellation_during_import_is_durable() { transfer_id: 25, transfer_name: Some("large".to_string()), sender_name: None, + access_mode: TransferAccessMode::ApprovalRequired, }, ) }); diff --git a/crates/vnidrop/tests/support/mod.rs b/crates/vnidrop/tests/support/mod.rs index 197b15a..6989e9b 100644 --- a/crates/vnidrop/tests/support/mod.rs +++ b/crates/vnidrop/tests/support/mod.rs @@ -12,7 +12,7 @@ use std::{ use vnidrop::{ CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, - ShareResult, ShareSource, SourceKind, VnidropCore, VnidropError, + ShareResult, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError, }; #[derive(Default)] @@ -192,6 +192,7 @@ pub fn share_path( transfer_id, transfer_name: Some(display_name.to_string()), sender_name: Some("sender".to_string()), + access_mode: TransferAccessMode::ApprovalRequired, }, ) .expect("test share should be created") diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index a03f160..5f3d8ee 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -50,6 +50,7 @@ private class AndroidFileSystemService( file: PickedShareFile, transferName: String, senderName: String, + accessPolicy: ShareAccessPolicy, ): Result = runCatching { context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor -> checkNotNull(descriptor) { "Could not open selected file descriptor" } @@ -58,6 +59,7 @@ private class AndroidFileSystemService( displayName = file.displayName, transferName = transferName, senderName = senderName, + accessPolicy = accessPolicy, ).getOrThrow() } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index a516a40..b117248 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -21,16 +21,40 @@ data class CoreEventModel( val dataJson: String, ) +enum class ShareAccessPolicy { + RequireApproval, + AnyoneWithTransfer, +} + +enum class TransferDirection { + Send, + Receive, +} + +enum class TransferStatus { + Importing, + Sharing, + Receiving, + Done, + Failed, + Cancelled, + Stopped, +} + data class Transfer( val localId: String, val transferId: ULong, - val direction: String, - val status: String, + val direction: TransferDirection, + val status: TransferStatus, val peerId: String?, val transferName: String?, + val contentHash: String?, val fileCount: ULong, val totalSize: ULong, val ticket: String?, + val accessPolicy: ShareAccessPolicy, + val createdAt: Long, + val updatedAt: Long, ) data class Share( @@ -90,9 +114,21 @@ interface CoreGateway { suspend fun initialize(appDataDir: String): Result fun shutdown() - suspend fun sharePath(path: String, transferName: String, senderName: String): Result - suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String): Result - suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String): Result + suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result + suspend fun shareFileDescriptor( + fd: Int, + displayName: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result + suspend fun shareSecurityScopedFileUrl( + fileUrl: String, + displayName: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result suspend fun inspectTicket(ticket: String): Result suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index b157106..76f7aa7 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -25,6 +25,7 @@ import uniffi.vnidrop.SourceKind import uniffi.vnidrop.StoredTransfer import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.TransferMetadata +import uniffi.vnidrop.TransferAccessMode import uniffi.vnidrop.VnidropCore class CoreRepository( @@ -64,7 +65,12 @@ class CoreRepository( _state.value = CoreState() } - override suspend fun sharePath(path: String, transferName: String, senderName: String): Result = + override suspend fun sharePath( + path: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result = shareSources( sources = listOf( ShareSource( @@ -76,6 +82,7 @@ class CoreRepository( ), transferName = transferName, senderName = senderName, + accessPolicy = accessPolicy, ) override suspend fun shareFileDescriptor( @@ -83,6 +90,7 @@ class CoreRepository( displayName: String, transferName: String, senderName: String, + accessPolicy: ShareAccessPolicy, ): Result = shareSources( sources = listOf( @@ -95,6 +103,7 @@ class CoreRepository( ), transferName = transferName, senderName = senderName, + accessPolicy = accessPolicy, ) override suspend fun shareSecurityScopedFileUrl( @@ -102,6 +111,7 @@ class CoreRepository( displayName: String, transferName: String, senderName: String, + accessPolicy: ShareAccessPolicy, ): Result = shareSources( sources = listOf( @@ -114,6 +124,7 @@ class CoreRepository( ), transferName = transferName, senderName = senderName, + accessPolicy = accessPolicy, ) override suspend fun inspectTicket(ticket: String): Result = runCore { @@ -170,6 +181,7 @@ class CoreRepository( sources: List, transferName: String, senderName: String, + accessPolicy: ShareAccessPolicy, ): Result = runCore { withPlatformPathAccess(sources) { requireCore().shareFiles( @@ -178,6 +190,7 @@ class CoreRepository( transferId = nextTransferId(), transferName = transferName.ifBlank { null }, senderName = senderName.ifBlank { null }, + accessMode = accessPolicy.toNative(), ), ).toModel() }.also { share -> @@ -243,15 +256,46 @@ private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel( private fun StoredTransfer.toModel(): Transfer = Transfer( localId = localId, transferId = transferId, - direction = direction, - status = status, + direction = direction.toTransferDirection(), + status = status.toTransferStatus(), peerId = peerId, transferName = transferName, + contentHash = contentHash, fileCount = fileCount, totalSize = totalSize, ticket = ticket, + accessPolicy = accessMode.toModel(), + createdAt = createdAt, + updatedAt = updatedAt, ) +private fun ShareAccessPolicy.toNative(): TransferAccessMode = when (this) { + ShareAccessPolicy.RequireApproval -> TransferAccessMode.APPROVAL_REQUIRED + ShareAccessPolicy.AnyoneWithTransfer -> TransferAccessMode.PUBLIC +} + +private fun TransferAccessMode.toModel(): ShareAccessPolicy = when (this) { + TransferAccessMode.APPROVAL_REQUIRED -> ShareAccessPolicy.RequireApproval + TransferAccessMode.PUBLIC -> ShareAccessPolicy.AnyoneWithTransfer +} + +private fun String.toTransferDirection(): TransferDirection = when (this) { + "send" -> TransferDirection.Send + "receive" -> TransferDirection.Receive + else -> error("Unknown transfer direction: $this") +} + +private fun String.toTransferStatus(): TransferStatus = when (this) { + "importing" -> TransferStatus.Importing + "sharing" -> TransferStatus.Sharing + "receiving" -> TransferStatus.Receiving + "done" -> TransferStatus.Done + "failed" -> TransferStatus.Failed + "cancelled" -> TransferStatus.Cancelled + "stopped" -> TransferStatus.Stopped + else -> error("Unknown transfer status: $this") +} + private fun ShareResult.toModel(): Share = Share( transferId = transferId, ticket = ticket, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt index b0c7bbf..0b849db 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -30,6 +30,7 @@ interface FileSystemService { file: PickedShareFile, transferName: String, senderName: String, + accessPolicy: ShareAccessPolicy, ): Result } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt index 773e6de..4a1e901 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt @@ -47,11 +47,13 @@ private class IosFileSystemService : FileSystemService { file: PickedShareFile, transferName: String, senderName: String, + accessPolicy: ShareAccessPolicy, ): Result = repository.shareSecurityScopedFileUrl( file.value, file.displayName, transferName, senderName, + accessPolicy, ) private fun validateSecurityScopedUrl(value: String): FolderAccessStatus { diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt index 1b692be..b882401 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt @@ -33,5 +33,6 @@ private class JvmFileSystemService : FileSystemService { file: PickedShareFile, transferName: String, senderName: String, - ): Result = repository.sharePath(file.value, transferName, senderName) + accessPolicy: ShareAccessPolicy, + ): Result = repository.sharePath(file.value, transferName, senderName, accessPolicy) }