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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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() {

View File

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

View File

@@ -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")