test(core): organize hardening coverage and CI

This commit is contained in:
2026-07-10 12:25:33 +02:00
parent 2271f453f1
commit 382a015688
18 changed files with 2113 additions and 902 deletions

37
.github/workflows/rust-core.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Rust core
on:
pull_request:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "crates/vnidrop/**"
- ".github/workflows/rust-core.yml"
push:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "crates/vnidrop/**"
- ".github/workflows/rust-core.yml"
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install Rust quality components
run: rustup component add clippy rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run strict Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run unit and integration tests
run: cargo test --workspace --all-targets
- name: Check documentation
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps

View File

@@ -1,406 +1,18 @@
#[cfg(test)] #[path = "tests/access_policy.rs"]
mod tests { mod access_policy_tests;
#[cfg(unix)] #[path = "tests/filesystem.rs"]
use std::os::fd::AsRawFd; mod filesystem_tests;
use std::{io::Read, path::Path, sync::Arc}; #[path = "tests/handshake.rs"]
mod handshake_tests;
use data_encoding::BASE64URL_NOPAD; #[path = "tests/limits.rs"]
use iroh::SecretKey; mod limits_tests;
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; #[path = "tests/repository.rs"]
use serde_json::json; mod repository_tests;
#[path = "tests/runtime.rs"]
use crate::{ mod runtime_tests;
access_policy::{AccessDecision, AccessPolicy}, #[path = "tests/secret.rs"]
api::{CoreEvent, CoreEventSink, ShareSource, SourceKind, TransferMetadata}, mod secret_tests;
error::VnidropError, #[path = "tests/ticket.rs"]
filesystem::{ mod ticket_tests;
collect_import_files, default_collection_name, path_to_string, #[path = "tests/transfer_state.rs"]
percent_decode_file_url_path, validated_relative_string, mod transfer_state_tests;
},
repository::{ReceiverRequestInsert, 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 metadata_ticket_round_trip_tolerates_wrapped_whitespace() {
let secret = SecretKey::generate();
let addr = iroh::EndpointAddr::new(secret.public());
let blob_ticket = BlobTicket::new(addr, Hash::new([9; 32]), BlobFormat::HashSeq);
let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10);
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata)
.encode()
.unwrap();
let wrapped = encoded
.as_bytes()
.chunks(8)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<_>>()
.join("\n ");
let parsed = parse_transfer_ticket(&wrapped).unwrap();
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
}
#[test]
fn invalid_ticket_is_rejected() {
assert!(parse_transfer_ticket("not-a-ticket").is_err());
}
#[test]
fn ticket_rejects_unsupported_versions_and_mismatched_hashes() {
let secret = SecretKey::generate();
let addr = iroh::EndpointAddr::new(secret.public());
let blob_ticket = BlobTicket::new(addr, Hash::new([5; 32]), BlobFormat::HashSeq);
let payload = json!({
"version": 2,
"blob_ticket": blob_ticket.to_string(),
"metadata": {
"version": 1,
"transfer_id": 7,
"transfer_name": "bad version",
"sender_name": null,
"created_at": 1,
"content_hash": blob_ticket.hash().to_string(),
"file_count": 1,
"total_size": 10
}
});
let encoded = format!(
"vnd1:{}",
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
);
assert!(parse_transfer_ticket(&encoded)
.unwrap_err()
.to_string()
.contains("unsupported VniDrop ticket version"));
let payload = json!({
"version": 1,
"blob_ticket": blob_ticket.to_string(),
"metadata": {
"version": 1,
"transfer_id": 7,
"transfer_name": "bad hash",
"sender_name": null,
"created_at": 1,
"content_hash": Hash::new([6; 32]).to_string(),
"file_count": 1,
"total_size": 10
}
});
let encoded = format!(
"vnd1:{}",
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
);
assert!(parse_transfer_ticket(&encoded)
.unwrap_err()
.to_string()
.contains("metadata hash does not match"));
}
#[tokio::test]
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());
assert!(validated_relative_string("").is_err());
}
#[test]
fn file_url_decodes_spaces() {
assert_eq!(
percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(),
"/tmp/My File.txt"
);
}
#[cfg(unix)]
#[test]
fn file_descriptor_source_duplicates_and_streams() {
let mut temp = tempfile::tempfile().unwrap();
std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap();
std::io::Seek::rewind(&mut temp).unwrap();
let files = collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: temp.as_raw_fd().to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.unwrap();
let mut imported = files.into_iter().next().unwrap().source.open().unwrap();
let mut content = String::new();
imported.read_to_string(&mut content).unwrap();
assert_eq!(content, "fd-backed import");
}
#[cfg(unix)]
#[test]
fn file_descriptor_source_rejects_invalid_values() {
assert!(collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: "not-an-fd".to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.is_err());
assert!(collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: "-1".to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.is_err());
}
#[test]
fn android_content_uri_must_be_opened_by_platform_code() {
let error = collect_import_files(vec![ShareSource {
kind: SourceKind::AndroidContentUri,
value: "content://media/item".to_string(),
display_name: Some("from-uri.txt".to_string()),
is_directory: false,
}])
.unwrap_err()
.to_string();
assert!(error.contains("ParcelFileDescriptor"));
}
#[test]
fn directory_sources_preserve_safe_relative_names() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("picked");
std::fs::create_dir_all(root.join("nested")).unwrap();
std::fs::write(root.join("nested").join("a.txt"), b"a").unwrap();
std::fs::write(root.join("b.txt"), b"b").unwrap();
let mut files = collect_import_files(vec![ShareSource {
kind: SourceKind::Path,
value: root.to_string_lossy().to_string(),
display_name: Some("Album".to_string()),
is_directory: true,
}])
.unwrap();
files.sort_by(|a, b| a.collection_name.cmp(&b.collection_name));
assert_eq!(default_collection_name(&files), "Album");
assert_eq!(files[0].collection_name, "Album/b.txt");
assert_eq!(files[1].collection_name, "Album/nested/a.txt");
}
#[test]
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();
assert_eq!(repository.schema_version().await.unwrap(), 1);
repository
.upsert_transfer(crate::repository::TransferUpsert {
transfer_id: 7,
direction: "send",
status: "sharing",
transfer_name: Some("demo"),
content_hash: Some("hash"),
ticket: Some("ticket"),
file_count: 1,
total_size: 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");
let reopened = Repository::open(temp.path()).await.unwrap();
let transfers = reopened.list_transfers().await.unwrap();
assert_eq!(transfers.len(), 1);
let events = reopened.list_events(Some(7)).await.unwrap();
assert_eq!(events[0].id, "event-1");
}
#[tokio::test]
async fn repository_persists_receiver_requests() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-1",
transfer_id: 77,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: Some("receiver"),
receiver_device_name: Some("phone"),
app_version: "0.1.0",
})
.await
.unwrap();
repository
.update_receiver_request_status("request-1", "accepted", None)
.await
.unwrap();
assert!(repository
.update_receiver_request_status("request-1", "refused", Some("late"))
.await
.is_err());
assert!(repository
.update_receiver_request_status("missing", "accepted", None)
.await
.is_err());
let requests = repository.list_receiver_requests(77).await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].status, "accepted");
assert_eq!(requests[0].receiver_name.as_deref(), Some("receiver"));
assert!(requests[0].responded_at.is_some());
}
#[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
);
assert_eq!(
policy.decide(99, None).await,
AccessDecision::Deny {
reason: "missing-endpoint-id"
}
);
}
#[tokio::test]
async fn access_policy_rejects_expired_approval_sessions() {
let policy = AccessPolicy::new();
policy
.set_mode(100, TransferAccessMode::ApprovalRequired)
.await;
policy
.approve_endpoint_until(100, "node-a".to_string(), Some(crate::util::now_ms() - 1))
.await;
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-expired"
}
);
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-required"
}
);
}
#[test]
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
let temp = tempfile::tempdir().unwrap();
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let error = core
.receive(
"not-a-ticket".to_string(),
temp.path().to_string_lossy().to_string(),
None,
)
.unwrap_err();
assert!(matches!(error, VnidropError::Ticket { .. }));
let events = core.list_events(None).unwrap();
assert!(events
.iter()
.any(|event| event.phase == "error" && event.kind == "invalid-ticket"));
core.shutdown();
}
}

View File

@@ -0,0 +1,56 @@
use crate::{
access_policy::{AccessDecision, AccessPolicy},
util::now_ms,
TransferAccessMode,
};
#[tokio::test]
async fn 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
);
assert_eq!(
policy.decide(99, None).await,
AccessDecision::Deny {
reason: "missing-endpoint-id"
}
);
}
#[tokio::test]
async fn rejects_expired_approval_sessions() {
let policy = AccessPolicy::new();
policy
.set_mode(100, TransferAccessMode::ApprovalRequired)
.await;
policy
.approve_endpoint_until(100, "node-a".to_string(), Some(now_ms() - 1))
.await;
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-expired"
}
);
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-required"
}
);
}

View File

@@ -0,0 +1,233 @@
#[cfg(unix)]
use std::os::fd::AsRawFd;
use std::{io::Read, path::Path};
use crate::{
api::{CoreLimits, ShareSource, SourceKind},
filesystem::{
cleanup_stale_temporary_files, collect_import_files, collect_import_files_with_limits,
default_collection_name, path_to_string, percent_decode_file_url_path,
validated_relative_string, AtomicOutputFile,
},
};
#[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());
assert!(validated_relative_string("").is_err());
}
#[test]
fn file_url_decodes_spaces() {
assert_eq!(
percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(),
"/tmp/My File.txt"
);
}
#[cfg(unix)]
#[test]
fn file_descriptor_source_duplicates_and_streams() {
let mut temp = tempfile::tempfile().unwrap();
std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap();
std::io::Seek::rewind(&mut temp).unwrap();
let files = collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: temp.as_raw_fd().to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.unwrap();
let mut imported = files.into_iter().next().unwrap().source.open().unwrap();
let mut content = String::new();
imported.read_to_string(&mut content).unwrap();
assert_eq!(content, "fd-backed import");
}
#[cfg(unix)]
#[test]
fn file_descriptor_source_rejects_invalid_values() {
for value in ["not-an-fd", "-1"] {
assert!(collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: value.to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.is_err());
}
}
#[test]
fn android_content_uri_must_be_opened_by_platform_code() {
let error = collect_import_files(vec![ShareSource {
kind: SourceKind::AndroidContentUri,
value: "content://media/item".to_string(),
display_name: Some("from-uri.txt".to_string()),
is_directory: false,
}])
.unwrap_err()
.to_string();
assert!(error.contains("ParcelFileDescriptor"));
}
#[test]
fn directory_sources_preserve_safe_relative_names() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("picked");
std::fs::create_dir_all(root.join("nested")).unwrap();
std::fs::write(root.join("nested").join("a.txt"), b"a").unwrap();
std::fs::write(root.join("b.txt"), b"b").unwrap();
let mut files = collect_import_files(vec![ShareSource {
kind: SourceKind::Path,
value: root.to_string_lossy().to_string(),
display_name: Some("Album".to_string()),
is_directory: true,
}])
.unwrap();
files.sort_by(|a, b| a.collection_name.cmp(&b.collection_name));
assert_eq!(default_collection_name(&files), "Album");
assert_eq!(files[0].collection_name, "Album/b.txt");
assert_eq!(files[1].collection_name, "Album/nested/a.txt");
}
#[test]
fn atomic_output_commits_without_overwriting() {
let output = tempfile::tempdir().unwrap();
let (pending, mut file) = AtomicOutputFile::create(output.path(), "nested/file.txt").unwrap();
std::io::Write::write_all(&mut file, b"complete").unwrap();
file.sync_all().unwrap();
drop(file);
pending.commit().unwrap();
assert_eq!(
std::fs::read(output.path().join("nested/file.txt")).unwrap(),
b"complete"
);
assert!(AtomicOutputFile::create(output.path(), "nested/file.txt").is_err());
assert_eq!(
std::fs::read(output.path().join("nested/file.txt")).unwrap(),
b"complete"
);
}
#[test]
fn dropped_atomic_output_removes_partial_file() {
let output = tempfile::tempdir().unwrap();
let (pending, mut file) = AtomicOutputFile::create(output.path(), "partial.txt").unwrap();
std::io::Write::write_all(&mut file, b"partial").unwrap();
drop(file);
drop(pending);
assert!(!output.path().join("partial.txt").exists());
assert!(std::fs::read_dir(output.path()).unwrap().all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.contains(".part")));
}
#[cfg(unix)]
#[test]
fn atomic_output_rejects_symlinked_parent() {
use std::os::unix::fs::symlink;
let output = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), output.path().join("link")).unwrap();
assert!(AtomicOutputFile::create(output.path(), "link/escape.txt").is_err());
assert!(!outside.path().join("escape.txt").exists());
}
#[cfg(unix)]
#[test]
fn atomic_output_never_replaces_symlink_destination() {
use std::os::unix::fs::symlink;
let output = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
std::fs::write(outside.path(), b"outside").unwrap();
symlink(outside.path(), output.path().join("target.txt")).unwrap();
assert!(AtomicOutputFile::create(output.path(), "target.txt").is_err());
assert_eq!(std::fs::read(outside.path()).unwrap(), b"outside");
}
#[test]
fn cleanup_removes_only_vnidrop_temporary_files() {
let output = tempfile::tempdir().unwrap();
std::fs::write(output.path().join(".file.vnidrop-old.part"), b"partial").unwrap();
std::fs::write(output.path().join("keep.part"), b"keep").unwrap();
assert_eq!(
cleanup_stale_temporary_files(output.path(), std::time::Duration::ZERO).unwrap(),
1
);
assert!(!output.path().join(".file.vnidrop-old.part").exists());
assert!(output.path().join("keep.part").exists());
}
#[test]
fn import_collection_limits_are_enforced_before_streaming() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("one.txt"), b"one").unwrap();
std::fs::write(temp.path().join("two.txt"), b"two").unwrap();
let source = ShareSource {
kind: SourceKind::Path,
value: temp.path().to_string_lossy().to_string(),
display_name: Some("folder".to_string()),
is_directory: true,
};
let file_limits = CoreLimits {
max_collection_files: 1,
..CoreLimits::default()
};
assert!(collect_import_files_with_limits(vec![source.clone()], &file_limits).is_err());
let size_limits = CoreLimits {
max_collection_files: 10,
max_total_bytes: 5,
..CoreLimits::default()
};
assert!(collect_import_files_with_limits(vec![source], &size_limits).is_err());
let path_limits = CoreLimits {
max_path_bytes: 4,
..CoreLimits::default()
};
let file = temp.path().join("long-name.txt");
std::fs::write(&file, b"x").unwrap();
assert!(collect_import_files_with_limits(
vec![ShareSource {
kind: SourceKind::Path,
value: file.to_string_lossy().to_string(),
display_name: Some("long-name.txt".to_string()),
is_directory: false,
}],
&path_limits,
)
.is_err());
}
#[test]
fn generated_relative_paths_never_accept_traversal_components() {
for prefix in ["", "folder/", "a/b/"] {
for traversal in ["..", "../escape", "..\\escape"] {
let candidate = format!("{prefix}{traversal}");
assert!(
validated_relative_string(&candidate).is_err(),
"{candidate}"
);
}
}
assert!(validated_relative_string(".").is_err());
assert!(validated_relative_string("/absolute").is_err());
}

View File

@@ -0,0 +1,13 @@
use crate::handshake::HandshakeResponse;
#[test]
fn malformed_handshake_response_is_rejected() {
for payload in [
r#"{"Approved":{"token":7,"expires_at":"later"}}"#,
r#"{"Denied":{}}"#,
r#"{"Unknown":{"reason":"no"}}"#,
"not-json",
] {
assert!(serde_json::from_str::<HandshakeResponse>(payload).is_err());
}
}

View File

@@ -0,0 +1,15 @@
use crate::api::CoreLimits;
#[test]
fn default_limits_are_valid() {
CoreLimits::default().validate().unwrap();
}
#[test]
fn zero_limit_is_rejected() {
let limits = CoreLimits {
max_sources: 0,
..CoreLimits::default()
};
assert!(limits.validate().is_err());
}

View File

@@ -0,0 +1,516 @@
use crate::{
api::CoreEvent,
repository::{ReceiverRequestInsert, Repository, TransferUpsert},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
};
fn transfer(
transfer_id: u64,
direction: TransferDirection,
status: TransferStatus,
) -> TransferUpsert<'static> {
TransferUpsert {
transfer_id,
peer_id: None,
direction,
status,
transfer_name: Some("demo"),
content_hash: Some("hash"),
ticket: Some("ticket"),
file_count: 1,
total_size: 12,
access_mode: "approval_required",
}
}
#[tokio::test]
async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 3);
repository
.insert_transfer(transfer(
7,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.unwrap();
let shares = repository.list_active_shares().await.unwrap();
assert_eq!(shares.len(), 1);
assert_eq!(shares[0].transfer_id, 7);
assert_eq!(shares[0].content_hash, "hash");
assert_eq!(shares[0].access_mode, "approval_required");
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(),
},
500,
)
.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), 500).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, "created");
drop(repository);
let reopened = Repository::open(temp.path()).await.unwrap();
let transfers = reopened.list_transfers().await.unwrap();
assert_eq!(transfers.len(), 1);
let events = reopened.list_events(Some(7), 500).await.unwrap();
assert_eq!(events[0].id, "event-1");
}
#[tokio::test]
async fn receiver_request_can_only_be_resolved_once() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-1",
transfer_id: 77,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: Some("receiver"),
receiver_device_name: Some("phone"),
app_version: "0.1.0",
})
.await
.unwrap();
repository
.update_receiver_request_status("request-1", ReceiverRequestStatus::Accepted, None)
.await
.unwrap();
assert!(repository
.update_receiver_request_status("request-1", ReceiverRequestStatus::Refused, Some("late"),)
.await
.is_err());
assert!(repository
.update_receiver_request_status("missing", ReceiverRequestStatus::Accepted, None)
.await
.is_err());
let requests = repository.list_receiver_requests(77).await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].status, "accepted");
assert_eq!(requests[0].receiver_name.as_deref(), Some("receiver"));
assert!(requests[0].responded_at.is_some());
}
#[tokio::test]
async fn startup_expiration_is_idempotent_for_pending_requests() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "pending-1",
transfer_id: 78,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: None,
receiver_device_name: None,
app_version: "0.1.0",
})
.await
.unwrap();
assert_eq!(
repository
.expire_pending_receiver_requests("restart")
.await
.unwrap(),
1
);
assert_eq!(
repository
.expire_pending_receiver_requests("restart")
.await
.unwrap(),
0
);
let requests = repository.list_receiver_requests(78).await.unwrap();
assert_eq!(requests[0].status, "expired");
assert_eq!(requests[0].reason.as_deref(), Some("restart"));
}
#[tokio::test]
async fn concurrent_approval_responses_have_single_winner() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "race-1",
transfer_id: 79,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: None,
receiver_device_name: None,
app_version: "0.1.0",
})
.await
.unwrap();
let accepted_repository = repository.clone();
let refused_repository = repository.clone();
let (accepted, refused) = tokio::join!(
accepted_repository.update_receiver_request_status(
"race-1",
ReceiverRequestStatus::Accepted,
None,
),
refused_repository.update_receiver_request_status(
"race-1",
ReceiverRequestStatus::Refused,
Some("race"),
),
);
assert_ne!(accepted.is_ok(), refused.is_ok());
let requests = repository.list_receiver_requests(79).await.unwrap();
assert!(matches!(
requests[0].status.as_str(),
"accepted" | "refused"
));
}
#[tokio::test]
async fn conditional_transition_rejects_stale_state() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
81,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.unwrap();
let error = repository
.transition_transfer_status(81, TransferStatus::Receiving, TransferStatus::Done)
.await
.unwrap_err();
assert!(error.to_string().contains("expected one matching transfer"));
assert_eq!(
repository.list_transfers().await.unwrap()[0].status,
"sharing"
);
}
#[tokio::test]
async fn repeated_terminal_transition_is_idempotent() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
88,
TransferDirection::Send,
TransferStatus::Importing,
))
.await
.unwrap();
repository
.transition_transfer_status(88, TransferStatus::Importing, TransferStatus::Failed)
.await
.unwrap();
repository
.transition_transfer_status(88, TransferStatus::Importing, TransferStatus::Failed)
.await
.unwrap();
assert_eq!(
repository.list_transfers().await.unwrap()[0].status,
"failed"
);
}
#[tokio::test]
async fn duplicate_transfer_does_not_overwrite_existing_record() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
82,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.unwrap();
assert!(repository
.insert_transfer(transfer(
82,
TransferDirection::Receive,
TransferStatus::Receiving,
))
.await
.is_err());
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.direction, "send");
assert_eq!(stored.status, "sharing");
}
#[tokio::test]
async fn recovery_fails_only_interrupted_states() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
83,
TransferDirection::Send,
TransferStatus::Importing,
))
.await
.unwrap();
repository
.insert_transfer(transfer(
84,
TransferDirection::Receive,
TransferStatus::Receiving,
))
.await
.unwrap();
repository
.insert_transfer(transfer(
85,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.unwrap();
let recovered = repository.recover_interrupted_transfers().await.unwrap();
assert_eq!(recovered.len(), 2);
assert_eq!(recovered[0].transfer_id, 83);
assert_eq!(recovered[0].previous_status, TransferStatus::Importing);
assert_eq!(recovered[1].transfer_id, 84);
assert_eq!(recovered[1].previous_status, TransferStatus::Receiving);
let transfers = repository.list_transfers().await.unwrap();
assert_eq!(
transfers
.iter()
.find(|transfer| transfer.transfer_id == 83)
.unwrap()
.status,
"failed"
);
assert_eq!(
transfers
.iter()
.find(|transfer| transfer.transfer_id == 84)
.unwrap()
.status,
"failed"
);
assert_eq!(
transfers
.iter()
.find(|transfer| transfer.transfer_id == 85)
.unwrap()
.status,
"sharing"
);
assert!(repository
.recover_interrupted_transfers()
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn share_completion_is_conditional_and_atomic() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(TransferUpsert {
transfer_id: 86,
peer_id: None,
direction: TransferDirection::Send,
status: TransferStatus::Importing,
transfer_name: Some("pending"),
content_hash: None,
ticket: None,
file_count: 0,
total_size: 0,
access_mode: "approval_required",
})
.await
.unwrap();
repository
.complete_share_import(TransferUpsert {
transfer_id: 86,
peer_id: None,
direction: TransferDirection::Send,
status: TransferStatus::Sharing,
transfer_name: Some("complete"),
content_hash: Some("final-hash"),
ticket: Some("final-ticket"),
file_count: 2,
total_size: 24,
access_mode: "approval_required",
})
.await
.unwrap();
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.status, "sharing");
assert_eq!(stored.transfer_name.as_deref(), Some("complete"));
assert_eq!(stored.content_hash.as_deref(), Some("final-hash"));
assert_eq!(stored.ticket.as_deref(), Some("final-ticket"));
assert_eq!(stored.file_count, 2);
assert_eq!(stored.total_size, 24);
assert!(repository
.complete_share_import(transfer(
86,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.is_err());
}
#[tokio::test]
async fn injected_write_failure_preserves_previous_state() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(TransferUpsert {
transfer_id: 87,
peer_id: None,
direction: TransferDirection::Send,
status: TransferStatus::Importing,
transfer_name: Some("pending"),
content_hash: None,
ticket: None,
file_count: 0,
total_size: 0,
access_mode: "approval_required",
})
.await
.unwrap();
repository.fail_next_write();
assert!(repository
.complete_share_import(transfer(
87,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.is_err());
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.status, "importing");
assert_eq!(stored.content_hash, None);
assert_eq!(stored.ticket, None);
}
#[tokio::test]
async fn migrates_schema_v2_identity_without_losing_transfer() {
let temp = tempfile::tempdir().unwrap();
let database = temp.path().join("vnidrop.sqlite3");
let options = SqliteConnectOptions::from_str("sqlite://")
.unwrap()
.filename(&database)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE 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,
access_mode TEXT NOT NULL DEFAULT 'approval_required',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
INSERT INTO transfers (
transfer_id, direction, status, transfer_name, content_hash, ticket,
file_count, total_size, access_mode, created_at, updated_at
) VALUES (7, 'send', 'stopped', 'legacy', 'hash', 'ticket', 1, 12,
'approval_required', 10, 11)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query("PRAGMA user_version = 2")
.execute(&pool)
.await
.unwrap();
pool.close().await;
let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 3);
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send");
assert_eq!(stored.transfer_name.as_deref(), Some("legacy"));
assert_eq!(stored.ticket.as_deref(), Some("ticket"));
assert_eq!(stored.peer_id, None);
}
#[tokio::test]
async fn event_reads_respect_configured_history_limit() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
for sequence in 0..3 {
repository
.insert_event(
&CoreEvent {
id: format!("event-{sequence}"),
timestamp: sequence,
scope: "endpoint".to_string(),
transfer_id: None,
direction: None,
phase: "test".to_string(),
kind: "generated".to_string(),
data_json: "{}".to_string(),
},
2,
)
.await
.unwrap();
}
let events = repository.list_events(None, 2).await.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].id, "event-2");
assert_eq!(events[1].id, "event-1");
}
use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};

View File

@@ -0,0 +1,143 @@
use std::sync::Arc;
use iroh_blobs::Hash;
use crate::{
repository::{Repository, TransferUpsert},
transfer_state::{TransferDirection, TransferStatus},
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
};
struct TestSink;
impl CoreEventSink for TestSink {
fn on_event(&self, _event: CoreEvent) {}
}
#[test]
fn initializes_and_reports_endpoint() {
let temp = tempfile::tempdir().unwrap();
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
assert!(!core.status().endpoint_id.is_empty());
core.shutdown();
}
#[test]
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
let temp = tempfile::tempdir().unwrap();
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let error = core
.receive(
"not-a-ticket".to_string(),
temp.path().to_string_lossy().to_string(),
None,
)
.unwrap_err();
assert!(matches!(error, VnidropError::Ticket { .. }));
let events = core.list_events(None).unwrap();
assert!(events
.iter()
.any(|event| event.phase == "error" && event.kind == "invalid-ticket"));
core.shutdown();
}
#[test]
fn startup_recovers_interrupted_transfer_and_persists_event() {
let temp = tempfile::tempdir().unwrap();
let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
preparation_runtime.block_on(async {
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(TransferUpsert {
transfer_id: 91,
peer_id: None,
direction: TransferDirection::Receive,
status: TransferStatus::Receiving,
transfer_name: Some("interrupted"),
content_hash: Some("hash"),
ticket: None,
file_count: 1,
total_size: 5,
access_mode: "approval_required",
})
.await
.unwrap();
});
drop(preparation_runtime);
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let transfer = core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 91)
.unwrap();
assert_eq!(transfer.status, "failed");
let events = core.list_events(Some(91)).unwrap();
assert!(events.iter().any(|event| {
event.phase == "recovery"
&& event.kind == "interrupted-transfer-failed"
&& event.data_json.contains("receiving")
}));
core.shutdown();
}
#[test]
fn startup_fails_persisted_share_when_root_blob_is_missing() {
let temp = tempfile::tempdir().unwrap();
let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
preparation_runtime.block_on(async {
let repository = Repository::open(temp.path()).await.unwrap();
let missing_hash = Hash::new([42; 32]).to_string();
repository
.insert_transfer(TransferUpsert {
transfer_id: 92,
peer_id: None,
direction: TransferDirection::Send,
status: TransferStatus::Sharing,
transfer_name: Some("missing blob"),
content_hash: Some(&missing_hash),
ticket: Some("ticket"),
file_count: 1,
total_size: 5,
access_mode: "approval_required",
})
.await
.unwrap();
});
drop(preparation_runtime);
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let transfer = core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 92)
.unwrap();
assert_eq!(transfer.status, "failed");
assert_eq!(core.status().active_shares, 0);
assert!(core.list_events(Some(92)).unwrap().iter().any(|event| {
event.phase == "recovery" && event.kind == "share-root-missing-or-corrupt"
}));
core.shutdown();
}

View File

@@ -0,0 +1,22 @@
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::secret::load_or_create_secret;
#[tokio::test]
async fn persists_with_restricted_permissions() {
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());
#[cfg(unix)]
assert_eq!(
std::fs::metadata(temp.path().join("iroh.secret"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}

View File

@@ -0,0 +1,134 @@
use data_encoding::BASE64URL_NOPAD;
use iroh::SecretKey;
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
use serde_json::json;
use crate::{
api::{CoreLimits, TransferMetadata},
ticket::{parse_transfer_ticket, parse_transfer_ticket_with_limits, VnidropTicket},
};
fn blob_ticket(hash_byte: u8) -> BlobTicket {
let secret = SecretKey::generate();
let addr = iroh::EndpointAddr::new(secret.public());
BlobTicket::new(addr, Hash::new([hash_byte; 32]), BlobFormat::HashSeq)
}
#[test]
fn metadata_ticket_round_trips() {
let blob_ticket = blob_ticket(7);
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 metadata_ticket_tolerates_wrapped_whitespace() {
let blob_ticket = blob_ticket(9);
let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10);
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata)
.encode()
.unwrap();
let wrapped = encoded
.as_bytes()
.chunks(8)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<_>>()
.join("\n ");
let parsed = parse_transfer_ticket(&wrapped).unwrap();
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
}
#[test]
fn invalid_ticket_is_rejected() {
assert!(parse_transfer_ticket("not-a-ticket").is_err());
}
#[test]
fn rejects_unsupported_versions_and_mismatched_hashes() {
let blob_ticket = blob_ticket(5);
let payload = json!({
"version": 2,
"blob_ticket": blob_ticket.to_string(),
"metadata": {
"version": 1,
"transfer_id": 7,
"transfer_name": "bad version",
"sender_name": null,
"created_at": 1,
"content_hash": blob_ticket.hash().to_string(),
"file_count": 1,
"total_size": 10
}
});
let encoded = format!(
"vnd1:{}",
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
);
assert!(parse_transfer_ticket(&encoded)
.unwrap_err()
.to_string()
.contains("unsupported VniDrop ticket version"));
let payload = json!({
"version": 1,
"blob_ticket": blob_ticket.to_string(),
"metadata": {
"version": 1,
"transfer_id": 7,
"transfer_name": "bad hash",
"sender_name": null,
"created_at": 1,
"content_hash": Hash::new([6; 32]).to_string(),
"file_count": 1,
"total_size": 10
}
});
let encoded = format!(
"vnd1:{}",
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
);
assert!(parse_transfer_ticket(&encoded)
.unwrap_err()
.to_string()
.contains("metadata hash does not match"));
}
#[test]
fn rejects_ticket_over_configured_size_limit() {
let limits = CoreLimits {
max_ticket_bytes: 8,
..CoreLimits::default()
};
assert!(parse_transfer_ticket_with_limits("not-a-ticket", &limits).is_err());
}
#[test]
fn parser_rejects_or_parses_generated_inputs_without_panicking() {
let alphabet = b"vnd1:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ /\\\n";
let mut state = 0x9e37_79b9u32;
for len in 0..512usize {
let mut input = String::with_capacity(len);
for _ in 0..len {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
input.push(alphabet[state as usize % alphabet.len()] as char);
}
let _ = parse_transfer_ticket(&input);
}
}

View File

@@ -0,0 +1,31 @@
use crate::transfer_state::{TransferDirection, TransferStatus};
#[test]
fn parses_only_known_persisted_values() {
assert_eq!(
TransferDirection::try_from("send").unwrap(),
TransferDirection::Send
);
assert_eq!(
TransferStatus::try_from("receiving").unwrap(),
TransferStatus::Receiving
);
assert!(TransferDirection::try_from("sideways").is_err());
assert!(TransferStatus::try_from("pending-ish").is_err());
}
#[test]
fn permits_only_defined_lifecycle_transitions() {
assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Sharing));
assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Failed));
assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Cancelled));
assert!(TransferStatus::Sharing.can_transition_to(TransferStatus::Stopped));
assert!(TransferStatus::Sharing.can_transition_to(TransferStatus::Failed));
assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Done));
assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Failed));
assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Cancelled));
assert!(!TransferStatus::Sharing.can_transition_to(TransferStatus::Done));
assert!(!TransferStatus::Done.can_transition_to(TransferStatus::Receiving));
assert!(!TransferStatus::Failed.can_transition_to(TransferStatus::Sharing));
}

View File

@@ -0,0 +1,44 @@
# Core test organization
VniDrop uses two complementary Rust test layers.
## Internal tests
Tests under `src/tests/` can exercise crate-private invariants without widening
the production API:
- `access_policy.rs`: authorization and approval-session rules.
- `filesystem.rs`: source collection and path validation.
- `handshake.rs`: malformed protocol response handling.
- `limits.rs`: core-limit validation.
- `repository.rs`: schema, persistence, and transition invariants.
- `runtime.rs`: public error mapping and runtime orchestration.
- `secret.rs`: node identity persistence and file permissions.
- `ticket.rs`: ticket encoding, parsing, and metadata validation.
- `transfer_state.rs`: persisted enum parsing and legal lifecycle transitions.
## Integration tests
Files directly under `tests/` are black-box scenarios. They must use the public
`vnidrop` API and should be organized by behavior rather than implementation
module:
- `approval.rs`: receiver authorization flows.
- `lifecycle.rs`: stop, restart, recovery, and revocation behavior.
- `output_sink.rs`: foreign output-sink contracts and failures.
- `transfer.rs`: end-to-end file and directory transfers.
Reusable fixtures live in `tests/support/`. `CoreGuard` shuts down a test core
on drop, while `RecordingSink` and `MemoryOutputSink` keep assertions focused
on externally observable behavior.
## Test requirements
- Every bug fix must include a regression test.
- Avoid arbitrary sleeps. When polling an asynchronous boundary is unavoidable,
use a short interval and a bounded timeout with a useful failure message.
- Prefer deterministic IDs, inputs, and clocks.
- Do not expose production internals solely for integration tests.
- Failure tests must verify durable status and emitted events when applicable,
not only the returned error.
- Recovery tests must close the original core and reopen the same data directory.

View File

@@ -0,0 +1,130 @@
mod support;
use std::sync::Arc;
use support::{
receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink,
TestNode,
};
use vnidrop::CoreLimits;
#[test]
fn approval_required_denies_then_allows_receiver() {
let source_dir = tempfile::tempdir().unwrap();
let denied_output = tempfile::tempdir().unwrap();
let allowed_output = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("private.txt");
std::fs::write(&source_path, b"approved content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 9, "private.txt", false);
assert!(receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket.clone(),
denied_output.path(),
false,
)
.is_err());
assert!(sender
.sink
.events()
.iter()
.any(|event| event.phase == "approval" && event.kind == "receiver-refused"));
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
allowed_output.path(),
true,
)
.unwrap();
assert_eq!(
std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
b"approved content"
);
}
#[test]
fn receiver_can_cancel_while_waiting_for_approval() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("waiting.txt");
std::fs::write(&source_path, b"waiting").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 26, "waiting.txt", false);
let receiver_core = receiver.core.arc();
let ticket = share.ticket;
let output = output_dir.path().to_string_lossy().to_string();
let worker = std::thread::spawn(move || {
receiver_core.receive(ticket, output, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(&sender.core, share.transfer_id);
receiver.core.cancel_transfer(share.transfer_id).unwrap();
let _ = sender.core.respond_receiver_request(
request.id,
false,
Some("receiver-cancelled".to_string()),
);
assert!(worker.join().unwrap().is_err());
let transfer = receiver
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == share.transfer_id)
.unwrap();
assert_eq!(transfer.status, "cancelled");
}
#[test]
fn pending_approval_limit_denies_excess_receiver() {
let sender_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let output_one = tempfile::tempdir().unwrap();
let output_two = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("limited.txt");
std::fs::write(&source_path, b"limited").unwrap();
let limits = CoreLimits {
max_pending_approvals: 1,
..CoreLimits::default()
};
let sender = CoreGuard::start_with_limits(
sender_dir.path(),
Arc::new(RecordingSink::default()),
limits,
);
let receiver_one = TestNode::new();
let receiver_two = TestNode::new();
let share = share_path(&sender, &source_path, 28, "limited.txt", false);
let first_core = receiver_one.core.arc();
let first_ticket = share.ticket.clone();
let first_output = output_one.path().to_string_lossy().to_string();
let first = std::thread::spawn(move || {
first_core.receive(first_ticket, first_output, Some("first".to_string()))
});
let request = wait_for_receiver_request(&sender, share.transfer_id);
let second = receiver_two.core.receive(
share.ticket,
output_two.path().to_string_lossy().to_string(),
Some("second".to_string()),
);
assert!(second
.unwrap_err()
.to_string()
.contains("too-many-pending-approvals"));
sender
.respond_receiver_request(request.id, false, Some("test complete".to_string()))
.unwrap();
assert!(first.join().unwrap().is_err());
}

View File

@@ -0,0 +1,251 @@
mod support;
use std::sync::Arc;
use std::time::{Duration, Instant};
use support::{share_path, CoreGuard, RecordingSink, TestNode};
use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode};
#[test]
fn cancelling_share_updates_status_and_events() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("cancel.txt");
std::fs::write(&source_path, b"cancel me").unwrap();
let sender = TestNode::new();
let share = share_path(&sender.core, &source_path, 10, "cancel.txt", false);
sender.core.cancel_transfer(share.transfer_id).unwrap();
let transfers = sender.core.list_transfers().unwrap();
assert_eq!(transfers[0].status, "stopped");
assert!(sender
.sink
.events()
.iter()
.any(|event| event.kind == "share-stopped"));
}
#[test]
fn persisted_share_is_recovered_and_can_be_stopped_after_restart() {
let source_dir = tempfile::tempdir().unwrap();
let core_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("persistent.txt");
std::fs::write(&source_path, b"survives restart").unwrap();
let sender = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default()));
let share = share_path(&sender, &source_path, 20, "persistent.txt", false);
drop(sender);
let restarted = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default()));
assert_eq!(restarted.status().active_shares, 1);
restarted.cancel_transfer(share.transfer_id).unwrap();
let transfer = restarted
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == share.transfer_id)
.unwrap();
assert_eq!(transfer.status, "stopped");
assert_eq!(restarted.status().active_shares, 0);
}
#[test]
fn stopped_share_rejects_direct_legacy_blob_ticket() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("revoked.txt");
std::fs::write(&source_path, b"must not be served").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 21, "revoked.txt", false);
sender.core.cancel_transfer(share.transfer_id).unwrap();
let result = receiver.core.receive(
share.blob_ticket,
output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
);
assert!(result.is_err(), "a stopped share must not serve blob bytes");
assert!(!output_dir.path().join("revoked.txt").exists());
}
#[test]
fn failed_import_leaves_durable_failed_transfer() {
let source_dir = tempfile::tempdir().unwrap();
let sender = TestNode::new();
let transfer_id = 22;
let result = sender.core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_dir
.path()
.join("missing.txt")
.to_string_lossy()
.to_string(),
display_name: Some("missing.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some("missing".to_string()),
sender_name: None,
},
);
assert!(result.is_err());
let transfer = sender
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == transfer_id)
.unwrap();
assert_eq!(transfer.status, "failed");
assert_eq!(sender.core.status().active_shares, 0);
}
#[test]
fn duplicate_transfer_id_does_not_replace_active_share() {
let source_dir = tempfile::tempdir().unwrap();
let first_path = source_dir.path().join("first.txt");
let second_path = source_dir.path().join("second.txt");
std::fs::write(&first_path, b"first").unwrap();
std::fs::write(&second_path, b"second").unwrap();
let sender = TestNode::new();
let first = share_path(&sender.core, &first_path, 23, "first.txt", false);
let duplicate = sender.core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: second_path.to_string_lossy().to_string(),
display_name: Some("second.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: first.transfer_id,
transfer_name: Some("second".to_string()),
sender_name: None,
},
);
assert!(duplicate.is_err());
assert_eq!(sender.core.status().active_shares, 1);
let transfer = sender
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == first.transfer_id)
.unwrap();
assert_eq!(transfer.status, "sharing");
assert_eq!(transfer.transfer_name.as_deref(), Some("first.txt"));
}
#[test]
fn access_mode_update_requires_active_persisted_share() {
let sender = TestNode::new();
assert!(sender
.core
.set_transfer_access_mode(999, TransferAccessMode::Public)
.is_err());
}
#[test]
fn source_limit_rejection_creates_no_transfer_state() {
let core_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let first = source_dir.path().join("one.txt");
let second = source_dir.path().join("two.txt");
std::fs::write(&first, b"one").unwrap();
std::fs::write(&second, b"two").unwrap();
let limits = CoreLimits {
max_sources: 1,
..CoreLimits::default()
};
let sender =
CoreGuard::start_with_limits(core_dir.path(), Arc::new(RecordingSink::default()), limits);
let result = sender.share_files(
vec![
ShareSource {
kind: SourceKind::Path,
value: first.to_string_lossy().to_string(),
display_name: Some("one.txt".to_string()),
is_directory: false,
},
ShareSource {
kind: SourceKind::Path,
value: second.to_string_lossy().to_string(),
display_name: Some("two.txt".to_string()),
is_directory: false,
},
],
ShareMetadataInput {
transfer_id: 24,
transfer_name: Some("too many".to_string()),
sender_name: None,
},
);
assert!(result.is_err());
assert!(sender.list_transfers().unwrap().is_empty());
}
#[test]
fn cancellation_during_import_is_durable() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("large.bin");
std::fs::File::create(&source_path)
.unwrap()
.set_len(256 * 1024 * 1024)
.unwrap();
let sender = TestNode::new();
let core = sender.core.arc();
let worker = std::thread::spawn(move || {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().to_string(),
display_name: Some("large.bin".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 25,
transfer_name: Some("large".to_string()),
sender_name: None,
},
)
});
let started = Instant::now();
loop {
if sender
.core
.list_transfers()
.unwrap()
.iter()
.any(|transfer| transfer.transfer_id == 25 && transfer.status == "importing")
{
break;
}
assert!(started.elapsed() < Duration::from_secs(10));
std::thread::sleep(Duration::from_millis(10));
}
sender.core.cancel_transfer(25).unwrap();
assert!(worker.join().unwrap().is_err());
let transfer = sender
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 25)
.unwrap();
assert_eq!(transfer.status, "cancelled");
assert_eq!(sender.core.status().active_transfers, 0);
assert_eq!(sender.core.status().active_shares, 0);
}

View File

@@ -1,496 +0,0 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use vnidrop::{
CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, ShareSource,
SourceKind, VnidropCore, VnidropError,
};
#[derive(Default)]
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
fn events(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
}
#[derive(Default)]
struct MemoryOutputSink {
files: Mutex<HashMap<String, Vec<u8>>>,
fail_writes: bool,
}
impl ReceiveOutputSink for MemoryOutputSink {
fn start_file(&self, relative_path: String) -> Result<(), VnidropError> {
self.files.lock().unwrap().insert(relative_path, Vec::new());
Ok(())
}
fn write_chunk(&self, relative_path: String, bytes: Vec<u8>) -> Result<(), VnidropError> {
if self.fail_writes {
return Err(VnidropError::Filesystem {
reason: "sink write failed".to_string(),
});
}
self.files
.lock()
.unwrap()
.get_mut(&relative_path)
.expect("file was not started")
.extend(bytes);
Ok(())
}
fn finish_file(&self, _relative_path: String) -> Result<(), VnidropError> {
Ok(())
}
}
impl MemoryOutputSink {
fn file(&self, relative_path: &str) -> Vec<u8> {
self.files.lock().unwrap()[relative_path].clone()
}
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest {
let started = Instant::now();
loop {
let requests = sender.list_receiver_requests(transfer_id).unwrap();
if let Some(request) = requests
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(50));
}
}
fn receive_with_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_dir: String,
receiver_name: Option<String>,
accepted: bool,
) -> Result<(), String> {
let handle = std::thread::spawn(move || {
receiver
.receive(ticket, output_dir, receiver_name)
.map_err(|error| error.to_string())
});
let request = wait_for_receiver_request(sender, transfer_id);
sender
.respond_receiver_request(
request.id,
accepted,
(!accepted).then(|| "sender-refused".to_string()),
)
.unwrap();
handle.join().unwrap()
}
fn receive_with_sink_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSink>,
receiver_name: Option<String>,
accepted: bool,
) -> Result<(), String> {
let handle = std::thread::spawn(move || {
receiver
.receive_with_output_sink(ticket, output_sink, receiver_name)
.map_err(|error| error.to_string())
});
let request = wait_for_receiver_request(sender, transfer_id);
sender
.respond_receiver_request(
request.id,
accepted,
(!accepted).then(|| "sender-refused".to_string()),
)
.unwrap();
handle.join().unwrap()
}
#[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_sink = Arc::new(RecordingSink::default());
let receiver_sink = Arc::new(RecordingSink::default());
let sender = VnidropCore::initialize(
sender_dir.path().join("core").to_string_lossy().to_string(),
sender_sink.clone(),
)
.unwrap();
let receiver = VnidropCore::initialize(
receiver_dir
.path()
.join("core")
.to_string_lossy()
.to_string(),
receiver_sink.clone(),
)
.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();
receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("hello.txt")).unwrap(),
b"hello from vnidrop"
);
sender.shutdown();
receiver.shutdown();
}
#[test]
fn two_local_cores_transfer_directory() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_root = sender_dir.path().join("photos");
std::fs::create_dir_all(source_root.join("nested")).unwrap();
std::fs::write(source_root.join("cover.txt"), b"cover").unwrap();
std::fs::write(source_root.join("nested").join("inside.txt"), b"inside").unwrap();
let sender = VnidropCore::initialize(
sender_dir.path().join("core").to_string_lossy().to_string(),
Arc::new(RecordingSink::default()),
)
.unwrap();
let receiver = VnidropCore::initialize(
receiver_dir
.path()
.join("core")
.to_string_lossy()
.to_string(),
Arc::new(RecordingSink::default()),
)
.unwrap();
let share = sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_root.to_string_lossy().to_string(),
display_name: Some("photos".to_string()),
is_directory: true,
}],
ShareMetadataInput {
transfer_id: 8,
transfer_name: Some("photos".to_string()),
sender_name: Some("sender".to_string()),
},
)
.unwrap();
receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("photos").join("cover.txt")).unwrap(),
b"cover"
);
assert_eq!(
std::fs::read(
output_dir
.path()
.join("photos")
.join("nested")
.join("inside.txt")
)
.unwrap(),
b"inside"
);
sender.shutdown();
receiver.shutdown();
}
#[test]
fn output_sink_receive_exports_nested_files() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_dir = tempfile::tempdir().unwrap();
let source_root = sender_dir.path().join("photos");
std::fs::create_dir_all(source_root.join("nested")).unwrap();
std::fs::write(source_root.join("cover.txt"), b"cover").unwrap();
std::fs::write(source_root.join("nested").join("inside.txt"), b"inside").unwrap();
let sender = VnidropCore::initialize(
sender_dir.path().join("core").to_string_lossy().to_string(),
Arc::new(RecordingSink::default()),
)
.unwrap();
let receiver = VnidropCore::initialize(
receiver_dir
.path()
.join("core")
.to_string_lossy()
.to_string(),
Arc::new(RecordingSink::default()),
)
.unwrap();
let share = sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_root.to_string_lossy().to_string(),
display_name: Some("photos".to_string()),
is_directory: true,
}],
ShareMetadataInput {
transfer_id: 18,
transfer_name: Some("photos".to_string()),
sender_name: Some("sender".to_string()),
},
)
.unwrap();
let output_sink = Arc::new(MemoryOutputSink::default());
receive_with_sink_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_sink.clone(),
Some("receiver".to_string()),
true,
)
.unwrap();
assert_eq!(output_sink.file("photos/cover.txt"), b"cover");
assert_eq!(output_sink.file("photos/nested/inside.txt"), b"inside");
sender.shutdown();
receiver.shutdown();
}
#[test]
fn output_sink_receive_fails_when_sink_write_fails() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_dir = tempfile::tempdir().unwrap();
let source_path = sender_dir.path().join("hello.txt");
std::fs::write(&source_path, b"hello").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: 19,
transfer_name: Some("hello".to_string()),
sender_name: Some("sender".to_string()),
},
)
.unwrap();
let output_sink = Arc::new(MemoryOutputSink {
files: Mutex::new(HashMap::new()),
fail_writes: true,
});
let error = receive_with_sink_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_sink,
Some("receiver".to_string()),
true,
)
.unwrap_err();
assert!(error.contains("sink write failed"));
sender.shutdown();
receiver.shutdown();
}
#[test]
fn approval_required_denies_then_allows_receiver() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_dir = tempfile::tempdir().unwrap();
let denied_output = tempfile::tempdir().unwrap();
let allowed_output = tempfile::tempdir().unwrap();
let source_path = sender_dir.path().join("private.txt");
std::fs::write(&source_path, b"approved content").unwrap();
let sender_sink = Arc::new(RecordingSink::default());
let sender = VnidropCore::initialize(
sender_dir.path().join("core").to_string_lossy().to_string(),
sender_sink.clone(),
)
.unwrap();
let receiver = VnidropCore::initialize(
receiver_dir
.path()
.join("core")
.to_string_lossy()
.to_string(),
Arc::new(RecordingSink::default()),
)
.unwrap();
let share = sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().to_string(),
display_name: Some("private.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 9,
transfer_name: Some("private".to_string()),
sender_name: None,
},
)
.unwrap();
assert!(receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket.clone(),
denied_output.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
false,
)
.is_err());
assert!(sender_sink
.events()
.iter()
.any(|event| event.phase == "approval" && event.kind == "receiver-refused"));
receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
allowed_output.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
true,
)
.unwrap();
assert_eq!(
std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
b"approved content"
);
sender.shutdown();
receiver.shutdown();
}
#[test]
fn cancelling_share_updates_status_and_events() {
let sender_dir = tempfile::tempdir().unwrap();
let source_path = sender_dir.path().join("cancel.txt");
std::fs::write(&source_path, b"cancel me").unwrap();
let sink = Arc::new(RecordingSink::default());
let sender = VnidropCore::initialize(
sender_dir.path().join("core").to_string_lossy().to_string(),
sink.clone(),
)
.unwrap();
let share = sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().to_string(),
display_name: Some("cancel.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 10,
transfer_name: Some("cancel".to_string()),
sender_name: None,
},
)
.unwrap();
sender.cancel_transfer(share.transfer_id).unwrap();
let transfers = sender.list_transfers().unwrap();
assert_eq!(transfers[0].status, "stopped");
assert!(sink
.events()
.iter()
.any(|event| event.kind == "share-stopped"));
sender.shutdown();
}

View File

@@ -0,0 +1,104 @@
mod support;
use std::sync::Arc;
use std::time::{Duration, Instant};
use support::{
receive_with_sink_response, share_path, wait_for_receiver_request, MemoryOutputSink, TestNode,
};
#[test]
fn exports_nested_files_to_output_sink() {
let source_dir = tempfile::tempdir().unwrap();
let source_root = source_dir.path().join("photos");
std::fs::create_dir_all(source_root.join("nested")).unwrap();
std::fs::write(source_root.join("cover.txt"), b"cover").unwrap();
std::fs::write(source_root.join("nested/inside.txt"), b"inside").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_root, 18, "photos", true);
let output_sink = Arc::new(MemoryOutputSink::default());
receive_with_sink_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_sink.clone(),
true,
)
.unwrap();
assert_eq!(output_sink.file("photos/cover.txt"), b"cover");
assert_eq!(output_sink.file("photos/nested/inside.txt"), b"inside");
assert_eq!(
output_sink.terminal_state("photos/cover.txt"),
Some("finished")
);
assert_eq!(
output_sink.terminal_state("photos/nested/inside.txt"),
Some("finished")
);
}
#[test]
fn reports_output_sink_write_failure() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"hello").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 19, "hello.txt", false);
let output_sink = Arc::new(MemoryOutputSink::failing_writes());
let error = receive_with_sink_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_sink.clone(),
true,
)
.unwrap_err();
assert!(error.contains("sink write failed"));
assert_eq!(output_sink.terminal_state("hello.txt"), Some("aborted"));
}
#[test]
fn cancellation_during_export_aborts_open_sink_file() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("slow.bin");
std::fs::File::create(&source_path)
.unwrap()
.set_len(32 * 1024 * 1024)
.unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 29, "slow.bin", false);
let output_sink = Arc::new(MemoryOutputSink::slow_writes(Duration::from_millis(25)));
let receiver_core = receiver.core.arc();
let sink_for_worker = output_sink.clone();
let worker = std::thread::spawn(move || {
receiver_core.receive_with_output_sink(
share.ticket,
sink_for_worker,
Some("receiver".to_string()),
)
});
let request = wait_for_receiver_request(&sender.core, share.transfer_id);
sender
.core
.respond_receiver_request(request.id, true, None)
.unwrap();
let started = Instant::now();
while !output_sink.has_started("slow.bin") {
assert!(started.elapsed() < Duration::from_secs(15));
std::thread::sleep(Duration::from_millis(10));
}
receiver.core.cancel_transfer(share.transfer_id).unwrap();
assert!(worker.join().unwrap().is_err());
assert_eq!(output_sink.terminal_state("slow.bin"), Some("aborted"));
}

View File

@@ -0,0 +1,262 @@
// Cargo compiles every file in `tests/` as a separate crate, and each scenario
// intentionally uses only a subset of this shared harness.
#![allow(dead_code)]
use std::{
collections::HashMap,
ops::Deref,
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput,
ShareResult, ShareSource, SourceKind, VnidropCore, VnidropError,
};
#[derive(Default)]
pub struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
pub fn events(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
}
pub struct CoreGuard(Arc<VnidropCore>);
impl CoreGuard {
pub fn start(path: &Path, sink: Arc<dyn CoreEventSink>) -> Self {
Self(
VnidropCore::initialize(path.to_string_lossy().to_string(), sink)
.expect("test core should initialize"),
)
}
pub fn start_with_limits(
path: &Path,
sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
) -> Self {
Self(
VnidropCore::initialize_with_limits(path.to_string_lossy().to_string(), sink, limits)
.expect("test core should initialize with limits"),
)
}
pub fn arc(&self) -> Arc<VnidropCore> {
self.0.clone()
}
}
impl Deref for CoreGuard {
type Target = VnidropCore;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Drop for CoreGuard {
fn drop(&mut self) {
self.0.shutdown();
}
}
pub struct TestNode {
_data_dir: tempfile::TempDir,
pub core: CoreGuard,
pub sink: Arc<RecordingSink>,
}
impl TestNode {
pub fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink::default());
let core = CoreGuard::start(data_dir.path(), sink.clone());
Self {
_data_dir: data_dir,
core,
sink,
}
}
}
#[derive(Default)]
pub struct MemoryOutputSink {
files: Mutex<HashMap<String, Vec<u8>>>,
terminal: Mutex<HashMap<String, &'static str>>,
fail_writes: bool,
write_delay: Duration,
}
impl MemoryOutputSink {
pub fn failing_writes() -> Self {
Self {
files: Mutex::new(HashMap::new()),
terminal: Mutex::new(HashMap::new()),
fail_writes: true,
write_delay: Duration::ZERO,
}
}
pub fn slow_writes(delay: Duration) -> Self {
Self {
files: Mutex::new(HashMap::new()),
terminal: Mutex::new(HashMap::new()),
fail_writes: false,
write_delay: delay,
}
}
pub fn file(&self, relative_path: &str) -> Vec<u8> {
self.files.lock().unwrap()[relative_path].clone()
}
pub fn terminal_state(&self, relative_path: &str) -> Option<&'static str> {
self.terminal.lock().unwrap().get(relative_path).copied()
}
pub fn has_started(&self, relative_path: &str) -> bool {
self.files.lock().unwrap().contains_key(relative_path)
}
}
impl ReceiveOutputSink for MemoryOutputSink {
fn start_file(&self, relative_path: String) -> Result<(), VnidropError> {
self.files.lock().unwrap().insert(relative_path, Vec::new());
Ok(())
}
fn write_chunk(&self, relative_path: String, bytes: Vec<u8>) -> Result<(), VnidropError> {
if !self.write_delay.is_zero() {
std::thread::sleep(self.write_delay);
}
if self.fail_writes {
return Err(VnidropError::Filesystem {
reason: "sink write failed".to_string(),
});
}
self.files
.lock()
.unwrap()
.get_mut(&relative_path)
.expect("file was not started")
.extend(bytes);
Ok(())
}
fn finish_file(&self, relative_path: String) -> Result<(), VnidropError> {
self.terminal
.lock()
.unwrap()
.insert(relative_path, "finished");
Ok(())
}
fn abort_file(&self, relative_path: String, _reason: String) -> Result<(), VnidropError> {
self.files.lock().unwrap().remove(&relative_path);
self.terminal
.lock()
.unwrap()
.insert(relative_path, "aborted");
Ok(())
}
}
pub fn share_path(
sender: &VnidropCore,
source: &Path,
transfer_id: u64,
display_name: &str,
is_directory: bool,
) -> ShareResult {
sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().to_string(),
display_name: Some(display_name.to_string()),
is_directory,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some(display_name.to_string()),
sender_name: Some("sender".to_string()),
},
)
.expect("test share should be created")
}
pub fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest {
let started = Instant::now();
loop {
let requests = sender.list_receiver_requests(transfer_id).unwrap();
if let Some(request) = requests
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(25));
}
}
pub fn receive_with_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_dir: &Path,
accepted: bool,
) -> Result<(), String> {
let output_dir = output_dir.to_string_lossy().to_string();
let handle = std::thread::spawn(move || {
receiver
.receive(ticket, output_dir, Some("receiver".to_string()))
.map_err(|error| error.to_string())
});
respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap()
}
pub fn receive_with_sink_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSink>,
accepted: bool,
) -> Result<(), String> {
let handle = std::thread::spawn(move || {
receiver
.receive_with_output_sink(ticket, output_sink, Some("receiver".to_string()))
.map_err(|error| error.to_string())
});
respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap()
}
fn respond_to_pending_request(sender: &VnidropCore, transfer_id: u64, accepted: bool) {
let request = wait_for_receiver_request(sender, transfer_id);
sender
.respond_receiver_request(
request.id,
accepted,
(!accepted).then(|| "sender-refused".to_string()),
)
.unwrap();
}

View File

@@ -0,0 +1,104 @@
mod support;
use support::{receive_with_response, share_path, TestNode};
#[test]
fn transfers_file_between_two_cores() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"hello from vnidrop").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 7, "hello.txt", false);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("hello.txt")).unwrap(),
b"hello from vnidrop"
);
let received = receiver
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 7)
.unwrap();
assert!(!received.local_id.is_empty());
assert_eq!(
received.peer_id.as_deref(),
Some(sender.core.status().endpoint_id.as_str())
);
}
#[test]
fn transfers_directory_between_two_cores() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_root = source_dir.path().join("photos");
std::fs::create_dir_all(source_root.join("nested")).unwrap();
std::fs::write(source_root.join("cover.txt"), b"cover").unwrap();
std::fs::write(source_root.join("nested/inside.txt"), b"inside").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_root, 8, "photos", true);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("photos/cover.txt")).unwrap(),
b"cover"
);
assert_eq!(
std::fs::read(output_dir.path().join("photos/nested/inside.txt")).unwrap(),
b"inside"
);
}
#[test]
fn receive_refuses_to_overwrite_existing_destination() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("existing.txt");
let output_path = output_dir.path().join("existing.txt");
std::fs::write(&source_path, b"new content").unwrap();
std::fs::write(&output_path, b"keep content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 27, "existing.txt", false);
assert!(receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_dir.path(),
true,
)
.is_err());
assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content");
assert!(std::fs::read_dir(output_dir.path())
.unwrap()
.all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.contains(".part")));
}