mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
test(core): organize hardening coverage and CI
This commit is contained in:
37
.github/workflows/rust-core.yml
vendored
Normal file
37
.github/workflows/rust-core.yml
vendored
Normal 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
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
56
crates/vnidrop/src/tests/access_policy.rs
Normal file
56
crates/vnidrop/src/tests/access_policy.rs
Normal 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"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
233
crates/vnidrop/src/tests/filesystem.rs
Normal file
233
crates/vnidrop/src/tests/filesystem.rs
Normal 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());
|
||||||
|
}
|
||||||
13
crates/vnidrop/src/tests/handshake.rs
Normal file
13
crates/vnidrop/src/tests/handshake.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/vnidrop/src/tests/limits.rs
Normal file
15
crates/vnidrop/src/tests/limits.rs
Normal 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());
|
||||||
|
}
|
||||||
516
crates/vnidrop/src/tests/repository.rs
Normal file
516
crates/vnidrop/src/tests/repository.rs
Normal 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};
|
||||||
143
crates/vnidrop/src/tests/runtime.rs
Normal file
143
crates/vnidrop/src/tests/runtime.rs
Normal 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();
|
||||||
|
}
|
||||||
22
crates/vnidrop/src/tests/secret.rs
Normal file
22
crates/vnidrop/src/tests/secret.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
134
crates/vnidrop/src/tests/ticket.rs
Normal file
134
crates/vnidrop/src/tests/ticket.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
crates/vnidrop/src/tests/transfer_state.rs
Normal file
31
crates/vnidrop/src/tests/transfer_state.rs
Normal 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));
|
||||||
|
}
|
||||||
44
crates/vnidrop/tests/README.md
Normal file
44
crates/vnidrop/tests/README.md
Normal 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.
|
||||||
130
crates/vnidrop/tests/approval.rs
Normal file
130
crates/vnidrop/tests/approval.rs
Normal 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());
|
||||||
|
}
|
||||||
251
crates/vnidrop/tests/lifecycle.rs
Normal file
251
crates/vnidrop/tests/lifecycle.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
104
crates/vnidrop/tests/output_sink.rs
Normal file
104
crates/vnidrop/tests/output_sink.rs
Normal 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"));
|
||||||
|
}
|
||||||
262
crates/vnidrop/tests/support/mod.rs
Normal file
262
crates/vnidrop/tests/support/mod.rs
Normal 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();
|
||||||
|
}
|
||||||
104
crates/vnidrop/tests/transfer.rs
Normal file
104
crates/vnidrop/tests/transfer.rs
Normal 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")));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user