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")
|
||||
|
||||
Reference in New Issue
Block a user