mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Refactor Rust core architecture
This commit is contained in:
811
Cargo.lock
generated
811
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/vnidrop-core"]
|
members = ["crates/vnidrop"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "vnidrop-core"
|
name = "vnidrop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "vnidrop_core"
|
name = "vnidrop"
|
||||||
crate-type = ["cdylib", "staticlib", "rlib"]
|
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -21,7 +21,11 @@ n0-future = "0.3.1"
|
|||||||
num_cpus = "1.17.0"
|
num_cpus = "1.17.0"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
|
||||||
thiserror = "2.0.18"
|
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"] }
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
uniffi = { version = "=0.29.4", features = ["tokio"] }
|
uniffi = { version = "=0.29.4", features = ["tokio"] }
|
||||||
uuid = { version = "1.23.3", features = ["v4", "serde"] }
|
uuid = { version = "1.23.3", features = ["v4", "serde"] }
|
||||||
81
crates/vnidrop/src/access_policy.rs
Normal file
81
crates/vnidrop/src/access_policy.rs
Normal 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
123
crates/vnidrop/src/api.rs
Normal 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>,
|
||||||
|
}
|
||||||
23
crates/vnidrop/src/error.rs
Normal file
23
crates/vnidrop/src/error.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
crates/vnidrop/src/filesystem.rs
Normal file
227
crates/vnidrop/src/filesystem.rs
Normal 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
22
crates/vnidrop/src/lib.rs
Normal 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;
|
||||||
29
crates/vnidrop/src/logging.rs
Normal file
29
crates/vnidrop/src/logging.rs
Normal 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(())
|
||||||
|
}
|
||||||
228
crates/vnidrop/src/repository.rs
Normal file
228
crates/vnidrop/src/repository.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,17 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
fs::File,
|
fs::File,
|
||||||
io::{self, Read, Write},
|
io,
|
||||||
path::{Component, Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
str::FromStr,
|
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use data_encoding::{BASE64URL_NOPAD, HEXLOWER};
|
|
||||||
use futures_lite::StreamExt as _;
|
use futures_lite::StreamExt as _;
|
||||||
use iroh::{
|
use iroh::{endpoint::presets, protocol::Router, Endpoint};
|
||||||
endpoint::presets,
|
|
||||||
protocol::Router,
|
|
||||||
Endpoint, SecretKey,
|
|
||||||
};
|
|
||||||
use iroh_blobs::{
|
use iroh_blobs::{
|
||||||
api::{
|
api::{blobs::AddProgressItem, proto::ExportRangesItem, remote::GetProgressItem, TempTag},
|
||||||
blobs::AddProgressItem,
|
|
||||||
proto::ExportRangesItem,
|
|
||||||
remote::GetProgressItem,
|
|
||||||
TempTag,
|
|
||||||
},
|
|
||||||
format::collection::Collection,
|
format::collection::Collection,
|
||||||
get::request::get_hash_seq_and_sizes,
|
get::request::get_hash_seq_and_sizes,
|
||||||
provider::events::{EventMask, EventSender, ProviderMessage, RequestUpdate},
|
provider::events::{EventMask, EventSender, ProviderMessage, RequestUpdate},
|
||||||
@@ -32,207 +20,26 @@ use iroh_blobs::{
|
|||||||
BlobFormat, BlobsProtocol, Hash,
|
BlobFormat, BlobsProtocol, Hash,
|
||||||
};
|
};
|
||||||
use n0_future::BufferedStreamExt;
|
use n0_future::BufferedStreamExt;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::{mpsc, oneshot, Mutex as TokioMutex};
|
use tokio::sync::{mpsc, oneshot, Mutex as TokioMutex};
|
||||||
|
|
||||||
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
|
use crate::{
|
||||||
const STREAM_BUFFER_LEN: usize = 1024 * 1024;
|
access_policy::{AccessDecision, AccessPolicy},
|
||||||
|
api::{
|
||||||
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
|
||||||
pub enum VnidropError {
|
StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
|
||||||
#[error("{reason}")]
|
},
|
||||||
Generic { reason: String },
|
error::VnidropError,
|
||||||
}
|
filesystem::{
|
||||||
|
collect_import_files, default_collection_name, read_stream_from_blocking_reader,
|
||||||
impl From<anyhow::Error> for VnidropError {
|
safe_output_path, wait_for_writer, write_stream_to_blocking_writer, TransferImport,
|
||||||
fn from(error: anyhow::Error) -> Self {
|
},
|
||||||
Self::Generic {
|
logging::init_logging,
|
||||||
reason: error.to_string(),
|
repository::Repository,
|
||||||
}
|
secret::load_or_create_secret,
|
||||||
}
|
ticket::{parse_transfer_ticket, ParsedTransferTicket, VnidropTicket},
|
||||||
}
|
util::{non_empty, now_ms, unique_transfer_id},
|
||||||
|
};
|
||||||
impl From<io::Error> for VnidropError {
|
|
||||||
fn from(error: io::Error) -> Self {
|
|
||||||
Self::Generic {
|
|
||||||
reason: error.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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::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 {
|
|
||||||
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)]
|
|
||||||
struct VnidropTicket {
|
|
||||||
version: u8,
|
|
||||||
blob_ticket: String,
|
|
||||||
metadata: TransferMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VnidropTicket {
|
|
||||||
fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self {
|
|
||||||
Self {
|
|
||||||
version: 1,
|
|
||||||
blob_ticket: blob_ticket.to_string(),
|
|
||||||
metadata,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, Serialize, Deserialize, uniffi::Record)]
|
|
||||||
pub struct TicketInspection {
|
|
||||||
pub kind: String,
|
|
||||||
pub blob_ticket: String,
|
|
||||||
pub metadata: Option<TransferMetadata>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct ParsedTransferTicket {
|
|
||||||
blob_ticket: BlobTicket,
|
|
||||||
metadata: Option<TransferMetadata>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct TransferImport {
|
|
||||||
tag: TempTag,
|
|
||||||
root_hash: Hash,
|
|
||||||
total_size: u64,
|
|
||||||
file_count: u64,
|
|
||||||
default_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ImportSourceFile {
|
|
||||||
path: PathBuf,
|
|
||||||
collection_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(uniffi::Object)]
|
#[derive(uniffi::Object)]
|
||||||
pub struct VnidropCore {
|
pub struct VnidropCore {
|
||||||
@@ -244,10 +51,13 @@ struct CoreInner {
|
|||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
router: Router,
|
router: Router,
|
||||||
store: FsStore,
|
store: FsStore,
|
||||||
|
repository: Repository,
|
||||||
|
access_policy: Arc<AccessPolicy>,
|
||||||
event_sink: Arc<dyn CoreEventSink>,
|
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>>,
|
||||||
sequence: Mutex<u64>,
|
sequence: Mutex<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +70,7 @@ impl VnidropCore {
|
|||||||
) -> Result<Arc<Self>, VnidropError> {
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.thread_name("vnidrop-core")
|
.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))?;
|
||||||
@@ -288,7 +98,10 @@ impl VnidropCore {
|
|||||||
receiver_name: Option<String>,
|
receiver_name: Option<String>,
|
||||||
) -> Result<(), VnidropError> {
|
) -> Result<(), VnidropError> {
|
||||||
self.runtime
|
self.runtime
|
||||||
.block_on(self.inner.receive(ticket, PathBuf::from(output_dir), receiver_name))
|
.block_on(
|
||||||
|
self.inner
|
||||||
|
.receive(ticket, PathBuf::from(output_dir), receiver_name),
|
||||||
|
)
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +111,41 @@ impl VnidropCore {
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_transfer_access_mode(
|
||||||
|
&self,
|
||||||
|
transfer_id: u64,
|
||||||
|
mode: TransferAccessMode,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
self.runtime
|
||||||
|
.block_on(self.inner.set_transfer_access_mode(transfer_id, mode))
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn approve_endpoint_for_transfer(
|
||||||
|
&self,
|
||||||
|
transfer_id: u64,
|
||||||
|
endpoint_id: String,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
self.runtime
|
||||||
|
.block_on(
|
||||||
|
self.inner
|
||||||
|
.approve_endpoint_for_transfer(transfer_id, endpoint_id),
|
||||||
|
)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
||||||
|
self.runtime
|
||||||
|
.block_on(self.inner.repository.list_transfers())
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>, VnidropError> {
|
||||||
|
self.runtime
|
||||||
|
.block_on(self.inner.repository.list_events(transfer_id))
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
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")?;
|
||||||
Ok(TicketInspection {
|
Ok(TicketInspection {
|
||||||
@@ -317,12 +165,11 @@ impl VnidropCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CoreInner {
|
impl CoreInner {
|
||||||
async fn start(
|
async fn start(app_data_dir: PathBuf, event_sink: Arc<dyn CoreEventSink>) -> Result<Arc<Self>> {
|
||||||
app_data_dir: PathBuf,
|
|
||||||
event_sink: Arc<dyn CoreEventSink>,
|
|
||||||
) -> Result<Arc<Self>> {
|
|
||||||
tokio::fs::create_dir_all(&app_data_dir).await?;
|
tokio::fs::create_dir_all(&app_data_dir).await?;
|
||||||
|
init_logging(&app_data_dir)?;
|
||||||
let secret_key = load_or_create_secret(&app_data_dir).await?;
|
let secret_key = load_or_create_secret(&app_data_dir).await?;
|
||||||
|
let repository = Repository::open(&app_data_dir).await?;
|
||||||
let store_root = app_data_dir.join("blobs");
|
let store_root = app_data_dir.join("blobs");
|
||||||
let store = FsStore::load(&store_root).await?;
|
let store = FsStore::load(&store_root).await?;
|
||||||
let endpoint = Endpoint::builder(presets::N0)
|
let endpoint = Endpoint::builder(presets::N0)
|
||||||
@@ -341,10 +188,13 @@ impl CoreInner {
|
|||||||
endpoint,
|
endpoint,
|
||||||
router,
|
router,
|
||||||
store,
|
store,
|
||||||
|
repository,
|
||||||
|
access_policy: AccessPolicy::new(),
|
||||||
event_sink,
|
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()),
|
||||||
sequence: Mutex::new(1),
|
sequence: Mutex::new(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -387,7 +237,8 @@ impl CoreInner {
|
|||||||
json!({ "source_count": sources.len() }),
|
json!({ "source_count": sources.len() }),
|
||||||
);
|
);
|
||||||
let import = self.import_sources(metadata.transfer_id, sources).await?;
|
let import = self.import_sources(metadata.transfer_id, sources).await?;
|
||||||
let blob_ticket = BlobTicket::new(self.endpoint.addr(), import.root_hash, BlobFormat::HashSeq);
|
let blob_ticket =
|
||||||
|
BlobTicket::new(self.endpoint.addr(), import.root_hash, BlobFormat::HashSeq);
|
||||||
let transfer_name = metadata
|
let transfer_name = metadata
|
||||||
.transfer_name
|
.transfer_name
|
||||||
.and_then(non_empty)
|
.and_then(non_empty)
|
||||||
@@ -408,10 +259,25 @@ impl CoreInner {
|
|||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.insert(import.root_hash.to_string(), metadata.transfer_id);
|
.insert(import.root_hash.to_string(), metadata.transfer_id);
|
||||||
|
self.access_policy
|
||||||
|
.set_mode(metadata.transfer_id, TransferAccessMode::Public)
|
||||||
|
.await;
|
||||||
self.active_shares
|
self.active_shares
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.insert(metadata.transfer_id, import.tag);
|
.insert(metadata.transfer_id, import.tag);
|
||||||
|
self.repository
|
||||||
|
.upsert_transfer(
|
||||||
|
metadata.transfer_id,
|
||||||
|
"send",
|
||||||
|
"sharing",
|
||||||
|
Some(&transfer_name),
|
||||||
|
Some(&import.root_hash.to_string()),
|
||||||
|
Some(&ticket),
|
||||||
|
import.file_count,
|
||||||
|
import.total_size,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
self.emit_transfer(
|
self.emit_transfer(
|
||||||
metadata.transfer_id,
|
metadata.transfer_id,
|
||||||
@@ -482,6 +348,32 @@ impl CoreInner {
|
|||||||
"receiver_name": receiver_name,
|
"receiver_name": receiver_name,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
self.repository
|
||||||
|
.upsert_transfer(
|
||||||
|
transfer_id,
|
||||||
|
"receive",
|
||||||
|
"receiving",
|
||||||
|
parsed
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|metadata| metadata.transfer_name.as_str()),
|
||||||
|
parsed
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|metadata| metadata.content_hash.as_str()),
|
||||||
|
None,
|
||||||
|
parsed
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|metadata| metadata.file_count)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
parsed
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|metadata| metadata.total_size)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
tokio::fs::create_dir_all(&output_dir).await?;
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
|
|
||||||
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
|
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
|
||||||
@@ -527,6 +419,9 @@ impl CoreInner {
|
|||||||
let collection = Collection::load(hash_and_format.hash, self.store.as_ref()).await?;
|
let collection = Collection::load(hash_and_format.hash, self.store.as_ref()).await?;
|
||||||
self.export_collection(transfer_id, total_files, output_dir, collection)
|
self.export_collection(transfer_id, total_files, output_dir, collection)
|
||||||
.await?;
|
.await?;
|
||||||
|
self.repository
|
||||||
|
.update_transfer_status(transfer_id, "done")
|
||||||
|
.await?;
|
||||||
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -541,19 +436,66 @@ impl CoreInner {
|
|||||||
"cancel-requested",
|
"cancel-requested",
|
||||||
json!({}),
|
json!({}),
|
||||||
);
|
);
|
||||||
|
self.repository
|
||||||
|
.update_transfer_status(transfer_id, "cancelled")
|
||||||
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if self.active_shares.lock().await.remove(&transfer_id).is_some() {
|
if self
|
||||||
|
.active_shares
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&transfer_id)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
self.hash_to_transfer
|
self.hash_to_transfer
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.retain(|_, id| *id != transfer_id);
|
.retain(|_, id| *id != transfer_id);
|
||||||
|
self.access_policy.remove_transfer(transfer_id).await;
|
||||||
|
self.repository
|
||||||
|
.update_transfer_status(transfer_id, "stopped")
|
||||||
|
.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(());
|
||||||
}
|
}
|
||||||
anyhow::bail!("transfer not found")
|
anyhow::bail!("transfer not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_transfer_access_mode(
|
||||||
|
&self,
|
||||||
|
transfer_id: u64,
|
||||||
|
mode: TransferAccessMode,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.access_policy.set_mode(transfer_id, mode.clone()).await;
|
||||||
|
self.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
"send",
|
||||||
|
"access",
|
||||||
|
"mode-updated",
|
||||||
|
json!({ "mode": format!("{mode:?}") }),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_endpoint_for_transfer(
|
||||||
|
&self,
|
||||||
|
transfer_id: u64,
|
||||||
|
endpoint_id: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.access_policy
|
||||||
|
.approve_endpoint(transfer_id, endpoint_id.clone())
|
||||||
|
.await;
|
||||||
|
self.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
"send",
|
||||||
|
"access",
|
||||||
|
"endpoint-approved",
|
||||||
|
json!({ "endpoint_id": endpoint_id }),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn shutdown(&self) {
|
async fn shutdown(&self) {
|
||||||
self.emit_endpoint("shutdown", "service-shutdown", json!({}));
|
self.emit_endpoint("shutdown", "service-shutdown", json!({}));
|
||||||
if let Err(error) = self.router.shutdown().await {
|
if let Err(error) = self.router.shutdown().await {
|
||||||
@@ -582,7 +524,11 @@ impl CoreInner {
|
|||||||
let stream = read_stream_from_blocking_reader(reader);
|
let stream = read_stream_from_blocking_reader(reader);
|
||||||
let import = core.store.add_stream(stream).await;
|
let import = core.store.add_stream(stream).await;
|
||||||
let (tag, size) = core
|
let (tag, size) = core
|
||||||
.consume_add_progress(transfer_id, file.collection_name.clone(), import.stream().await)
|
.consume_add_progress(
|
||||||
|
transfer_id,
|
||||||
|
file.collection_name.clone(),
|
||||||
|
import.stream().await,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Result::<_>::Ok((file.collection_name, tag, size))
|
Result::<_>::Ok((file.collection_name, tag, size))
|
||||||
}
|
}
|
||||||
@@ -714,7 +660,9 @@ impl CoreInner {
|
|||||||
ExportRangesItem::Size(size) => file_size = size,
|
ExportRangesItem::Size(size) => file_size = size,
|
||||||
ExportRangesItem::Data(leaf) => {
|
ExportRangesItem::Data(leaf) => {
|
||||||
if leaf.offset != exported {
|
if leaf.offset != exported {
|
||||||
anyhow::bail!("export stream for {relative_path} yielded out-of-order data");
|
anyhow::bail!(
|
||||||
|
"export stream for {relative_path} yielded out-of-order data"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
exported += leaf.data.len() as u64;
|
exported += leaf.data.len() as u64;
|
||||||
tx.send(Ok(Some(leaf.data)))
|
tx.send(Ok(Some(leaf.data)))
|
||||||
@@ -767,6 +715,12 @@ impl CoreInner {
|
|||||||
"endpoint_id": message.inner.endpoint_id.map(|id| id.to_string()),
|
"endpoint_id": message.inner.endpoint_id.map(|id| id.to_string()),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if let Some(endpoint_id) = message.inner.endpoint_id {
|
||||||
|
self.connection_endpoints
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(message.inner.connection_id, endpoint_id.to_string());
|
||||||
|
}
|
||||||
let _ = message.tx.send(Ok(())).await;
|
let _ = message.tx.send(Ok(())).await;
|
||||||
}
|
}
|
||||||
ProviderMessage::ClientConnectedNotify(message) => {
|
ProviderMessage::ClientConnectedNotify(message) => {
|
||||||
@@ -778,10 +732,48 @@ impl CoreInner {
|
|||||||
"endpoint_id": message.inner.endpoint_id.map(|id| id.to_string()),
|
"endpoint_id": message.inner.endpoint_id.map(|id| id.to_string()),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if let Some(endpoint_id) = message.inner.endpoint_id {
|
||||||
|
self.connection_endpoints
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(message.inner.connection_id, endpoint_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProviderMessage::ConnectionClosed(message) => {
|
||||||
|
self.connection_endpoints
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&message.inner.connection_id);
|
||||||
|
self.emit_endpoint(
|
||||||
|
"provider",
|
||||||
|
"connection-closed",
|
||||||
|
json!({ "connection_id": message.inner.connection_id }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ProviderMessage::GetRequestReceived(message) => {
|
ProviderMessage::GetRequestReceived(message) => {
|
||||||
let transfer_id = self.transfer_for_hash(message.inner.request.hash).await;
|
let transfer_id = self.transfer_for_hash(message.inner.request.hash).await;
|
||||||
if let Some(transfer_id) = transfer_id {
|
if let Some(transfer_id) = transfer_id {
|
||||||
|
let decision = self
|
||||||
|
.access_decision(transfer_id, message.inner.connection_id)
|
||||||
|
.await;
|
||||||
|
if let AccessDecision::Deny { reason } = decision {
|
||||||
|
self.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
"send",
|
||||||
|
"access",
|
||||||
|
"request-denied",
|
||||||
|
json!({
|
||||||
|
"connection_id": message.inner.connection_id,
|
||||||
|
"request_id": message.inner.request_id,
|
||||||
|
"reason": reason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let _ = message
|
||||||
|
.tx
|
||||||
|
.send(Err(iroh_blobs::provider::events::AbortReason::Permission))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.track_request_updates(
|
self.track_request_updates(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
message.inner.connection_id,
|
message.inner.connection_id,
|
||||||
@@ -792,7 +784,8 @@ impl CoreInner {
|
|||||||
let _ = message.tx.send(Ok(())).await;
|
let _ = message.tx.send(Ok(())).await;
|
||||||
}
|
}
|
||||||
ProviderMessage::GetRequestReceivedNotify(message) => {
|
ProviderMessage::GetRequestReceivedNotify(message) => {
|
||||||
if let Some(transfer_id) = self.transfer_for_hash(message.inner.request.hash).await {
|
if let Some(transfer_id) = self.transfer_for_hash(message.inner.request.hash).await
|
||||||
|
{
|
||||||
self.track_request_updates(
|
self.track_request_updates(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
message.inner.connection_id,
|
message.inner.connection_id,
|
||||||
@@ -802,8 +795,31 @@ impl CoreInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProviderMessage::GetManyRequestReceived(message) => {
|
ProviderMessage::GetManyRequestReceived(message) => {
|
||||||
let transfer_id = self.transfer_for_any_hash(&message.inner.request.hashes).await;
|
let transfer_id = self
|
||||||
|
.transfer_for_any_hash(&message.inner.request.hashes)
|
||||||
|
.await;
|
||||||
if let Some(transfer_id) = transfer_id {
|
if let Some(transfer_id) = transfer_id {
|
||||||
|
let decision = self
|
||||||
|
.access_decision(transfer_id, message.inner.connection_id)
|
||||||
|
.await;
|
||||||
|
if let AccessDecision::Deny { reason } = decision {
|
||||||
|
self.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
"send",
|
||||||
|
"access",
|
||||||
|
"request-denied",
|
||||||
|
json!({
|
||||||
|
"connection_id": message.inner.connection_id,
|
||||||
|
"request_id": message.inner.request_id,
|
||||||
|
"reason": reason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let _ = message
|
||||||
|
.tx
|
||||||
|
.send(Err(iroh_blobs::provider::events::AbortReason::Permission))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.track_request_updates(
|
self.track_request_updates(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
message.inner.connection_id,
|
message.inner.connection_id,
|
||||||
@@ -814,7 +830,10 @@ impl CoreInner {
|
|||||||
let _ = message.tx.send(Ok(())).await;
|
let _ = message.tx.send(Ok(())).await;
|
||||||
}
|
}
|
||||||
ProviderMessage::GetManyRequestReceivedNotify(message) => {
|
ProviderMessage::GetManyRequestReceivedNotify(message) => {
|
||||||
if let Some(transfer_id) = self.transfer_for_any_hash(&message.inner.request.hashes).await {
|
if let Some(transfer_id) = self
|
||||||
|
.transfer_for_any_hash(&message.inner.request.hashes)
|
||||||
|
.await
|
||||||
|
{
|
||||||
self.track_request_updates(
|
self.track_request_updates(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
message.inner.connection_id,
|
message.inner.connection_id,
|
||||||
@@ -881,6 +900,18 @@ impl CoreInner {
|
|||||||
.find_map(|hash| map.get(&hash.to_string()).copied())
|
.find_map(|hash| map.get(&hash.to_string()).copied())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn access_decision(&self, transfer_id: u64, connection_id: u64) -> AccessDecision {
|
||||||
|
let endpoint_id = self
|
||||||
|
.connection_endpoints
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&connection_id)
|
||||||
|
.cloned();
|
||||||
|
self.access_policy
|
||||||
|
.decide(transfer_id, endpoint_id.as_deref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
fn track_request_updates(
|
fn track_request_updates(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
@@ -971,7 +1002,7 @@ impl CoreInner {
|
|||||||
let id = format!("{timestamp}-{}", *sequence);
|
let id = format!("{timestamp}-{}", *sequence);
|
||||||
*sequence += 1;
|
*sequence += 1;
|
||||||
drop(sequence);
|
drop(sequence);
|
||||||
self.event_sink.on_event(CoreEvent {
|
let event = CoreEvent {
|
||||||
id,
|
id,
|
||||||
timestamp,
|
timestamp,
|
||||||
scope: scope.to_string(),
|
scope: scope.to_string(),
|
||||||
@@ -980,308 +1011,14 @@ impl CoreInner {
|
|||||||
phase: phase.to_string(),
|
phase: phase.to_string(),
|
||||||
kind: kind.to_string(),
|
kind: kind.to_string(),
|
||||||
data_json: data.to_string(),
|
data_json: data.to_string(),
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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?
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validated_relative_string(name: &str) -> Result<String> {
|
|
||||||
path_to_string(Path::new(name), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
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('\\') {
|
let repository = self.repository.clone();
|
||||||
Some(Ok(component))
|
let event_for_repository = event.clone();
|
||||||
} else {
|
tokio::spawn(async move {
|
||||||
Some(Err(anyhow::anyhow!("invalid path component {component:?}")))
|
if let Err(error) = repository.insert_event(&event_for_repository).await {
|
||||||
}
|
tracing::warn!(%error, "failed to persist core event");
|
||||||
}
|
}
|
||||||
Component::RootDir => {
|
});
|
||||||
if must_be_relative {
|
self.event_sink.on_event(event);
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn non_empty(value: String) -> Option<String> {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_ms() -> i64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|duration| duration.as_millis() as i64)
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_transfer_id() -> u64 {
|
|
||||||
now_ms() as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
30
crates/vnidrop/src/secret.rs
Normal file
30
crates/vnidrop/src/secret.rs
Normal 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
151
crates/vnidrop/src/tests.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
crates/vnidrop/src/ticket.rs
Normal file
69
crates/vnidrop/src/ticket.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
17
crates/vnidrop/src/util.rs
Normal file
17
crates/vnidrop/src/util.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use vnidrop_core::{
|
use vnidrop::{CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, VnidropCore};
|
||||||
CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, VnidropCore,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RecordingSink {
|
struct RecordingSink {
|
||||||
@@ -29,7 +27,11 @@ fn two_local_cores_transfer_file() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let receiver = VnidropCore::initialize(
|
let receiver = VnidropCore::initialize(
|
||||||
receiver_dir.path().join("core").to_string_lossy().to_string(),
|
receiver_dir
|
||||||
|
.path()
|
||||||
|
.join("core")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
Arc::new(RecordingSink::default()),
|
Arc::new(RecordingSink::default()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -72,14 +72,14 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cargo {
|
cargo {
|
||||||
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop-core")
|
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
|
||||||
publishJvmArtifacts = true
|
publishJvmArtifacts = true
|
||||||
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64))
|
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64))
|
||||||
}
|
}
|
||||||
|
|
||||||
uniffi {
|
uniffi {
|
||||||
generateFromLibrary {
|
generateFromLibrary {
|
||||||
namespace = "vnidrop_core"
|
namespace = "vnidrop"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import uniffi.vnidrop_core.CoreEvent
|
import uniffi.vnidrop.CoreEvent
|
||||||
import uniffi.vnidrop_core.CoreEventSink
|
import uniffi.vnidrop.CoreEventSink
|
||||||
import uniffi.vnidrop_core.ShareMetadataInput
|
import uniffi.vnidrop.ShareMetadataInput
|
||||||
import uniffi.vnidrop_core.ShareResult
|
import uniffi.vnidrop.ShareResult
|
||||||
import uniffi.vnidrop_core.ShareSource
|
import uniffi.vnidrop.ShareSource
|
||||||
import uniffi.vnidrop_core.SourceKind
|
import uniffi.vnidrop.SourceKind
|
||||||
import uniffi.vnidrop_core.TicketInspection
|
import uniffi.vnidrop.TicketInspection
|
||||||
import uniffi.vnidrop_core.VnidropCore
|
import uniffi.vnidrop.VnidropCore
|
||||||
|
|
||||||
data class CoreUiState(
|
data class CoreUiState(
|
||||||
val isInitialized: Boolean = false,
|
val isInitialized: Boolean = false,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package com.vnidrop.app
|
|||||||
import platform.Foundation.NSTemporaryDirectory
|
import platform.Foundation.NSTemporaryDirectory
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import uniffi.vnidrop_core.CoreEvent
|
import uniffi.vnidrop.CoreEvent
|
||||||
import uniffi.vnidrop_core.CoreEventSink
|
import uniffi.vnidrop.CoreEventSink
|
||||||
import uniffi.vnidrop_core.VnidropCore
|
import uniffi.vnidrop.VnidropCore
|
||||||
|
|
||||||
class SharedLogicIOSTest {
|
class SharedLogicIOSTest {
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package com.vnidrop.app
|
|||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import uniffi.vnidrop_core.CoreEvent
|
import uniffi.vnidrop.CoreEvent
|
||||||
import uniffi.vnidrop_core.CoreEventSink
|
import uniffi.vnidrop.CoreEventSink
|
||||||
import uniffi.vnidrop_core.VnidropCore
|
import uniffi.vnidrop.VnidropCore
|
||||||
|
|
||||||
class CoreNativeLoadTest {
|
class CoreNativeLoadTest {
|
||||||
@Test
|
@Test
|
||||||
fun generatedBindingsCanInitializeRustCore() {
|
fun generatedBindingsCanInitializeRustCore() {
|
||||||
val coreDir = Files.createTempDirectory("vnidrop-core-jvm-test")
|
val coreDir = Files.createTempDirectory("vnidrop-jvm-test")
|
||||||
val core = VnidropCore.initialize(
|
val core = VnidropCore.initialize(
|
||||||
appDataDir = coreDir.toString(),
|
appDataDir = coreDir.toString(),
|
||||||
eventSink = object : CoreEventSink {
|
eventSink = object : CoreEventSink {
|
||||||
|
|||||||
Reference in New Issue
Block a user