feat(core): add configurable share access policy

This commit is contained in:
2026-07-10 22:06:20 +02:00
parent 3160a5ed9c
commit 0bf4da0ee9
14 changed files with 224 additions and 17 deletions

View File

@@ -26,6 +26,13 @@ impl AccessPolicy {
self.modes.write().await.insert(transfer_id, mode); 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) { pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
self.modes.write().await.remove(&transfer_id); self.modes.write().await.remove(&transfer_id);
self.approved_sessions self.approved_sessions

View File

@@ -153,9 +153,10 @@ pub struct ShareMetadataInput {
pub transfer_id: u64, pub transfer_id: u64,
pub transfer_name: Option<String>, pub transfer_name: Option<String>,
pub sender_name: Option<String>, pub sender_name: Option<String>,
pub access_mode: TransferAccessMode,
} }
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum TransferAccessMode { pub enum TransferAccessMode {
Public, Public,
ApprovalRequired, ApprovalRequired,
@@ -173,6 +174,7 @@ pub struct StoredTransfer {
pub ticket: Option<String>, pub ticket: Option<String>,
pub file_count: u64, pub file_count: u64,
pub total_size: u64, pub total_size: u64,
pub access_mode: TransferAccessMode,
pub created_at: i64, pub created_at: i64,
pub updated_at: i64, pub updated_at: i64,
} }

View File

@@ -120,9 +120,18 @@ impl ApprovalService {
.await .await
{ {
Ok(true) => { Ok(true) => {
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) self.wait_for_sender_decision(remote_endpoint_id, request)
.await .await
} }
}
Ok(false) => { Ok(false) => {
self.deny(request.transfer_id, remote_endpoint_id, "unknown-transfer") self.deny(request.transfer_id, remote_endpoint_id, "unknown-transfer")
.await .await
@@ -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( async fn wait_for_sender_decision(
&self, &self,
remote_endpoint_id: String, remote_endpoint_id: String,

View File

@@ -14,6 +14,7 @@ use sqlx::{
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
access_policy::mode_from_storage,
api::{CoreEvent, ReceiverRequest, StoredTransfer}, api::{CoreEvent, ReceiverRequest, StoredTransfer},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
util::now_ms, util::now_ms,
@@ -641,7 +642,7 @@ impl Repository {
r#" r#"
SELECT transfer_id, direction, status, transfer_name, content_hash, ticket, SELECT transfer_id, direction, status, transfer_name, content_hash, ticket,
local_id, protocol_transfer_id, peer_id, 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 FROM transfers
ORDER BY updated_at DESC ORDER BY updated_at DESC
"#, "#,
@@ -716,6 +717,7 @@ fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<StoredTransfer> {
ticket: row.get("ticket"), ticket: row.get("ticket"),
file_count: row.get::<i64, _>("file_count") as u64, file_count: row.get::<i64, _>("file_count") as u64,
total_size: row.get::<i64, _>("total_size") as u64, total_size: row.get::<i64, _>("total_size") as u64,
access_mode: mode_from_storage(&row.get::<String, _>("access_mode")),
created_at: row.get("created_at"), created_at: row.get("created_at"),
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
}) })

View File

@@ -493,7 +493,7 @@ impl CoreInner {
ticket: None, ticket: None,
file_count: 0, file_count: 0,
total_size: 0, total_size: 0,
access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired), access_mode: mode_to_storage(&metadata.access_mode),
}) })
.await?; .await?;
let (cancel, mut cancelled) = oneshot::channel(); let (cancel, mut cancelled) = oneshot::channel();
@@ -538,6 +538,7 @@ impl CoreInner {
sources: Vec<ShareSource>, sources: Vec<ShareSource>,
metadata: ShareMetadataInput, metadata: ShareMetadataInput,
) -> Result<ShareResult> { ) -> Result<ShareResult> {
let access_mode = metadata.access_mode.clone();
self.emit_transfer( self.emit_transfer(
metadata.transfer_id, metadata.transfer_id,
"send", "send",
@@ -579,7 +580,7 @@ impl CoreInner {
ticket: Some(&ticket), ticket: Some(&ticket),
file_count: import.file_count, file_count: import.file_count,
total_size: import.total_size, total_size: import.total_size,
access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired), access_mode: mode_to_storage(&access_mode),
}) })
.await?; .await?;
self.hash_to_transfer self.hash_to_transfer
@@ -587,7 +588,7 @@ impl CoreInner {
.await .await
.insert(content_hash, metadata.transfer_id); .insert(content_hash, metadata.transfer_id);
self.access_policy self.access_policy
.set_mode(metadata.transfer_id, TransferAccessMode::ApprovalRequired) .set_mode(metadata.transfer_id, access_mode)
.await; .await;
self.active_shares self.active_shares
.lock() .lock()

View File

@@ -6,7 +6,53 @@ use support::{
receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink, receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink,
TestNode, 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] #[test]
fn approval_required_denies_then_allows_receiver() { fn approval_required_denies_then_allows_receiver() {

View File

@@ -6,6 +6,35 @@ use std::time::{Duration, Instant};
use support::{share_path, CoreGuard, RecordingSink, TestNode}; use support::{share_path, CoreGuard, RecordingSink, TestNode};
use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode}; 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] #[test]
fn cancelling_share_updates_status_and_events() { fn cancelling_share_updates_status_and_events() {
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();
@@ -92,6 +121,7 @@ fn failed_import_leaves_durable_failed_transfer() {
transfer_id, transfer_id,
transfer_name: Some("missing".to_string()), transfer_name: Some("missing".to_string()),
sender_name: None, 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_id: first.transfer_id,
transfer_name: Some("second".to_string()), transfer_name: Some("second".to_string()),
sender_name: None, sender_name: None,
access_mode: TransferAccessMode::ApprovalRequired,
}, },
); );
@@ -188,6 +219,7 @@ fn source_limit_rejection_creates_no_transfer_state() {
transfer_id: 24, transfer_id: 24,
transfer_name: Some("too many".to_string()), transfer_name: Some("too many".to_string()),
sender_name: None, sender_name: None,
access_mode: TransferAccessMode::ApprovalRequired,
}, },
); );
@@ -217,6 +249,7 @@ fn cancellation_during_import_is_durable() {
transfer_id: 25, transfer_id: 25,
transfer_name: Some("large".to_string()), transfer_name: Some("large".to_string()),
sender_name: None, sender_name: None,
access_mode: TransferAccessMode::ApprovalRequired,
}, },
) )
}); });

View File

@@ -12,7 +12,7 @@ use std::{
use vnidrop::{ use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput,
ShareResult, ShareSource, SourceKind, VnidropCore, VnidropError, ShareResult, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError,
}; };
#[derive(Default)] #[derive(Default)]
@@ -192,6 +192,7 @@ pub fn share_path(
transfer_id, transfer_id,
transfer_name: Some(display_name.to_string()), transfer_name: Some(display_name.to_string()),
sender_name: Some("sender".to_string()), sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
}, },
) )
.expect("test share should be created") .expect("test share should be created")

View File

@@ -50,6 +50,7 @@ private class AndroidFileSystemService(
file: PickedShareFile, file: PickedShareFile,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = runCatching { ): Result<Share> = runCatching {
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor -> context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor ->
checkNotNull(descriptor) { "Could not open selected file descriptor" } checkNotNull(descriptor) { "Could not open selected file descriptor" }
@@ -58,6 +59,7 @@ private class AndroidFileSystemService(
displayName = file.displayName, displayName = file.displayName,
transferName = transferName, transferName = transferName,
senderName = senderName, senderName = senderName,
accessPolicy = accessPolicy,
).getOrThrow() ).getOrThrow()
} }
} }

View File

@@ -21,16 +21,40 @@ data class CoreEventModel(
val dataJson: String, 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( data class Transfer(
val localId: String, val localId: String,
val transferId: ULong, val transferId: ULong,
val direction: String, val direction: TransferDirection,
val status: String, val status: TransferStatus,
val peerId: String?, val peerId: String?,
val transferName: String?, val transferName: String?,
val contentHash: String?,
val fileCount: ULong, val fileCount: ULong,
val totalSize: ULong, val totalSize: ULong,
val ticket: String?, val ticket: String?,
val accessPolicy: ShareAccessPolicy,
val createdAt: Long,
val updatedAt: Long,
) )
data class Share( data class Share(
@@ -90,9 +114,21 @@ interface CoreGateway {
suspend fun initialize(appDataDir: String): Result<Unit> suspend fun initialize(appDataDir: String): Result<Unit>
fun shutdown() fun shutdown()
suspend fun sharePath(path: String, transferName: String, senderName: String): Result<Share> suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share>
suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String): Result<Share> suspend fun shareFileDescriptor(
suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String): Result<Share> fd: Int,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit> suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>

View File

@@ -25,6 +25,7 @@ import uniffi.vnidrop.SourceKind
import uniffi.vnidrop.StoredTransfer import uniffi.vnidrop.StoredTransfer
import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.TicketInspection
import uniffi.vnidrop.TransferMetadata import uniffi.vnidrop.TransferMetadata
import uniffi.vnidrop.TransferAccessMode
import uniffi.vnidrop.VnidropCore import uniffi.vnidrop.VnidropCore
class CoreRepository( class CoreRepository(
@@ -64,7 +65,12 @@ class CoreRepository(
_state.value = CoreState() _state.value = CoreState()
} }
override suspend fun sharePath(path: String, transferName: String, senderName: String): Result<Share> = override suspend fun sharePath(
path: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> =
shareSources( shareSources(
sources = listOf( sources = listOf(
ShareSource( ShareSource(
@@ -76,6 +82,7 @@ class CoreRepository(
), ),
transferName = transferName, transferName = transferName,
senderName = senderName, senderName = senderName,
accessPolicy = accessPolicy,
) )
override suspend fun shareFileDescriptor( override suspend fun shareFileDescriptor(
@@ -83,6 +90,7 @@ class CoreRepository(
displayName: String, displayName: String,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = ): Result<Share> =
shareSources( shareSources(
sources = listOf( sources = listOf(
@@ -95,6 +103,7 @@ class CoreRepository(
), ),
transferName = transferName, transferName = transferName,
senderName = senderName, senderName = senderName,
accessPolicy = accessPolicy,
) )
override suspend fun shareSecurityScopedFileUrl( override suspend fun shareSecurityScopedFileUrl(
@@ -102,6 +111,7 @@ class CoreRepository(
displayName: String, displayName: String,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = ): Result<Share> =
shareSources( shareSources(
sources = listOf( sources = listOf(
@@ -114,6 +124,7 @@ class CoreRepository(
), ),
transferName = transferName, transferName = transferName,
senderName = senderName, senderName = senderName,
accessPolicy = accessPolicy,
) )
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore { override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore {
@@ -170,6 +181,7 @@ class CoreRepository(
sources: List<ShareSource>, sources: List<ShareSource>,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = runCore { ): Result<Share> = runCore {
withPlatformPathAccess(sources) { withPlatformPathAccess(sources) {
requireCore().shareFiles( requireCore().shareFiles(
@@ -178,6 +190,7 @@ class CoreRepository(
transferId = nextTransferId(), transferId = nextTransferId(),
transferName = transferName.ifBlank { null }, transferName = transferName.ifBlank { null },
senderName = senderName.ifBlank { null }, senderName = senderName.ifBlank { null },
accessMode = accessPolicy.toNative(),
), ),
).toModel() ).toModel()
}.also { share -> }.also { share ->
@@ -243,15 +256,46 @@ private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel(
private fun StoredTransfer.toModel(): Transfer = Transfer( private fun StoredTransfer.toModel(): Transfer = Transfer(
localId = localId, localId = localId,
transferId = transferId, transferId = transferId,
direction = direction, direction = direction.toTransferDirection(),
status = status, status = status.toTransferStatus(),
peerId = peerId, peerId = peerId,
transferName = transferName, transferName = transferName,
contentHash = contentHash,
fileCount = fileCount, fileCount = fileCount,
totalSize = totalSize, totalSize = totalSize,
ticket = ticket, 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( private fun ShareResult.toModel(): Share = Share(
transferId = transferId, transferId = transferId,
ticket = ticket, ticket = ticket,

View File

@@ -30,6 +30,7 @@ interface FileSystemService {
file: PickedShareFile, file: PickedShareFile,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> ): Result<Share>
} }

View File

@@ -47,11 +47,13 @@ private class IosFileSystemService : FileSystemService {
file: PickedShareFile, file: PickedShareFile,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = repository.shareSecurityScopedFileUrl( ): Result<Share> = repository.shareSecurityScopedFileUrl(
file.value, file.value,
file.displayName, file.displayName,
transferName, transferName,
senderName, senderName,
accessPolicy,
) )
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus { private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {

View File

@@ -33,5 +33,6 @@ private class JvmFileSystemService : FileSystemService {
file: PickedShareFile, file: PickedShareFile,
transferName: String, transferName: String,
senderName: String, senderName: String,
): Result<Share> = repository.sharePath(file.value, transferName, senderName) accessPolicy: ShareAccessPolicy,
): Result<Share> = repository.sharePath(file.value, transferName, senderName, accessPolicy)
} }