mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Harden Rust transfer core
This commit is contained in:
@@ -48,6 +48,9 @@ impl AccessPolicy {
|
|||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
endpoint_id: Option<&str>,
|
endpoint_id: Option<&str>,
|
||||||
) -> AccessDecision {
|
) -> AccessDecision {
|
||||||
|
// This is intentionally only the provider-side gate for milestone one.
|
||||||
|
// A later handshake can add receiver-request/sender-approval events on
|
||||||
|
// top without weakening the default public sharing behavior.
|
||||||
match self
|
match self
|
||||||
.modes
|
.modes
|
||||||
.read()
|
.read()
|
||||||
|
|||||||
@@ -2,13 +2,63 @@ use std::io;
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
||||||
pub enum VnidropError {
|
pub enum VnidropError {
|
||||||
#[error("{reason}")]
|
#[error("initialization failed: {reason}")]
|
||||||
Generic { reason: String },
|
Initialization { reason: String },
|
||||||
|
#[error("ticket error: {reason}")]
|
||||||
|
Ticket { reason: String },
|
||||||
|
#[error("filesystem error: {reason}")]
|
||||||
|
Filesystem { reason: String },
|
||||||
|
#[error("transfer error: {reason}")]
|
||||||
|
Transfer { reason: String },
|
||||||
|
#[error("permission error: {reason}")]
|
||||||
|
Permission { reason: String },
|
||||||
|
#[error("repository error: {reason}")]
|
||||||
|
Repository { reason: String },
|
||||||
|
#[error("internal error: {reason}")]
|
||||||
|
Internal { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VnidropError {
|
||||||
|
pub(crate) fn initialization(error: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self::Initialization {
|
||||||
|
reason: error.into().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ticket(error: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self::Ticket {
|
||||||
|
reason: error.into().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn filesystem(error: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self::Filesystem {
|
||||||
|
reason: error.into().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self::Transfer {
|
||||||
|
reason: error.into().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn permission(error: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self::Permission {
|
||||||
|
reason: error.into().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn repository(error: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self::Repository {
|
||||||
|
reason: error.into().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<anyhow::Error> for VnidropError {
|
impl From<anyhow::Error> for VnidropError {
|
||||||
fn from(error: anyhow::Error) -> Self {
|
fn from(error: anyhow::Error) -> Self {
|
||||||
Self::Generic {
|
Self::Internal {
|
||||||
reason: error.to_string(),
|
reason: error.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,7 +66,7 @@ impl From<anyhow::Error> for VnidropError {
|
|||||||
|
|
||||||
impl From<io::Error> for VnidropError {
|
impl From<io::Error> for VnidropError {
|
||||||
fn from(error: io::Error) -> Self {
|
fn from(error: io::Error) -> Self {
|
||||||
Self::Generic {
|
Self::Filesystem {
|
||||||
reason: error.to_string(),
|
reason: error.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
crates/vnidrop/src/event_hub.rs
Normal file
130
crates/vnidrop/src/event_hub.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::{
|
||||||
|
sync::{mpsc, oneshot, Mutex as TokioMutex},
|
||||||
|
task::JoinHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{CoreEvent, CoreEventSink},
|
||||||
|
repository::Repository,
|
||||||
|
util::now_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum EventCommand {
|
||||||
|
Persist(CoreEvent),
|
||||||
|
Flush(oneshot::Sender<()>),
|
||||||
|
Shutdown(oneshot::Sender<()>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct EventHub {
|
||||||
|
sink: Arc<dyn CoreEventSink>,
|
||||||
|
tx: mpsc::UnboundedSender<EventCommand>,
|
||||||
|
join: TokioMutex<Option<JoinHandle<()>>>,
|
||||||
|
sequence: Mutex<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHub {
|
||||||
|
pub(crate) fn start(repository: Repository, sink: Arc<dyn CoreEventSink>) -> Self {
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
let join = tokio::spawn(async move {
|
||||||
|
while let Some(command) = rx.recv().await {
|
||||||
|
match command {
|
||||||
|
EventCommand::Persist(event) => {
|
||||||
|
if let Err(error) = repository.insert_event(&event).await {
|
||||||
|
tracing::warn!(%error, event_id = %event.id, "failed to persist core event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventCommand::Flush(done) => {
|
||||||
|
let _ = done.send(());
|
||||||
|
}
|
||||||
|
EventCommand::Shutdown(done) => {
|
||||||
|
let _ = done.send(());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
sink,
|
||||||
|
tx,
|
||||||
|
join: TokioMutex::new(Some(join)),
|
||||||
|
sequence: Mutex::new(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn emit_endpoint(&self, phase: &str, kind: &str, data: Value) {
|
||||||
|
self.emit("endpoint", None, None, phase, kind, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn emit_transfer(
|
||||||
|
&self,
|
||||||
|
transfer_id: u64,
|
||||||
|
direction: &str,
|
||||||
|
phase: &str,
|
||||||
|
kind: &str,
|
||||||
|
data: Value,
|
||||||
|
) {
|
||||||
|
self.emit(
|
||||||
|
"transfer",
|
||||||
|
Some(transfer_id),
|
||||||
|
Some(direction.to_string()),
|
||||||
|
phase,
|
||||||
|
kind,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn flush(&self) {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
if self.tx.send(EventCommand::Flush(tx)).is_ok() {
|
||||||
|
let _ = rx.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn shutdown(&self) {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
if self.tx.send(EventCommand::Shutdown(tx)).is_ok() {
|
||||||
|
let _ = rx.await;
|
||||||
|
}
|
||||||
|
if let Some(join) = self.join.lock().await.take() {
|
||||||
|
let _ = join.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(
|
||||||
|
&self,
|
||||||
|
scope: &str,
|
||||||
|
transfer_id: Option<u64>,
|
||||||
|
direction: Option<String>,
|
||||||
|
phase: &str,
|
||||||
|
kind: &str,
|
||||||
|
data: Value,
|
||||||
|
) {
|
||||||
|
let timestamp = now_ms();
|
||||||
|
let mut sequence = self.sequence.lock().expect("event sequence lock poisoned");
|
||||||
|
let id = format!("{timestamp}-{}", *sequence);
|
||||||
|
*sequence += 1;
|
||||||
|
drop(sequence);
|
||||||
|
|
||||||
|
// Compose observes this event synchronously, while SQLite persistence is
|
||||||
|
// serialized through the queue. That keeps the UI responsive without
|
||||||
|
// losing the ability to flush persisted history during shutdown/tests.
|
||||||
|
let event = CoreEvent {
|
||||||
|
id,
|
||||||
|
timestamp,
|
||||||
|
scope: scope.to_string(),
|
||||||
|
transfer_id,
|
||||||
|
direction,
|
||||||
|
phase: phase.to_string(),
|
||||||
|
kind: kind.to_string(),
|
||||||
|
data_json: data.to_string(),
|
||||||
|
};
|
||||||
|
if self.tx.send(EventCommand::Persist(event.clone())).is_err() {
|
||||||
|
tracing::warn!(event_id = %event.id, "event persistence queue is closed");
|
||||||
|
}
|
||||||
|
self.sink.on_event(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,10 +122,16 @@ pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<Impo
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn source_path(source: &ShareSource) -> Result<PathBuf> {
|
fn source_path(source: &ShareSource) -> Result<PathBuf> {
|
||||||
|
if source.value.trim().is_empty() {
|
||||||
|
anyhow::bail!("source path must not be empty");
|
||||||
|
}
|
||||||
platform_path(&source.value)
|
platform_path(&source.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn platform_path(value: &str) -> Result<PathBuf> {
|
pub(crate) fn platform_path(value: &str) -> Result<PathBuf> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
anyhow::bail!("path must not be empty");
|
||||||
|
}
|
||||||
if let Some(without_scheme) = value.strip_prefix("file://") {
|
if let Some(without_scheme) = value.strip_prefix("file://") {
|
||||||
return Ok(PathBuf::from(percent_decode_file_url_path(without_scheme)?));
|
return Ok(PathBuf::from(percent_decode_file_url_path(without_scheme)?));
|
||||||
}
|
}
|
||||||
@@ -230,7 +236,11 @@ pub(crate) async fn wait_for_writer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validated_relative_string(name: &str) -> Result<String> {
|
pub(crate) fn validated_relative_string(name: &str) -> Result<String> {
|
||||||
path_to_string(Path::new(name), true)
|
let value = path_to_string(Path::new(name), true)?;
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
anyhow::bail!("relative path must not be empty");
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn path_to_string(path: impl AsRef<Path>, must_be_relative: bool) -> Result<String> {
|
pub(crate) fn path_to_string(path: impl AsRef<Path>, must_be_relative: bool) -> Result<String> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod access_policy;
|
mod access_policy;
|
||||||
mod api;
|
mod api;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod event_hub;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod repository;
|
mod repository;
|
||||||
@@ -19,4 +20,5 @@ pub use runtime::VnidropCore;
|
|||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
#[path = "tests.rs"]
|
||||||
|
mod core_tests;
|
||||||
|
|||||||
@@ -9,11 +9,24 @@ use sqlx::{
|
|||||||
use crate::api::{CoreEvent, StoredTransfer};
|
use crate::api::{CoreEvent, StoredTransfer};
|
||||||
use crate::util::now_ms;
|
use crate::util::now_ms;
|
||||||
|
|
||||||
|
const SCHEMA_VERSION: i64 = 1;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct Repository {
|
pub(crate) struct Repository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct TransferUpsert<'a> {
|
||||||
|
pub(crate) transfer_id: u64,
|
||||||
|
pub(crate) direction: &'a str,
|
||||||
|
pub(crate) status: &'a str,
|
||||||
|
pub(crate) transfer_name: Option<&'a str>,
|
||||||
|
pub(crate) content_hash: Option<&'a str>,
|
||||||
|
pub(crate) ticket: Option<&'a str>,
|
||||||
|
pub(crate) file_count: u64,
|
||||||
|
pub(crate) total_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
impl Repository {
|
impl Repository {
|
||||||
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
|
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
|
||||||
let db_path = app_data_dir.join("vnidrop.sqlite3");
|
let db_path = app_data_dir.join("vnidrop.sqlite3");
|
||||||
@@ -30,6 +43,8 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_schema(&self) -> Result<()> {
|
async fn ensure_schema(&self) -> Result<()> {
|
||||||
|
// The app owns this SQLite file. Keep migrations explicit so future
|
||||||
|
// desktop/mobile releases can move user history forward in place.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS transfers (
|
CREATE TABLE IF NOT EXISTS transfers (
|
||||||
@@ -71,20 +86,22 @@ impl Repository {
|
|||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn upsert_transfer(
|
#[cfg(test)]
|
||||||
&self,
|
pub(crate) async fn schema_version(&self) -> Result<i64> {
|
||||||
transfer_id: u64,
|
let row = sqlx::query("PRAGMA user_version")
|
||||||
direction: &str,
|
.fetch_one(&self.pool)
|
||||||
status: &str,
|
.await?;
|
||||||
transfer_name: Option<&str>,
|
Ok(row.get(0))
|
||||||
content_hash: Option<&str>,
|
}
|
||||||
ticket: Option<&str>,
|
|
||||||
file_count: u64,
|
pub(crate) async fn upsert_transfer(&self, transfer: TransferUpsert<'_>) -> Result<()> {
|
||||||
total_size: u64,
|
|
||||||
) -> Result<()> {
|
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -104,14 +121,14 @@ impl Repository {
|
|||||||
updated_at = excluded.updated_at;
|
updated_at = excluded.updated_at;
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(transfer_id as i64)
|
.bind(transfer.transfer_id as i64)
|
||||||
.bind(direction)
|
.bind(transfer.direction)
|
||||||
.bind(status)
|
.bind(transfer.status)
|
||||||
.bind(transfer_name)
|
.bind(transfer.transfer_name)
|
||||||
.bind(content_hash)
|
.bind(transfer.content_hash)
|
||||||
.bind(ticket)
|
.bind(transfer.ticket)
|
||||||
.bind(file_count as i64)
|
.bind(transfer.file_count as i64)
|
||||||
.bind(total_size as i64)
|
.bind(transfer.total_size as i64)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use std::{
|
|||||||
fs::File,
|
fs::File,
|
||||||
io,
|
io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -21,7 +24,10 @@ use iroh_blobs::{
|
|||||||
};
|
};
|
||||||
use n0_future::BufferedStreamExt;
|
use n0_future::BufferedStreamExt;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::{mpsc, oneshot, Mutex as TokioMutex};
|
use tokio::{
|
||||||
|
sync::{mpsc, oneshot, Mutex as TokioMutex},
|
||||||
|
task::JoinHandle,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
access_policy::{AccessDecision, AccessPolicy},
|
access_policy::{AccessDecision, AccessPolicy},
|
||||||
@@ -30,18 +36,26 @@ use crate::{
|
|||||||
StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
|
StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
|
||||||
},
|
},
|
||||||
error::VnidropError,
|
error::VnidropError,
|
||||||
|
event_hub::EventHub,
|
||||||
filesystem::{
|
filesystem::{
|
||||||
collect_import_files, default_collection_name, platform_path,
|
collect_import_files, default_collection_name, platform_path,
|
||||||
read_stream_from_blocking_reader, safe_output_path, wait_for_writer,
|
read_stream_from_blocking_reader, safe_output_path, wait_for_writer,
|
||||||
write_stream_to_blocking_writer, TransferImport,
|
write_stream_to_blocking_writer, TransferImport,
|
||||||
},
|
},
|
||||||
logging::init_logging,
|
logging::init_logging,
|
||||||
repository::Repository,
|
repository::{Repository, TransferUpsert},
|
||||||
secret::load_or_create_secret,
|
secret::load_or_create_secret,
|
||||||
ticket::{parse_transfer_ticket, ParsedTransferTicket, VnidropTicket},
|
ticket::{parse_transfer_ticket, ParsedTransferTicket, VnidropTicket},
|
||||||
util::{non_empty, now_ms, unique_transfer_id},
|
util::{non_empty, unique_transfer_id},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_SHARING: &str = "sharing";
|
||||||
|
const STATUS_RECEIVING: &str = "receiving";
|
||||||
|
const STATUS_DONE: &str = "done";
|
||||||
|
const STATUS_CANCELLED: &str = "cancelled";
|
||||||
|
const STATUS_STOPPED: &str = "stopped";
|
||||||
|
const STATUS_FAILED: &str = "failed";
|
||||||
|
|
||||||
#[derive(uniffi::Object)]
|
#[derive(uniffi::Object)]
|
||||||
pub struct VnidropCore {
|
pub struct VnidropCore {
|
||||||
runtime: tokio::runtime::Runtime,
|
runtime: tokio::runtime::Runtime,
|
||||||
@@ -49,17 +63,20 @@ pub struct VnidropCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct CoreInner {
|
struct CoreInner {
|
||||||
|
// Kotlin owns app lifecycle and platform file picking; this Rust object
|
||||||
|
// owns the Iroh endpoint, blob store, transfer history, and byte streaming.
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
router: Router,
|
router: Router,
|
||||||
store: FsStore,
|
store: FsStore,
|
||||||
repository: Repository,
|
repository: Repository,
|
||||||
|
event_hub: EventHub,
|
||||||
access_policy: Arc<AccessPolicy>,
|
access_policy: Arc<AccessPolicy>,
|
||||||
event_sink: Arc<dyn CoreEventSink>,
|
|
||||||
active_transfers: TokioMutex<HashMap<u64, oneshot::Sender<()>>>,
|
active_transfers: TokioMutex<HashMap<u64, oneshot::Sender<()>>>,
|
||||||
active_shares: TokioMutex<HashMap<u64, TempTag>>,
|
active_shares: TokioMutex<HashMap<u64, TempTag>>,
|
||||||
hash_to_transfer: TokioMutex<HashMap<String, u64>>,
|
hash_to_transfer: TokioMutex<HashMap<String, u64>>,
|
||||||
connection_endpoints: TokioMutex<HashMap<u64, String>>,
|
connection_endpoints: TokioMutex<HashMap<u64, String>>,
|
||||||
sequence: Mutex<u64>,
|
provider_task: TokioMutex<Option<JoinHandle<()>>>,
|
||||||
|
shutdown_started: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
@@ -74,7 +91,9 @@ impl VnidropCore {
|
|||||||
.thread_name("vnidrop")
|
.thread_name("vnidrop")
|
||||||
.build()?;
|
.build()?;
|
||||||
let app_data_dir = PathBuf::from(app_data_dir);
|
let app_data_dir = PathBuf::from(app_data_dir);
|
||||||
let inner = runtime.block_on(CoreInner::start(app_data_dir, event_sink))?;
|
let inner = runtime
|
||||||
|
.block_on(CoreInner::start(app_data_dir, event_sink))
|
||||||
|
.map_err(VnidropError::initialization)?;
|
||||||
Ok(Arc::new(Self { runtime, inner }))
|
Ok(Arc::new(Self { runtime, inner }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +108,7 @@ impl VnidropCore {
|
|||||||
) -> Result<ShareResult, VnidropError> {
|
) -> Result<ShareResult, VnidropError> {
|
||||||
self.runtime
|
self.runtime
|
||||||
.block_on(self.inner.share_files(sources, metadata))
|
.block_on(self.inner.share_files(sources, metadata))
|
||||||
.map_err(Into::into)
|
.map_err(VnidropError::transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn receive(
|
pub fn receive(
|
||||||
@@ -98,16 +117,29 @@ impl VnidropCore {
|
|||||||
output_dir: String,
|
output_dir: String,
|
||||||
receiver_name: Option<String>,
|
receiver_name: Option<String>,
|
||||||
) -> Result<(), VnidropError> {
|
) -> Result<(), VnidropError> {
|
||||||
let output_dir = platform_path(&output_dir)?;
|
if let Err(error) =
|
||||||
|
parse_transfer_ticket(&ticket).context("failed to parse transfer ticket")
|
||||||
|
{
|
||||||
|
self.runtime.block_on(async {
|
||||||
|
self.inner.emit_endpoint(
|
||||||
|
"error",
|
||||||
|
"invalid-ticket",
|
||||||
|
json!({ "reason": error.to_string() }),
|
||||||
|
);
|
||||||
|
self.inner.event_hub.flush().await;
|
||||||
|
});
|
||||||
|
return Err(VnidropError::ticket(error));
|
||||||
|
}
|
||||||
|
let output_dir = platform_path(&output_dir).map_err(VnidropError::filesystem)?;
|
||||||
self.runtime
|
self.runtime
|
||||||
.block_on(self.inner.receive(ticket, output_dir, receiver_name))
|
.block_on(self.inner.receive(ticket, output_dir, receiver_name))
|
||||||
.map_err(Into::into)
|
.map_err(VnidropError::transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> {
|
pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> {
|
||||||
self.runtime
|
self.runtime
|
||||||
.block_on(self.inner.cancel_transfer(transfer_id))
|
.block_on(self.inner.cancel_transfer(transfer_id))
|
||||||
.map_err(Into::into)
|
.map_err(VnidropError::transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_transfer_access_mode(
|
pub fn set_transfer_access_mode(
|
||||||
@@ -117,7 +149,7 @@ impl VnidropCore {
|
|||||||
) -> Result<(), VnidropError> {
|
) -> Result<(), VnidropError> {
|
||||||
self.runtime
|
self.runtime
|
||||||
.block_on(self.inner.set_transfer_access_mode(transfer_id, mode))
|
.block_on(self.inner.set_transfer_access_mode(transfer_id, mode))
|
||||||
.map_err(Into::into)
|
.map_err(VnidropError::permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn approve_endpoint_for_transfer(
|
pub fn approve_endpoint_for_transfer(
|
||||||
@@ -130,23 +162,25 @@ impl VnidropCore {
|
|||||||
self.inner
|
self.inner
|
||||||
.approve_endpoint_for_transfer(transfer_id, endpoint_id),
|
.approve_endpoint_for_transfer(transfer_id, endpoint_id),
|
||||||
)
|
)
|
||||||
.map_err(Into::into)
|
.map_err(VnidropError::permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
||||||
self.runtime
|
self.runtime
|
||||||
.block_on(self.inner.repository.list_transfers())
|
.block_on(self.inner.repository.list_transfers())
|
||||||
.map_err(Into::into)
|
.map_err(VnidropError::repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>, VnidropError> {
|
pub fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>, VnidropError> {
|
||||||
self.runtime
|
self.runtime
|
||||||
.block_on(self.inner.repository.list_events(transfer_id))
|
.block_on(self.inner.list_events(transfer_id))
|
||||||
.map_err(Into::into)
|
.map_err(VnidropError::repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn inspect_ticket(&self, ticket: String) -> Result<TicketInspection, VnidropError> {
|
pub fn inspect_ticket(&self, ticket: String) -> Result<TicketInspection, VnidropError> {
|
||||||
let parsed = parse_transfer_ticket(&ticket).context("failed to parse transfer ticket")?;
|
let parsed = parse_transfer_ticket(&ticket)
|
||||||
|
.context("failed to parse transfer ticket")
|
||||||
|
.map_err(VnidropError::ticket)?;
|
||||||
Ok(TicketInspection {
|
Ok(TicketInspection {
|
||||||
kind: if parsed.metadata.is_some() {
|
kind: if parsed.metadata.is_some() {
|
||||||
"vnidrop".to_string()
|
"vnidrop".to_string()
|
||||||
@@ -177,24 +211,28 @@ impl CoreInner {
|
|||||||
.await?;
|
.await?;
|
||||||
endpoint.online().await;
|
endpoint.online().await;
|
||||||
|
|
||||||
|
// Provider events are where the sender sees remote readers. The core
|
||||||
|
// uses them for send progress and for the current approval gate.
|
||||||
let (events, event_rx) = EventSender::channel(128, EventMask::ALL_READONLY);
|
let (events, event_rx) = EventSender::channel(128, EventMask::ALL_READONLY);
|
||||||
let blobs = BlobsProtocol::new(&store, Some(events));
|
let blobs = BlobsProtocol::new(&store, Some(events));
|
||||||
let router = Router::builder(endpoint.clone())
|
let router = Router::builder(endpoint.clone())
|
||||||
.accept(iroh_blobs::ALPN, blobs)
|
.accept(iroh_blobs::ALPN, blobs)
|
||||||
.spawn();
|
.spawn();
|
||||||
|
let event_hub = EventHub::start(repository.clone(), event_sink);
|
||||||
|
|
||||||
let inner = Arc::new(Self {
|
let inner = Arc::new(Self {
|
||||||
endpoint,
|
endpoint,
|
||||||
router,
|
router,
|
||||||
store,
|
store,
|
||||||
repository,
|
repository,
|
||||||
|
event_hub,
|
||||||
access_policy: AccessPolicy::new(),
|
access_policy: AccessPolicy::new(),
|
||||||
event_sink,
|
|
||||||
active_transfers: TokioMutex::new(HashMap::new()),
|
active_transfers: TokioMutex::new(HashMap::new()),
|
||||||
active_shares: TokioMutex::new(HashMap::new()),
|
active_shares: TokioMutex::new(HashMap::new()),
|
||||||
hash_to_transfer: TokioMutex::new(HashMap::new()),
|
hash_to_transfer: TokioMutex::new(HashMap::new()),
|
||||||
connection_endpoints: TokioMutex::new(HashMap::new()),
|
connection_endpoints: TokioMutex::new(HashMap::new()),
|
||||||
sequence: Mutex::new(1),
|
provider_task: TokioMutex::new(None),
|
||||||
|
shutdown_started: AtomicBool::new(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
inner.emit_endpoint(
|
inner.emit_endpoint(
|
||||||
@@ -206,7 +244,7 @@ impl CoreInner {
|
|||||||
"store_root": store_root.to_string_lossy(),
|
"store_root": store_root.to_string_lossy(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
inner.spawn_provider_event_task(event_rx);
|
inner.spawn_provider_event_task(event_rx).await;
|
||||||
Ok(inner)
|
Ok(inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +261,29 @@ impl CoreInner {
|
|||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
sources: Vec<ShareSource>,
|
sources: Vec<ShareSource>,
|
||||||
metadata: ShareMetadataInput,
|
metadata: ShareMetadataInput,
|
||||||
|
) -> Result<ShareResult> {
|
||||||
|
let transfer_id = metadata.transfer_id;
|
||||||
|
let result = self.share_files_inner(sources, metadata).await;
|
||||||
|
if let Err(error) = &result {
|
||||||
|
self.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
"send",
|
||||||
|
"error",
|
||||||
|
"failed",
|
||||||
|
json!({ "reason": error.to_string() }),
|
||||||
|
);
|
||||||
|
let _ = self
|
||||||
|
.repository
|
||||||
|
.update_transfer_status(transfer_id, STATUS_FAILED)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn share_files_inner(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
sources: Vec<ShareSource>,
|
||||||
|
metadata: ShareMetadataInput,
|
||||||
) -> Result<ShareResult> {
|
) -> Result<ShareResult> {
|
||||||
if sources.is_empty() {
|
if sources.is_empty() {
|
||||||
anyhow::bail!("at least one source is required");
|
anyhow::bail!("at least one source is required");
|
||||||
@@ -266,16 +327,16 @@ impl CoreInner {
|
|||||||
.await
|
.await
|
||||||
.insert(metadata.transfer_id, import.tag);
|
.insert(metadata.transfer_id, import.tag);
|
||||||
self.repository
|
self.repository
|
||||||
.upsert_transfer(
|
.upsert_transfer(TransferUpsert {
|
||||||
metadata.transfer_id,
|
transfer_id: metadata.transfer_id,
|
||||||
"send",
|
direction: "send",
|
||||||
"sharing",
|
status: STATUS_SHARING,
|
||||||
Some(&transfer_name),
|
transfer_name: Some(&transfer_name),
|
||||||
Some(&import.root_hash.to_string()),
|
content_hash: Some(&import.root_hash.to_string()),
|
||||||
Some(&ticket),
|
ticket: Some(&ticket),
|
||||||
import.file_count,
|
file_count: import.file_count,
|
||||||
import.total_size,
|
total_size: import.total_size,
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
self.emit_transfer(
|
self.emit_transfer(
|
||||||
@@ -308,12 +369,25 @@ impl CoreInner {
|
|||||||
output_dir: PathBuf,
|
output_dir: PathBuf,
|
||||||
receiver_name: Option<String>,
|
receiver_name: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let parsed = parse_transfer_ticket(&ticket).context("failed to parse transfer ticket")?;
|
let parsed = match parse_transfer_ticket(&ticket).context("failed to parse transfer ticket")
|
||||||
|
{
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(error) => {
|
||||||
|
self.emit_endpoint(
|
||||||
|
"error",
|
||||||
|
"invalid-ticket",
|
||||||
|
json!({ "reason": error.to_string() }),
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
let transfer_id = parsed
|
let transfer_id = parsed
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|metadata| metadata.transfer_id)
|
.map(|metadata| metadata.transfer_id)
|
||||||
.unwrap_or_else(unique_transfer_id);
|
.unwrap_or_else(unique_transfer_id);
|
||||||
|
// Cancellation is cooperative: it stops our receive future and marks
|
||||||
|
// local state while lower-level Iroh work unwinds naturally.
|
||||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||||
self.active_transfers
|
self.active_transfers
|
||||||
.lock()
|
.lock()
|
||||||
@@ -326,6 +400,19 @@ impl CoreInner {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.active_transfers.lock().await.remove(&transfer_id);
|
self.active_transfers.lock().await.remove(&transfer_id);
|
||||||
|
if let Err(error) = &result {
|
||||||
|
self.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
"receive",
|
||||||
|
"error",
|
||||||
|
"failed",
|
||||||
|
json!({ "reason": error.to_string() }),
|
||||||
|
);
|
||||||
|
let _ = self
|
||||||
|
.repository
|
||||||
|
.update_transfer_status(transfer_id, STATUS_FAILED)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +423,8 @@ impl CoreInner {
|
|||||||
output_dir: PathBuf,
|
output_dir: PathBuf,
|
||||||
receiver_name: Option<String>,
|
receiver_name: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let metadata_json = serde_json::to_value(&parsed.metadata).unwrap_or_else(|_| json!(null));
|
let metadata_json =
|
||||||
|
serde_json::to_value(&parsed.metadata).unwrap_or(serde_json::Value::Null);
|
||||||
self.emit_transfer(
|
self.emit_transfer(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
"receive",
|
"receive",
|
||||||
@@ -348,30 +436,30 @@ impl CoreInner {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
self.repository
|
self.repository
|
||||||
.upsert_transfer(
|
.upsert_transfer(TransferUpsert {
|
||||||
transfer_id,
|
transfer_id,
|
||||||
"receive",
|
direction: "receive",
|
||||||
"receiving",
|
status: STATUS_RECEIVING,
|
||||||
parsed
|
transfer_name: parsed
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|metadata| metadata.transfer_name.as_str()),
|
.map(|metadata| metadata.transfer_name.as_str()),
|
||||||
parsed
|
content_hash: parsed
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|metadata| metadata.content_hash.as_str()),
|
.map(|metadata| metadata.content_hash.as_str()),
|
||||||
None,
|
ticket: None,
|
||||||
parsed
|
file_count: parsed
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|metadata| metadata.file_count)
|
.map(|metadata| metadata.file_count)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
parsed
|
total_size: parsed
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|metadata| metadata.total_size)
|
.map(|metadata| metadata.total_size)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
tokio::fs::create_dir_all(&output_dir).await?;
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
|
|
||||||
@@ -419,7 +507,7 @@ impl CoreInner {
|
|||||||
self.export_collection(transfer_id, total_files, output_dir, collection)
|
self.export_collection(transfer_id, total_files, output_dir, collection)
|
||||||
.await?;
|
.await?;
|
||||||
self.repository
|
self.repository
|
||||||
.update_transfer_status(transfer_id, "done")
|
.update_transfer_status(transfer_id, STATUS_DONE)
|
||||||
.await?;
|
.await?;
|
||||||
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -436,7 +524,7 @@ impl CoreInner {
|
|||||||
json!({}),
|
json!({}),
|
||||||
);
|
);
|
||||||
self.repository
|
self.repository
|
||||||
.update_transfer_status(transfer_id, "cancelled")
|
.update_transfer_status(transfer_id, STATUS_CANCELLED)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -453,7 +541,7 @@ impl CoreInner {
|
|||||||
.retain(|_, id| *id != transfer_id);
|
.retain(|_, id| *id != transfer_id);
|
||||||
self.access_policy.remove_transfer(transfer_id).await;
|
self.access_policy.remove_transfer(transfer_id).await;
|
||||||
self.repository
|
self.repository
|
||||||
.update_transfer_status(transfer_id, "stopped")
|
.update_transfer_status(transfer_id, STATUS_STOPPED)
|
||||||
.await?;
|
.await?;
|
||||||
self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({}));
|
self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({}));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -496,7 +584,13 @@ impl CoreInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn shutdown(&self) {
|
async fn shutdown(&self) {
|
||||||
|
if self.shutdown_started.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.emit_endpoint("shutdown", "service-shutdown", json!({}));
|
self.emit_endpoint("shutdown", "service-shutdown", json!({}));
|
||||||
|
// Flush before stopping the router so the app can show the shutdown
|
||||||
|
// event even if the process exits soon after Compose disposes the core.
|
||||||
|
self.event_hub.flush().await;
|
||||||
if let Err(error) = self.router.shutdown().await {
|
if let Err(error) = self.router.shutdown().await {
|
||||||
self.emit_endpoint(
|
self.emit_endpoint(
|
||||||
"shutdown",
|
"shutdown",
|
||||||
@@ -504,6 +598,11 @@ impl CoreInner {
|
|||||||
json!({ "error": error.to_string() }),
|
json!({ "error": error.to_string() }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(task) = self.provider_task.lock().await.take() {
|
||||||
|
task.abort();
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
self.event_hub.shutdown().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn import_sources(
|
async fn import_sources(
|
||||||
@@ -693,13 +792,14 @@ impl CoreInner {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_provider_event_task(self: &Arc<Self>, mut rx: mpsc::Receiver<ProviderMessage>) {
|
async fn spawn_provider_event_task(self: &Arc<Self>, mut rx: mpsc::Receiver<ProviderMessage>) {
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
while let Some(message) = rx.recv().await {
|
while let Some(message) = rx.recv().await {
|
||||||
core.handle_provider_message(message).await;
|
core.handle_provider_message(message).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
*self.provider_task.lock().await = Some(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_provider_message(self: &Arc<Self>, message: ProviderMessage) {
|
async fn handle_provider_message(self: &Arc<Self>, message: ProviderMessage) {
|
||||||
@@ -917,6 +1017,9 @@ impl CoreInner {
|
|||||||
request_id: u64,
|
request_id: u64,
|
||||||
mut rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
|
mut rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
|
||||||
) {
|
) {
|
||||||
|
// Request update tasks are tied to individual provider streams. Router
|
||||||
|
// shutdown closes those streams; only the long-lived provider receiver
|
||||||
|
// is tracked directly for explicit shutdown.
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(Some(update)) = rx.recv().await {
|
while let Ok(Some(update)) = rx.recv().await {
|
||||||
@@ -965,7 +1068,7 @@ impl CoreInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn emit_endpoint(&self, phase: &str, kind: &str, data: serde_json::Value) {
|
fn emit_endpoint(&self, phase: &str, kind: &str, data: serde_json::Value) {
|
||||||
self.emit("endpoint", None, None, phase, kind, data);
|
self.event_hub.emit_endpoint(phase, kind, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_transfer(
|
fn emit_transfer(
|
||||||
@@ -976,47 +1079,12 @@ impl CoreInner {
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
data: serde_json::Value,
|
data: serde_json::Value,
|
||||||
) {
|
) {
|
||||||
self.emit(
|
self.event_hub
|
||||||
"transfer",
|
.emit_transfer(transfer_id, direction, phase, kind, data);
|
||||||
Some(transfer_id),
|
|
||||||
Some(direction.to_string()),
|
|
||||||
phase,
|
|
||||||
kind,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit(
|
async fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>> {
|
||||||
&self,
|
self.event_hub.flush().await;
|
||||||
scope: &str,
|
self.repository.list_events(transfer_id).await
|
||||||
transfer_id: Option<u64>,
|
|
||||||
direction: Option<String>,
|
|
||||||
phase: &str,
|
|
||||||
kind: &str,
|
|
||||||
data: serde_json::Value,
|
|
||||||
) {
|
|
||||||
let timestamp = now_ms();
|
|
||||||
let mut sequence = self.sequence.lock().expect("event sequence lock poisoned");
|
|
||||||
let id = format!("{timestamp}-{}", *sequence);
|
|
||||||
*sequence += 1;
|
|
||||||
drop(sequence);
|
|
||||||
let event = CoreEvent {
|
|
||||||
id,
|
|
||||||
timestamp,
|
|
||||||
scope: scope.to_string(),
|
|
||||||
transfer_id,
|
|
||||||
direction,
|
|
||||||
phase: phase.to_string(),
|
|
||||||
kind: kind.to_string(),
|
|
||||||
data_json: data.to_string(),
|
|
||||||
};
|
|
||||||
let repository = self.repository.clone();
|
|
||||||
let event_for_repository = event.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(error) = repository.insert_event(&event_for_repository).await {
|
|
||||||
tracing::warn!(%error, "failed to persist core event");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
self.event_sink.on_event(event);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ mod tests {
|
|||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
use std::{io::Read, path::Path, sync::Arc};
|
use std::{io::Read, path::Path, sync::Arc};
|
||||||
|
|
||||||
|
use data_encoding::BASE64URL_NOPAD;
|
||||||
use iroh::SecretKey;
|
use iroh::SecretKey;
|
||||||
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
|
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
access_policy::{AccessDecision, AccessPolicy},
|
access_policy::{AccessDecision, AccessPolicy},
|
||||||
api::{CoreEvent, CoreEventSink, ShareSource, SourceKind, TransferMetadata},
|
api::{CoreEvent, CoreEventSink, ShareSource, SourceKind, TransferMetadata},
|
||||||
|
error::VnidropError,
|
||||||
filesystem::{
|
filesystem::{
|
||||||
collect_import_files, path_to_string, percent_decode_file_url_path,
|
collect_import_files, default_collection_name, path_to_string,
|
||||||
validated_relative_string,
|
percent_decode_file_url_path, validated_relative_string,
|
||||||
},
|
},
|
||||||
repository::Repository,
|
repository::Repository,
|
||||||
runtime::VnidropCore,
|
runtime::VnidropCore,
|
||||||
@@ -77,6 +80,58 @@ mod tests {
|
|||||||
assert!(parse_transfer_ticket("not-a-ticket").is_err());
|
assert!(parse_transfer_ticket("not-a-ticket").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_rejects_unsupported_versions_and_mismatched_hashes() {
|
||||||
|
let secret = SecretKey::generate();
|
||||||
|
let addr = iroh::EndpointAddr::new(secret.public());
|
||||||
|
let blob_ticket = BlobTicket::new(addr, Hash::new([5; 32]), BlobFormat::HashSeq);
|
||||||
|
let payload = json!({
|
||||||
|
"version": 2,
|
||||||
|
"blob_ticket": blob_ticket.to_string(),
|
||||||
|
"metadata": {
|
||||||
|
"version": 1,
|
||||||
|
"transfer_id": 7,
|
||||||
|
"transfer_name": "bad version",
|
||||||
|
"sender_name": null,
|
||||||
|
"created_at": 1,
|
||||||
|
"content_hash": blob_ticket.hash().to_string(),
|
||||||
|
"file_count": 1,
|
||||||
|
"total_size": 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let encoded = format!(
|
||||||
|
"vnd1:{}",
|
||||||
|
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
|
||||||
|
);
|
||||||
|
assert!(parse_transfer_ticket(&encoded)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("unsupported VniDrop ticket version"));
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"version": 1,
|
||||||
|
"blob_ticket": blob_ticket.to_string(),
|
||||||
|
"metadata": {
|
||||||
|
"version": 1,
|
||||||
|
"transfer_id": 7,
|
||||||
|
"transfer_name": "bad hash",
|
||||||
|
"sender_name": null,
|
||||||
|
"created_at": 1,
|
||||||
|
"content_hash": Hash::new([6; 32]).to_string(),
|
||||||
|
"file_count": 1,
|
||||||
|
"total_size": 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let encoded = format!(
|
||||||
|
"vnd1:{}",
|
||||||
|
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
|
||||||
|
);
|
||||||
|
assert!(parse_transfer_ticket(&encoded)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("metadata hash does not match"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn secret_persists() {
|
async fn secret_persists() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -90,6 +145,7 @@ mod tests {
|
|||||||
assert!(path_to_string(Path::new("../escape"), true).is_err());
|
assert!(path_to_string(Path::new("../escape"), true).is_err());
|
||||||
assert!(path_to_string(Path::new("/absolute"), true).is_err());
|
assert!(path_to_string(Path::new("/absolute"), true).is_err());
|
||||||
assert!(validated_relative_string("bad\\name").is_err());
|
assert!(validated_relative_string("bad\\name").is_err());
|
||||||
|
assert!(validated_relative_string("").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -121,6 +177,61 @@ mod tests {
|
|||||||
assert_eq!(content, "fd-backed import");
|
assert_eq!(content, "fd-backed import");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn file_descriptor_source_rejects_invalid_values() {
|
||||||
|
assert!(collect_import_files(vec![ShareSource {
|
||||||
|
kind: SourceKind::FileDescriptor,
|
||||||
|
value: "not-an-fd".to_string(),
|
||||||
|
display_name: Some("from-fd.txt".to_string()),
|
||||||
|
is_directory: false,
|
||||||
|
}])
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
assert!(collect_import_files(vec![ShareSource {
|
||||||
|
kind: SourceKind::FileDescriptor,
|
||||||
|
value: "-1".to_string(),
|
||||||
|
display_name: Some("from-fd.txt".to_string()),
|
||||||
|
is_directory: false,
|
||||||
|
}])
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_content_uri_must_be_opened_by_platform_code() {
|
||||||
|
let error = collect_import_files(vec![ShareSource {
|
||||||
|
kind: SourceKind::AndroidContentUri,
|
||||||
|
value: "content://media/item".to_string(),
|
||||||
|
display_name: Some("from-uri.txt".to_string()),
|
||||||
|
is_directory: false,
|
||||||
|
}])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("ParcelFileDescriptor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_sources_preserve_safe_relative_names() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("picked");
|
||||||
|
std::fs::create_dir_all(root.join("nested")).unwrap();
|
||||||
|
std::fs::write(root.join("nested").join("a.txt"), b"a").unwrap();
|
||||||
|
std::fs::write(root.join("b.txt"), b"b").unwrap();
|
||||||
|
|
||||||
|
let mut files = collect_import_files(vec![ShareSource {
|
||||||
|
kind: SourceKind::Path,
|
||||||
|
value: root.to_string_lossy().to_string(),
|
||||||
|
display_name: Some("Album".to_string()),
|
||||||
|
is_directory: true,
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
files.sort_by(|a, b| a.collection_name.cmp(&b.collection_name));
|
||||||
|
|
||||||
|
assert_eq!(default_collection_name(&files), "Album");
|
||||||
|
assert_eq!(files[0].collection_name, "Album/b.txt");
|
||||||
|
assert_eq!(files[1].collection_name, "Album/nested/a.txt");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn can_initialize_core() {
|
fn can_initialize_core() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -138,17 +249,18 @@ mod tests {
|
|||||||
async fn repository_persists_transfers_and_events() {
|
async fn repository_persists_transfers_and_events() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let repository = Repository::open(temp.path()).await.unwrap();
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
assert_eq!(repository.schema_version().await.unwrap(), 1);
|
||||||
repository
|
repository
|
||||||
.upsert_transfer(
|
.upsert_transfer(crate::repository::TransferUpsert {
|
||||||
7,
|
transfer_id: 7,
|
||||||
"send",
|
direction: "send",
|
||||||
"sharing",
|
status: "sharing",
|
||||||
Some("demo"),
|
transfer_name: Some("demo"),
|
||||||
Some("hash"),
|
content_hash: Some("hash"),
|
||||||
Some("ticket"),
|
ticket: Some("ticket"),
|
||||||
1,
|
file_count: 1,
|
||||||
12,
|
total_size: 12,
|
||||||
)
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repository
|
repository
|
||||||
@@ -172,6 +284,12 @@ mod tests {
|
|||||||
let events = repository.list_events(Some(7)).await.unwrap();
|
let events = repository.list_events(Some(7)).await.unwrap();
|
||||||
assert_eq!(events.len(), 1);
|
assert_eq!(events.len(), 1);
|
||||||
assert_eq!(events[0].kind, "created");
|
assert_eq!(events[0].kind, "created");
|
||||||
|
|
||||||
|
let reopened = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let transfers = reopened.list_transfers().await.unwrap();
|
||||||
|
assert_eq!(transfers.len(), 1);
|
||||||
|
let events = reopened.list_events(Some(7)).await.unwrap();
|
||||||
|
assert_eq!(events[0].id, "event-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -193,5 +311,36 @@ mod tests {
|
|||||||
policy.decide(99, Some("node-a")).await,
|
policy.decide(99, Some("node-a")).await,
|
||||||
AccessDecision::Allow
|
AccessDecision::Allow
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
policy.decide(99, None).await,
|
||||||
|
AccessDecision::Deny {
|
||||||
|
reason: "missing-endpoint-id"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let core = VnidropCore::initialize(
|
||||||
|
temp.path().to_string_lossy().to_string(),
|
||||||
|
Arc::new(TestSink),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = core
|
||||||
|
.receive(
|
||||||
|
"not-a-ticket".to_string(),
|
||||||
|
temp.path().to_string_lossy().to_string(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, VnidropError::Ticket { .. }));
|
||||||
|
|
||||||
|
let events = core.list_events(None).unwrap();
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.phase == "error" && event.kind == "invalid-ticket"));
|
||||||
|
core.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::api::TransferMetadata;
|
use crate::api::TransferMetadata;
|
||||||
|
|
||||||
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
|
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
|
||||||
|
const VNIDROP_TICKET_VERSION: u8 = 1;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub(crate) struct VnidropTicket {
|
pub(crate) struct VnidropTicket {
|
||||||
@@ -19,7 +20,7 @@ pub(crate) struct VnidropTicket {
|
|||||||
impl VnidropTicket {
|
impl VnidropTicket {
|
||||||
pub(crate) fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self {
|
pub(crate) fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self {
|
||||||
Self {
|
Self {
|
||||||
version: 1,
|
version: VNIDROP_TICKET_VERSION,
|
||||||
blob_ticket: blob_ticket.to_string(),
|
blob_ticket: blob_ticket.to_string(),
|
||||||
metadata,
|
metadata,
|
||||||
}
|
}
|
||||||
@@ -55,8 +56,26 @@ pub(crate) fn parse_transfer_ticket(value: &str) -> Result<ParsedTransferTicket>
|
|||||||
let normalized = normalize_ticket_input(value);
|
let normalized = normalize_ticket_input(value);
|
||||||
if normalized.starts_with(VNIDROP_TICKET_PREFIX) {
|
if normalized.starts_with(VNIDROP_TICKET_PREFIX) {
|
||||||
let ticket = VnidropTicket::decode(&normalized)?;
|
let ticket = VnidropTicket::decode(&normalized)?;
|
||||||
|
if ticket.version != VNIDROP_TICKET_VERSION {
|
||||||
|
anyhow::bail!("unsupported VniDrop ticket version {}", ticket.version);
|
||||||
|
}
|
||||||
|
if ticket.metadata.version != VNIDROP_TICKET_VERSION {
|
||||||
|
anyhow::bail!(
|
||||||
|
"unsupported VniDrop metadata version {}",
|
||||||
|
ticket.metadata.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ticket.metadata.transfer_id == 0 {
|
||||||
|
anyhow::bail!("VniDrop ticket metadata is missing a valid transfer id");
|
||||||
|
}
|
||||||
|
if ticket.metadata.transfer_name.trim().is_empty() {
|
||||||
|
anyhow::bail!("VniDrop ticket metadata is missing a transfer name");
|
||||||
|
}
|
||||||
let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
|
let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
|
||||||
.context("invalid BlobTicket inside VniDrop ticket")?;
|
.context("invalid BlobTicket inside VniDrop ticket")?;
|
||||||
|
if ticket.metadata.content_hash != blob_ticket.hash().to_string() {
|
||||||
|
anyhow::bail!("VniDrop ticket metadata hash does not match BlobTicket hash");
|
||||||
|
}
|
||||||
return Ok(ParsedTransferTicket {
|
return Ok(ParsedTransferTicket {
|
||||||
blob_ticket,
|
blob_ticket,
|
||||||
metadata: Some(ticket.metadata),
|
metadata: Some(ticket.metadata),
|
||||||
@@ -71,5 +90,8 @@ pub(crate) fn parse_transfer_ticket(value: &str) -> Result<ParsedTransferTicket>
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_ticket_input(value: &str) -> String {
|
fn normalize_ticket_input(value: &str) -> String {
|
||||||
|
// Tickets are commonly copied from text views or chat apps that insert line
|
||||||
|
// breaks. Strip whitespace only; other corrupt characters should still be
|
||||||
|
// rejected by the base64 or BlobTicket decoders.
|
||||||
value.chars().filter(|char| !char.is_whitespace()).collect()
|
value.chars().filter(|char| !char.is_whitespace()).collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use vnidrop::{CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, VnidropCore};
|
use vnidrop::{
|
||||||
|
CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode,
|
||||||
|
VnidropCore,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RecordingSink {
|
struct RecordingSink {
|
||||||
@@ -13,6 +16,12 @@ impl CoreEventSink for RecordingSink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RecordingSink {
|
||||||
|
fn events(&self) -> Vec<CoreEvent> {
|
||||||
|
self.events.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_local_cores_transfer_file() {
|
fn two_local_cores_transfer_file() {
|
||||||
let sender_dir = tempfile::tempdir().unwrap();
|
let sender_dir = tempfile::tempdir().unwrap();
|
||||||
@@ -21,9 +30,11 @@ fn two_local_cores_transfer_file() {
|
|||||||
let source_path = sender_dir.path().join("hello.txt");
|
let source_path = sender_dir.path().join("hello.txt");
|
||||||
std::fs::write(&source_path, b"hello from vnidrop").unwrap();
|
std::fs::write(&source_path, b"hello from vnidrop").unwrap();
|
||||||
|
|
||||||
|
let sender_sink = Arc::new(RecordingSink::default());
|
||||||
|
let receiver_sink = Arc::new(RecordingSink::default());
|
||||||
let sender = VnidropCore::initialize(
|
let sender = VnidropCore::initialize(
|
||||||
sender_dir.path().join("core").to_string_lossy().to_string(),
|
sender_dir.path().join("core").to_string_lossy().to_string(),
|
||||||
Arc::new(RecordingSink::default()),
|
sender_sink.clone(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let receiver = VnidropCore::initialize(
|
let receiver = VnidropCore::initialize(
|
||||||
@@ -32,7 +43,7 @@ fn two_local_cores_transfer_file() {
|
|||||||
.join("core")
|
.join("core")
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
Arc::new(RecordingSink::default()),
|
receiver_sink.clone(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -68,3 +79,185 @@ fn two_local_cores_transfer_file() {
|
|||||||
sender.shutdown();
|
sender.shutdown();
|
||||||
receiver.shutdown();
|
receiver.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_local_cores_transfer_directory() {
|
||||||
|
let sender_dir = tempfile::tempdir().unwrap();
|
||||||
|
let receiver_dir = tempfile::tempdir().unwrap();
|
||||||
|
let output_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_root = sender_dir.path().join("photos");
|
||||||
|
std::fs::create_dir_all(source_root.join("nested")).unwrap();
|
||||||
|
std::fs::write(source_root.join("cover.txt"), b"cover").unwrap();
|
||||||
|
std::fs::write(source_root.join("nested").join("inside.txt"), b"inside").unwrap();
|
||||||
|
|
||||||
|
let sender = VnidropCore::initialize(
|
||||||
|
sender_dir.path().join("core").to_string_lossy().to_string(),
|
||||||
|
Arc::new(RecordingSink::default()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let receiver = VnidropCore::initialize(
|
||||||
|
receiver_dir
|
||||||
|
.path()
|
||||||
|
.join("core")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
|
Arc::new(RecordingSink::default()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let share = sender
|
||||||
|
.share_files(
|
||||||
|
vec![ShareSource {
|
||||||
|
kind: SourceKind::Path,
|
||||||
|
value: source_root.to_string_lossy().to_string(),
|
||||||
|
display_name: Some("photos".to_string()),
|
||||||
|
is_directory: true,
|
||||||
|
}],
|
||||||
|
ShareMetadataInput {
|
||||||
|
transfer_id: 8,
|
||||||
|
transfer_name: Some("photos".to_string()),
|
||||||
|
sender_name: Some("sender".to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
receiver
|
||||||
|
.receive(
|
||||||
|
share.ticket,
|
||||||
|
output_dir.path().to_string_lossy().to_string(),
|
||||||
|
Some("receiver".to_string()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(output_dir.path().join("photos").join("cover.txt")).unwrap(),
|
||||||
|
b"cover"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(
|
||||||
|
output_dir
|
||||||
|
.path()
|
||||||
|
.join("photos")
|
||||||
|
.join("nested")
|
||||||
|
.join("inside.txt")
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
b"inside"
|
||||||
|
);
|
||||||
|
|
||||||
|
sender.shutdown();
|
||||||
|
receiver.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approval_required_denies_then_allows_receiver() {
|
||||||
|
let sender_dir = tempfile::tempdir().unwrap();
|
||||||
|
let receiver_dir = tempfile::tempdir().unwrap();
|
||||||
|
let denied_output = tempfile::tempdir().unwrap();
|
||||||
|
let allowed_output = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = sender_dir.path().join("private.txt");
|
||||||
|
std::fs::write(&source_path, b"approved content").unwrap();
|
||||||
|
|
||||||
|
let sender_sink = Arc::new(RecordingSink::default());
|
||||||
|
let sender = VnidropCore::initialize(
|
||||||
|
sender_dir.path().join("core").to_string_lossy().to_string(),
|
||||||
|
sender_sink.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let receiver = VnidropCore::initialize(
|
||||||
|
receiver_dir
|
||||||
|
.path()
|
||||||
|
.join("core")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
|
Arc::new(RecordingSink::default()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let share = sender
|
||||||
|
.share_files(
|
||||||
|
vec![ShareSource {
|
||||||
|
kind: SourceKind::Path,
|
||||||
|
value: source_path.to_string_lossy().to_string(),
|
||||||
|
display_name: Some("private.txt".to_string()),
|
||||||
|
is_directory: false,
|
||||||
|
}],
|
||||||
|
ShareMetadataInput {
|
||||||
|
transfer_id: 9,
|
||||||
|
transfer_name: Some("private".to_string()),
|
||||||
|
sender_name: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
sender
|
||||||
|
.set_transfer_access_mode(share.transfer_id, TransferAccessMode::ApprovalRequired)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(receiver
|
||||||
|
.receive(
|
||||||
|
share.ticket.clone(),
|
||||||
|
denied_output.path().to_string_lossy().to_string(),
|
||||||
|
Some("receiver".to_string()),
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(sender_sink
|
||||||
|
.events()
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.phase == "access" && event.kind == "request-denied"));
|
||||||
|
|
||||||
|
sender
|
||||||
|
.approve_endpoint_for_transfer(share.transfer_id, receiver.status().endpoint_id)
|
||||||
|
.unwrap();
|
||||||
|
receiver
|
||||||
|
.receive(
|
||||||
|
share.ticket,
|
||||||
|
allowed_output.path().to_string_lossy().to_string(),
|
||||||
|
Some("receiver".to_string()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
|
||||||
|
b"approved content"
|
||||||
|
);
|
||||||
|
|
||||||
|
sender.shutdown();
|
||||||
|
receiver.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelling_share_updates_status_and_events() {
|
||||||
|
let sender_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = sender_dir.path().join("cancel.txt");
|
||||||
|
std::fs::write(&source_path, b"cancel me").unwrap();
|
||||||
|
let sink = Arc::new(RecordingSink::default());
|
||||||
|
let sender = VnidropCore::initialize(
|
||||||
|
sender_dir.path().join("core").to_string_lossy().to_string(),
|
||||||
|
sink.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let share = sender
|
||||||
|
.share_files(
|
||||||
|
vec![ShareSource {
|
||||||
|
kind: SourceKind::Path,
|
||||||
|
value: source_path.to_string_lossy().to_string(),
|
||||||
|
display_name: Some("cancel.txt".to_string()),
|
||||||
|
is_directory: false,
|
||||||
|
}],
|
||||||
|
ShareMetadataInput {
|
||||||
|
transfer_id: 10,
|
||||||
|
transfer_name: Some("cancel".to_string()),
|
||||||
|
sender_name: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
sender.cancel_transfer(share.transfer_id).unwrap();
|
||||||
|
|
||||||
|
let transfers = sender.list_transfers().unwrap();
|
||||||
|
assert_eq!(transfers[0].status, "stopped");
|
||||||
|
assert!(sink
|
||||||
|
.events()
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.kind == "share-stopped"));
|
||||||
|
sender.shutdown();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user