Refactor Rust core architecture

This commit is contained in:
2026-07-03 00:09:11 +02:00
parent 90463d7210
commit 3da576a94e
21 changed files with 2072 additions and 590 deletions

35
crates/vnidrop/Cargo.toml Normal file
View File

@@ -0,0 +1,35 @@
[package]
name = "vnidrop"
version = "0.1.0"
edition = "2021"
[lib]
name = "vnidrop"
crate-type = ["cdylib", "staticlib", "rlib"]
[dependencies]
anyhow = "1.0.102"
async-channel = "2.5.0"
bytes = "1.11.1"
data-encoding = "2.11.0"
futures = "0.3"
futures-lite = "2.6.1"
iroh = "1.0.0"
iroh-blobs = "0.103.0"
irpc = "0.17.0"
n0-future = "0.3.1"
num_cpus = "1.17.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
thiserror = "2.0.18"
tracing = "0.1.41"
tracing-appender = "0.2.4"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] }
tokio = { version = "1.52.3", features = ["full"] }
uniffi = { version = "=0.29.4", features = ["tokio"] }
uuid = { version = "1.23.3", features = ["v4", "serde"] }
walkdir = "2.5.0"
[dev-dependencies]
tempfile = "3.27.0"

View File

@@ -0,0 +1,81 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use tokio::sync::RwLock;
use crate::api::TransferAccessMode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AccessDecision {
Allow,
Deny { reason: &'static str },
}
#[derive(Debug, Default)]
pub(crate) struct AccessPolicy {
modes: RwLock<HashMap<u64, TransferAccessMode>>,
approved_sessions: RwLock<HashSet<(u64, String)>>,
}
impl AccessPolicy {
pub(crate) fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub(crate) async fn set_mode(&self, transfer_id: u64, mode: TransferAccessMode) {
self.modes.write().await.insert(transfer_id, mode);
}
pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
self.modes.write().await.remove(&transfer_id);
self.approved_sessions
.write()
.await
.retain(|(id, _)| *id != transfer_id);
}
pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) {
self.approved_sessions
.write()
.await
.insert((transfer_id, endpoint_id));
}
pub(crate) async fn decide(
&self,
transfer_id: u64,
endpoint_id: Option<&str>,
) -> AccessDecision {
match self
.modes
.read()
.await
.get(&transfer_id)
.cloned()
.unwrap_or(TransferAccessMode::Public)
{
TransferAccessMode::Public => AccessDecision::Allow,
TransferAccessMode::ApprovalRequired => {
let Some(endpoint_id) = endpoint_id else {
return AccessDecision::Deny {
reason: "missing-endpoint-id",
};
};
if self
.approved_sessions
.read()
.await
.contains(&(transfer_id, endpoint_id.to_string()))
{
AccessDecision::Allow
} else {
AccessDecision::Deny {
reason: "approval-required",
}
}
}
}
}
}

123
crates/vnidrop/src/api.rs Normal file
View File

@@ -0,0 +1,123 @@
use iroh_blobs::Hash;
use serde::{Deserialize, Serialize};
use crate::util::{non_empty, now_ms};
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct CoreEvent {
pub id: String,
pub timestamp: i64,
pub scope: String,
pub transfer_id: Option<u64>,
pub direction: Option<String>,
pub phase: String,
pub kind: String,
pub data_json: String,
}
#[uniffi::export(with_foreign)]
pub trait CoreEventSink: Send + Sync {
fn on_event(&self, event: CoreEvent);
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct RuntimeStatus {
pub endpoint_id: String,
pub addr: String,
pub active_transfers: u64,
pub active_shares: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
pub enum SourceKind {
Path,
AndroidContentUri,
IosSecurityScopedUrl,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct ShareSource {
pub kind: SourceKind,
pub value: String,
pub display_name: Option<String>,
pub is_directory: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct ShareMetadataInput {
pub transfer_id: u64,
pub transfer_name: Option<String>,
pub sender_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
pub enum TransferAccessMode {
Public,
ApprovalRequired,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct StoredTransfer {
pub transfer_id: u64,
pub direction: String,
pub status: String,
pub transfer_name: Option<String>,
pub content_hash: Option<String>,
pub ticket: Option<String>,
pub file_count: u64,
pub total_size: u64,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct ShareResult {
pub transfer_id: u64,
pub ticket: String,
pub blob_ticket: String,
pub hash: String,
pub transfer_name: String,
pub file_count: u64,
pub total_size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct TransferMetadata {
pub version: u8,
pub transfer_id: u64,
pub transfer_name: String,
pub sender_name: Option<String>,
pub created_at: i64,
pub content_hash: String,
pub file_count: u64,
pub total_size: u64,
}
impl TransferMetadata {
pub(crate) fn new(
transfer_id: u64,
transfer_name: impl Into<String>,
sender_name: Option<String>,
content_hash: Hash,
file_count: u64,
total_size: u64,
) -> Self {
Self {
version: 1,
transfer_id,
transfer_name: transfer_name.into(),
sender_name: sender_name.and_then(non_empty),
created_at: now_ms(),
content_hash: content_hash.to_string(),
file_count,
total_size,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct TicketInspection {
pub kind: String,
pub blob_ticket: String,
pub metadata: Option<TransferMetadata>,
}

View File

@@ -0,0 +1,23 @@
use std::io;
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum VnidropError {
#[error("{reason}")]
Generic { reason: String },
}
impl From<anyhow::Error> for VnidropError {
fn from(error: anyhow::Error) -> Self {
Self::Generic {
reason: error.to_string(),
}
}
}
impl From<io::Error> for VnidropError {
fn from(error: io::Error) -> Self {
Self::Generic {
reason: error.to_string(),
}
}
}

View File

@@ -0,0 +1,227 @@
use std::{
io::{self, Read, Write},
path::{Component, Path, PathBuf},
};
use anyhow::{Context, Result};
use bytes::Bytes;
use iroh_blobs::{api::TempTag, Hash};
use crate::{
api::{ShareSource, SourceKind},
util::non_empty,
};
const STREAM_BUFFER_LEN: usize = 1024 * 1024;
#[derive(Debug)]
pub(crate) struct TransferImport {
pub(crate) tag: TempTag,
pub(crate) root_hash: Hash,
pub(crate) total_size: u64,
pub(crate) file_count: u64,
pub(crate) default_name: String,
}
#[derive(Debug)]
pub(crate) struct ImportSourceFile {
pub(crate) path: PathBuf,
pub(crate) collection_name: String,
}
pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<ImportSourceFile>> {
let mut files = Vec::new();
for source in sources {
match source.kind {
SourceKind::Path | SourceKind::IosSecurityScopedUrl => {
let path = source_path(&source)?;
let display_name = source
.display_name
.clone()
.and_then(non_empty)
.or_else(|| {
path.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "transfer".to_string());
if source.is_directory || path.is_dir() {
collect_dir_files(&path, &display_name, &mut files)?;
} else {
files.push(ImportSourceFile {
path,
collection_name: validated_relative_string(&display_name)?,
});
}
}
SourceKind::AndroidContentUri => {
anyhow::bail!(
"Android content URI streaming needs platform file descriptor glue before it can be imported without copying"
);
}
}
}
if files.is_empty() {
anyhow::bail!("no files found in selected sources");
}
Ok(files)
}
fn source_path(source: &ShareSource) -> Result<PathBuf> {
if matches!(source.kind, SourceKind::IosSecurityScopedUrl)
&& source.value.starts_with("file://")
{
let without_scheme = source.value.trim_start_matches("file://");
return Ok(PathBuf::from(percent_decode_file_url_path(without_scheme)?));
}
Ok(PathBuf::from(&source.value))
}
fn collect_dir_files(
root: &Path,
display_name: &str,
files: &mut Vec<ImportSourceFile>,
) -> Result<()> {
for entry in walkdir::WalkDir::new(root).follow_links(false) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let relative = entry
.path()
.strip_prefix(root)
.context("failed to compute relative path")?;
let collection_name = path_to_string(Path::new(display_name).join(relative), true)?;
files.push(ImportSourceFile {
path: entry.path().to_path_buf(),
collection_name,
});
}
Ok(())
}
pub(crate) fn default_collection_name(files: &[ImportSourceFile]) -> String {
files
.first()
.and_then(|file| file.collection_name.split('/').next())
.filter(|name| !name.is_empty())
.unwrap_or("transfer")
.to_string()
}
pub(crate) fn safe_output_path(output_dir: &Path, relative_path: &str) -> Result<PathBuf> {
let relative = Path::new(relative_path);
path_to_string(relative, true)?;
Ok(output_dir.join(relative))
}
pub(crate) fn read_stream_from_blocking_reader<R>(
mut reader: R,
) -> impl futures::Stream<Item = io::Result<Bytes>> + Send + Sync + 'static
where
R: Read + Send + 'static,
{
let (tx, rx) = async_channel::bounded(2);
std::thread::spawn(move || {
let mut buffer = vec![0; STREAM_BUFFER_LEN];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
if tx
.send_blocking(Ok(Bytes::copy_from_slice(&buffer[..read])))
.is_err()
{
break;
}
}
Err(error) => {
let _ = tx.send_blocking(Err(error));
break;
}
}
}
});
rx
}
pub(crate) fn write_stream_to_blocking_writer<W>(
mut writer: W,
rx: async_channel::Receiver<io::Result<Option<Bytes>>>,
) -> io::Result<()>
where
W: Write,
{
while let Ok(item) = rx.recv_blocking() {
match item? {
Some(bytes) => writer.write_all(&bytes)?,
None => break,
}
}
writer.flush()
}
pub(crate) async fn wait_for_writer(
task: std::thread::JoinHandle<io::Result<()>>,
) -> Result<io::Result<()>> {
tokio::task::spawn_blocking(move || {
task.join()
.map_err(|_| anyhow::anyhow!("export writer thread panicked"))
})
.await?
}
pub(crate) fn validated_relative_string(name: &str) -> Result<String> {
path_to_string(Path::new(name), true)
}
pub(crate) fn path_to_string(path: impl AsRef<Path>, must_be_relative: bool) -> Result<String> {
let mut path_str = String::new();
let parts = path
.as_ref()
.components()
.filter_map(|component| match component {
Component::Normal(x) => {
let Some(component) = x.to_str() else {
return Some(Err(anyhow::anyhow!("invalid character in path")));
};
if !component.contains('/') && !component.contains('\\') {
Some(Ok(component))
} else {
Some(Err(anyhow::anyhow!("invalid path component {component:?}")))
}
}
Component::RootDir => {
if must_be_relative {
Some(Err(anyhow::anyhow!("invalid root path component")))
} else {
path_str.push('/');
None
}
}
other => Some(Err(anyhow::anyhow!("invalid path component {other:?}"))),
})
.collect::<Result<Vec<_>>>()?;
path_str.push_str(&parts.join("/"));
Ok(path_str)
}
pub(crate) fn percent_decode_file_url_path(value: &str) -> Result<String> {
let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
anyhow::bail!("invalid percent escape in file URL");
}
let hex = std::str::from_utf8(&bytes[i + 1..i + 3])?;
output.push(u8::from_str_radix(hex, 16).context("invalid percent escape in file URL")?);
i += 3;
} else {
output.push(bytes[i]);
i += 1;
}
}
Ok(String::from_utf8(output)?)
}

22
crates/vnidrop/src/lib.rs Normal file
View File

@@ -0,0 +1,22 @@
mod access_policy;
mod api;
mod error;
mod filesystem;
mod logging;
mod repository;
mod runtime;
mod secret;
mod ticket;
mod util;
pub use api::{
CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
};
pub use error::VnidropError;
pub use runtime::VnidropCore;
uniffi::setup_scaffolding!();
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,29 @@
use std::{path::Path, sync::OnceLock};
use anyhow::Result;
use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
static LOG_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new();
pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> {
if LOG_GUARD.get().is_some() {
return Ok(());
}
let log_dir = app_data_dir.join("logs");
std::fs::create_dir_all(&log_dir)?;
let file_appender = tracing_appender::rolling::daily(log_dir, "vnidrop.log");
let (writer, guard) = tracing_appender::non_blocking(file_appender);
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("vnidrop=debug,iroh=info,iroh_blobs=info,warn"));
let subscriber = tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_writer(writer).with_ansi(false));
if tracing::subscriber::set_global_default(subscriber).is_ok() {
let _ = LOG_GUARD.set(guard);
}
Ok(())
}

View File

@@ -0,0 +1,228 @@
use std::{path::Path, str::FromStr};
use anyhow::Result;
use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
Row, SqlitePool,
};
use crate::api::{CoreEvent, StoredTransfer};
use crate::util::now_ms;
#[derive(Debug, Clone)]
pub(crate) struct Repository {
pool: SqlitePool,
}
impl Repository {
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
let db_path = app_data_dir.join("vnidrop.sqlite3");
let options = SqliteConnectOptions::from_str("sqlite://")?
.filename(db_path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4)
.connect_with(options)
.await?;
let repository = Self { pool };
repository.ensure_schema().await?;
Ok(repository)
}
async fn ensure_schema(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS transfers (
transfer_id INTEGER PRIMARY KEY,
direction TEXT NOT NULL,
status TEXT NOT NULL,
transfer_name TEXT,
content_hash TEXT,
ticket TEXT,
file_count INTEGER NOT NULL DEFAULT 0,
total_size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS transfer_events (
id TEXT PRIMARY KEY,
timestamp INTEGER NOT NULL,
scope TEXT NOT NULL,
transfer_id INTEGER,
direction TEXT,
phase TEXT NOT NULL,
kind TEXT NOT NULL,
data_json TEXT NOT NULL
);
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_transfer_events_transfer_id ON transfer_events(transfer_id, timestamp);",
)
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn upsert_transfer(
&self,
transfer_id: u64,
direction: &str,
status: &str,
transfer_name: Option<&str>,
content_hash: Option<&str>,
ticket: Option<&str>,
file_count: u64,
total_size: u64,
) -> Result<()> {
let now = now_ms();
sqlx::query(
r#"
INSERT INTO transfers (
transfer_id, direction, status, transfer_name, content_hash, ticket,
file_count, total_size, created_at, updated_at
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
ON CONFLICT(transfer_id) DO UPDATE SET
direction = excluded.direction,
status = excluded.status,
transfer_name = excluded.transfer_name,
content_hash = excluded.content_hash,
ticket = excluded.ticket,
file_count = excluded.file_count,
total_size = excluded.total_size,
updated_at = excluded.updated_at;
"#,
)
.bind(transfer_id as i64)
.bind(direction)
.bind(status)
.bind(transfer_name)
.bind(content_hash)
.bind(ticket)
.bind(file_count as i64)
.bind(total_size as i64)
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn update_transfer_status(
&self,
transfer_id: u64,
status: &str,
) -> Result<()> {
sqlx::query("UPDATE transfers SET status = ?1, updated_at = ?2 WHERE transfer_id = ?3")
.bind(status)
.bind(now_ms())
.bind(transfer_id as i64)
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn insert_event(&self, event: &CoreEvent) -> Result<()> {
sqlx::query(
r#"
INSERT OR REPLACE INTO transfer_events (
id, timestamp, scope, transfer_id, direction, phase, kind, data_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);
"#,
)
.bind(&event.id)
.bind(event.timestamp)
.bind(&event.scope)
.bind(event.transfer_id.map(|value| value as i64))
.bind(&event.direction)
.bind(&event.phase)
.bind(&event.kind)
.bind(&event.data_json)
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn list_transfers(&self) -> Result<Vec<StoredTransfer>> {
let rows = sqlx::query(
r#"
SELECT transfer_id, direction, status, transfer_name, content_hash, ticket,
file_count, total_size, created_at, updated_at
FROM transfers
ORDER BY updated_at DESC
"#,
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(row_to_transfer).collect())
}
pub(crate) async fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>> {
let rows = if let Some(transfer_id) = transfer_id {
sqlx::query(
r#"
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
FROM transfer_events
WHERE transfer_id = ?1
ORDER BY timestamp ASC
"#,
)
.bind(transfer_id as i64)
.fetch_all(&self.pool)
.await?
} else {
sqlx::query(
r#"
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
FROM transfer_events
ORDER BY timestamp DESC
LIMIT 500
"#,
)
.fetch_all(&self.pool)
.await?
};
Ok(rows.into_iter().map(row_to_event).collect())
}
}
fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> StoredTransfer {
StoredTransfer {
transfer_id: row.get::<i64, _>("transfer_id") as u64,
direction: row.get("direction"),
status: row.get("status"),
transfer_name: row.get("transfer_name"),
content_hash: row.get("content_hash"),
ticket: row.get("ticket"),
file_count: row.get::<i64, _>("file_count") as u64,
total_size: row.get::<i64, _>("total_size") as u64,
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
}
fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent {
CoreEvent {
id: row.get("id"),
timestamp: row.get("timestamp"),
scope: row.get("scope"),
transfer_id: row
.get::<Option<i64>, _>("transfer_id")
.map(|value| value as u64),
direction: row.get("direction"),
phase: row.get("phase"),
kind: row.get("kind"),
data_json: row.get("data_json"),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
use std::{io, path::Path, str::FromStr};
use anyhow::{Context, Result};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
pub(crate) async fn load_or_create_secret(app_data_dir: &Path) -> Result<SecretKey> {
if let Ok(secret) = std::env::var("IROH_SECRET") {
return SecretKey::from_str(&secret).context("invalid IROH_SECRET");
}
let path = app_data_dir.join("iroh.secret");
match tokio::fs::read_to_string(&path).await {
Ok(secret) => {
let bytes = HEXLOWER
.decode(secret.trim().as_bytes())
.context("invalid persisted iroh secret encoding")?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("invalid persisted iroh secret length"))?;
Ok(SecretKey::from_bytes(&bytes))
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let secret = SecretKey::generate();
tokio::fs::write(&path, HEXLOWER.encode(&secret.to_bytes())).await?;
Ok(secret)
}
Err(error) => Err(error.into()),
}
}

151
crates/vnidrop/src/tests.rs Normal file
View File

@@ -0,0 +1,151 @@
#[cfg(test)]
mod tests {
use std::{path::Path, sync::Arc};
use iroh::SecretKey;
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
use crate::{
access_policy::{AccessDecision, AccessPolicy},
api::{CoreEvent, CoreEventSink, TransferMetadata},
filesystem::{path_to_string, percent_decode_file_url_path, validated_relative_string},
repository::Repository,
runtime::VnidropCore,
secret::load_or_create_secret,
ticket::{parse_transfer_ticket, VnidropTicket},
TransferAccessMode,
};
struct TestSink;
impl CoreEventSink for TestSink {
fn on_event(&self, _event: CoreEvent) {}
}
#[test]
fn metadata_ticket_round_trips() {
let secret = SecretKey::generate();
let addr = iroh::EndpointAddr::new(secret.public());
let blob_ticket = BlobTicket::new(addr, Hash::new([7; 32]), BlobFormat::HashSeq);
let metadata = TransferMetadata::new(
42,
"Summer photos",
Some("hammed".to_string()),
blob_ticket.hash(),
3,
2048,
);
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone())
.encode()
.unwrap();
let parsed = parse_transfer_ticket(&encoded).unwrap();
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
assert_eq!(
parsed.metadata.unwrap().transfer_name,
metadata.transfer_name
);
}
#[test]
fn invalid_ticket_is_rejected() {
assert!(parse_transfer_ticket("not-a-ticket").is_err());
}
#[tokio::test]
async fn secret_persists() {
let temp = tempfile::tempdir().unwrap();
let first = load_or_create_secret(temp.path()).await.unwrap();
let second = load_or_create_secret(temp.path()).await.unwrap();
assert_eq!(first.to_bytes(), second.to_bytes());
}
#[test]
fn path_validation_rejects_unsafe_paths() {
assert!(path_to_string(Path::new("../escape"), true).is_err());
assert!(path_to_string(Path::new("/absolute"), true).is_err());
assert!(validated_relative_string("bad\\name").is_err());
}
#[test]
fn file_url_decodes_spaces() {
assert_eq!(
percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(),
"/tmp/My File.txt"
);
}
#[test]
fn can_initialize_core() {
let temp = tempfile::tempdir().unwrap();
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let status = core.status();
assert!(!status.endpoint_id.is_empty());
core.shutdown();
}
#[tokio::test]
async fn repository_persists_transfers_and_events() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.upsert_transfer(
7,
"send",
"sharing",
Some("demo"),
Some("hash"),
Some("ticket"),
1,
12,
)
.await
.unwrap();
repository
.insert_event(&CoreEvent {
id: "event-1".to_string(),
timestamp: 10,
scope: "transfer".to_string(),
transfer_id: Some(7),
direction: Some("send".to_string()),
phase: "ticket".to_string(),
kind: "created".to_string(),
data_json: "{}".to_string(),
})
.await
.unwrap();
let transfers = repository.list_transfers().await.unwrap();
assert_eq!(transfers.len(), 1);
assert_eq!(transfers[0].transfer_name.as_deref(), Some("demo"));
let events = repository.list_events(Some(7)).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, "created");
}
#[tokio::test]
async fn access_policy_requires_approved_endpoint_when_locked() {
let policy = AccessPolicy::new();
policy
.set_mode(99, TransferAccessMode::ApprovalRequired)
.await;
assert_eq!(
policy.decide(99, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-required"
}
);
policy.approve_endpoint(99, "node-a".to_string()).await;
assert_eq!(
policy.decide(99, Some("node-a")).await,
AccessDecision::Allow
);
}
}

View File

@@ -0,0 +1,69 @@
use std::str::FromStr;
use anyhow::{Context, Result};
use data_encoding::BASE64URL_NOPAD;
use iroh_blobs::ticket::BlobTicket;
use serde::{Deserialize, Serialize};
use crate::api::TransferMetadata;
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct VnidropTicket {
version: u8,
blob_ticket: String,
metadata: TransferMetadata,
}
impl VnidropTicket {
pub(crate) fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self {
Self {
version: 1,
blob_ticket: blob_ticket.to_string(),
metadata,
}
}
pub(crate) fn encode(&self) -> Result<String> {
let bytes = serde_json::to_vec(self)?;
Ok(format!(
"{VNIDROP_TICKET_PREFIX}{}",
BASE64URL_NOPAD.encode(&bytes)
))
}
fn decode(value: &str) -> Result<Self> {
let encoded = value
.strip_prefix(VNIDROP_TICKET_PREFIX)
.context("not a VniDrop ticket")?;
let bytes = BASE64URL_NOPAD
.decode(encoded.as_bytes())
.context("invalid VniDrop ticket encoding")?;
serde_json::from_slice(&bytes).context("invalid VniDrop ticket payload")
}
}
#[derive(Debug, Clone)]
pub(crate) struct ParsedTransferTicket {
pub(crate) blob_ticket: BlobTicket,
pub(crate) metadata: Option<TransferMetadata>,
}
pub(crate) fn parse_transfer_ticket(value: &str) -> Result<ParsedTransferTicket> {
if value.starts_with(VNIDROP_TICKET_PREFIX) {
let ticket = VnidropTicket::decode(value)?;
let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
.context("invalid BlobTicket inside VniDrop ticket")?;
return Ok(ParsedTransferTicket {
blob_ticket,
metadata: Some(ticket.metadata),
});
}
let blob_ticket = BlobTicket::from_str(value).context("invalid BlobTicket")?;
Ok(ParsedTransferTicket {
blob_ticket,
metadata: None,
})
}

View File

@@ -0,0 +1,17 @@
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) fn non_empty(value: String) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
pub(crate) fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or_default()
}
pub(crate) fn unique_transfer_id() -> u64 {
now_ms() as u64
}

View File

@@ -0,0 +1,70 @@
use std::sync::{Arc, Mutex};
use vnidrop::{CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, VnidropCore};
#[derive(Default)]
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
#[test]
fn two_local_cores_transfer_file() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = sender_dir.path().join("hello.txt");
std::fs::write(&source_path, b"hello from vnidrop").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_path.to_string_lossy().to_string(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 7,
transfer_name: Some("hello".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("hello.txt")).unwrap(),
b"hello from vnidrop"
);
sender.shutdown();
receiver.shutdown();
}

View File

@@ -0,0 +1,2 @@
[bindings.kotlin]
package_name = "com.vnidrop.core"