mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(core): add configurable share access policy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -153,9 +153,10 @@ pub struct ShareMetadataInput {
|
||||
pub transfer_id: u64,
|
||||
pub transfer_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 {
|
||||
Public,
|
||||
ApprovalRequired,
|
||||
@@ -173,6 +174,7 @@ pub struct StoredTransfer {
|
||||
pub ticket: Option<String>,
|
||||
pub file_count: u64,
|
||||
pub total_size: u64,
|
||||
pub access_mode: TransferAccessMode,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<StoredTransfer> {
|
||||
ticket: row.get("ticket"),
|
||||
file_count: row.get::<i64, _>("file_count") 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"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})
|
||||
|
||||
@@ -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<ShareSource>,
|
||||
metadata: ShareMetadataInput,
|
||||
) -> Result<ShareResult> {
|
||||
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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -50,6 +50,7 @@ private class AndroidFileSystemService(
|
||||
file: PickedShareFile,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> = 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Unit>
|
||||
fun shutdown()
|
||||
suspend fun sharePath(path: String, transferName: String, senderName: String): Result<Share>
|
||||
suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String): Result<Share>
|
||||
suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: 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,
|
||||
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 receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||
|
||||
@@ -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<Share> =
|
||||
override suspend fun sharePath(
|
||||
path: String,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> =
|
||||
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<Share> =
|
||||
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<Share> =
|
||||
shareSources(
|
||||
sources = listOf(
|
||||
@@ -114,6 +124,7 @@ class CoreRepository(
|
||||
),
|
||||
transferName = transferName,
|
||||
senderName = senderName,
|
||||
accessPolicy = accessPolicy,
|
||||
)
|
||||
|
||||
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore {
|
||||
@@ -170,6 +181,7 @@ class CoreRepository(
|
||||
sources: List<ShareSource>,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> = 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,
|
||||
|
||||
@@ -30,6 +30,7 @@ interface FileSystemService {
|
||||
file: PickedShareFile,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share>
|
||||
}
|
||||
|
||||
|
||||
@@ -47,11 +47,13 @@ private class IosFileSystemService : FileSystemService {
|
||||
file: PickedShareFile,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> = repository.shareSecurityScopedFileUrl(
|
||||
file.value,
|
||||
file.displayName,
|
||||
transferName,
|
||||
senderName,
|
||||
accessPolicy,
|
||||
)
|
||||
|
||||
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
|
||||
|
||||
@@ -33,5 +33,6 @@ private class JvmFileSystemService : FileSystemService {
|
||||
file: PickedShareFile,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
): Result<Share> = repository.sharePath(file.value, transferName, senderName)
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> = repository.sharePath(file.value, transferName, senderName, accessPolicy)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user