Files
varde/varde-daemon/tests/lan_trust.rs
T
bl 20bdf56668 Milestone 4: LAN discovery, trust gate, auto-sync, rate limits, events
mDNS-style LAN discovery (iroh MdnsDiscovery, discovery flag, default
on) feeds a presence tracker; trusted peers that appear trigger fetches
of every incomplete pin, and Pin itself now fetches from present
trusted peers. Serving is our own ProtocolHandler: trusted NodeIds get
the full store, everyone else a filtered view limited to open_lan pins
and ticket-exported hashes (plus hashseq children) that answers "not
found" for the rest. Ticket export records standing serve-consent for
that hash; trust changes take effect on new connections. ShapedStore
implements the full iroh-blobs Store trait to charge provider reads to
an upload token bucket and downloader writes to a download bucket;
upload cap 0 closes incoming connections at accept. Subscribe now
streams transfer_progress both ways, peer_joined, and pin_complete.

Tests: forged-ticket trust gating (denied untrusted, served after
trust), 256 KiB/s upload cap enforced by wall clock, event stream
during a transfer, and real-mdns auto-sync between two daemons
(skips where multicast is unavailable).

Dependencies: n0-future, async-channel, futures-lite, bytes — all
already in the tree via iroh; needed directly to name types in
iroh-blobs trait signatures and channels. iroh feature
discovery-local-network for MdnsDiscovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:27:58 +02:00

288 lines
11 KiB
Rust

//! Milestone 4: trust gating, rate limiting, event streaming, and
//! mdns-based auto-sync between trusted daemons.
mod support;
use std::path::Path;
use std::str::FromStr;
use std::time::{Duration, Instant};
use varde_proto::{MaterializeMode, PinPolicy, Request, ResponseData};
fn add_file(client: &mut support::Client, path: &Path) -> String {
let reply = client.request(&Request::Add {
path: path.display().to_string(),
recursive: false,
});
assert!(reply.ok, "add failed: {:?}", reply.error);
match reply.data {
Some(ResponseData::Added { hash, .. }) => hash,
other => panic!("unexpected add reply: {other:?}"),
}
}
fn pin(client: &mut support::Client, hash: &str, open_lan: bool) {
let reply = client.request(&Request::Pin {
hash: hash.to_string(),
policy: PinPolicy { open_lan },
});
assert!(reply.ok, "pin failed: {:?}", reply.error);
}
fn export_ticket(client: &mut support::Client, hash: &str) -> String {
let reply = client.request(&Request::TicketExport {
hash: hash.to_string(),
});
assert!(reply.ok, "ticket export failed: {:?}", reply.error);
match reply.data {
Some(ResponseData::Ticket { ticket }) => ticket,
other => panic!("unexpected ticket reply: {other:?}"),
}
}
fn import_ticket(client: &mut support::Client, ticket: &str) {
let reply = client.request(&Request::TicketImport {
ticket: ticket.to_string(),
pin_policy: PinPolicy::default(),
});
assert!(reply.ok, "ticket import failed: {:?}", reply.error);
}
fn node_id(client: &mut support::Client) -> String {
let reply = client.request(&Request::Status { hash: None });
match reply.data {
Some(ResponseData::Status(s)) => s.node_id.expect("daemon has a node id"),
other => panic!("unexpected status reply: {other:?}"),
}
}
fn is_complete(socket: &Path, hash: &str) -> bool {
let mut client = support::Client::connect(socket);
let reply = client.request(&Request::Status {
hash: Some(hash.to_string()),
});
matches!(
reply.data,
Some(ResponseData::HashStatus(s)) if s.complete
)
}
fn wait_complete(socket: &Path, hash: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if is_complete(socket, hash) {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
false
}
/// Re-target a ticket at another hash on the same provider: what an
/// attacker who learned a hash out of band would hand to their daemon.
fn forge_ticket(real_ticket: &str, target_hash: &str) -> String {
let ticket = iroh_blobs::ticket::BlobTicket::from_str(real_ticket).unwrap();
let hash = iroh_blobs::Hash::from_str(target_hash).unwrap();
iroh_blobs::ticket::BlobTicket::new(
ticket.node_addr().clone(),
hash,
iroh_blobs::BlobFormat::Raw,
)
.unwrap()
.to_string()
}
#[test]
fn trust_gates_serving_per_hash() {
let provider = support::spawn_daemon();
let fetcher_dir = tempfile::tempdir().unwrap();
let fetcher = support::spawn_daemon_at(fetcher_dir.path());
let mut provider_client = support::Client::connect(&provider.socket);
let mut fetcher_client = support::Client::connect(&fetcher.socket);
let dir = tempfile::tempdir().unwrap();
// `shared` is deliberately published via ticket; `private` is pinned
// but never shared.
std::fs::write(dir.path().join("shared.bin"), vec![1u8; 300_000]).unwrap();
std::fs::write(dir.path().join("private.bin"), vec![2u8; 300_000]).unwrap();
let shared = add_file(&mut provider_client, &dir.path().join("shared.bin"));
let private = add_file(&mut provider_client, &dir.path().join("private.bin"));
pin(&mut provider_client, &shared, false);
pin(&mut provider_client, &private, false);
let ticket = export_ticket(&mut provider_client, &shared);
// The exported content transfers to the untrusted fetcher.
import_ticket(&mut fetcher_client, &ticket);
assert!(
wait_complete(&fetcher.socket, &shared, Duration::from_secs(60)),
"exported content must be fetchable without trust"
);
// A forged ticket for the never-shared hash must not: the provider
// answers "not found" to untrusted peers.
let forged = forge_ticket(&ticket, &private);
import_ticket(&mut fetcher_client, &forged);
std::thread::sleep(Duration::from_secs(5));
assert!(
!is_complete(&fetcher.socket, &private),
"unshared content leaked to an untrusted peer"
);
// Once the provider trusts the fetcher, the same request succeeds.
// Trust is evaluated when a connection is accepted, so restart the
// fetcher (same store, same identity) to shed the connection that
// was established while untrusted.
let fetcher_id = node_id(&mut fetcher_client);
let reply = provider_client.request(&Request::PeerTrust {
node_id: fetcher_id,
});
assert!(reply.ok, "peer trust failed: {:?}", reply.error);
drop(fetcher);
let fetcher = support::spawn_daemon_at(fetcher_dir.path());
let mut fetcher_client = support::Client::connect(&fetcher.socket);
import_ticket(&mut fetcher_client, &forged);
assert!(
wait_complete(&fetcher.socket, &private, Duration::from_secs(60)),
"trusted peer should be served everything"
);
}
#[test]
fn upload_rate_limit_is_enforced() {
let dir = tempfile::tempdir().unwrap();
// 256 KiB/s upload cap on the provider.
let provider = support::spawn_daemon_with_env(dir.path(), &[("VARDE_MAX_UPLOAD", "262144")]);
let fetcher = support::spawn_daemon();
let mut provider_client = support::Client::connect(&provider.socket);
let mut fetcher_client = support::Client::connect(&fetcher.socket);
let payload = vec![7u8; 1_000_000];
let src = dir.path().join("capped.bin");
std::fs::write(&src, &payload).unwrap();
let hash = add_file(&mut provider_client, &src);
pin(&mut provider_client, &hash, false);
let ticket = export_ticket(&mut provider_client, &hash);
let start = Instant::now();
import_ticket(&mut fetcher_client, &ticket);
assert!(
wait_complete(&fetcher.socket, &hash, Duration::from_secs(120)),
"capped fetch never completed"
);
let elapsed = start.elapsed();
// 1 MB at 256 KiB/s is ~4 s minus one 256 KiB burst: ~3 s. Anything
// under 1.5 s means the limiter did nothing (uncapped localhost
// moves this in well under 200 ms).
assert!(
elapsed >= Duration::from_millis(1500),
"1 MB fetch finished in {elapsed:?} despite 256 KiB/s upload cap"
);
}
#[test]
fn subscribe_streams_progress_and_completion() {
let provider = support::spawn_daemon();
let fetcher = support::spawn_daemon();
let mut provider_client = support::Client::connect(&provider.socket);
let mut fetcher_client = support::Client::connect(&fetcher.socket);
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("watched.bin");
std::fs::write(&src, vec![5u8; 2_000_000]).unwrap();
let hash = add_file(&mut provider_client, &src);
pin(&mut provider_client, &hash, false);
let ticket = export_ticket(&mut provider_client, &hash);
// Subscribe on the fetcher before starting the transfer.
let mut events = support::Client::connect(&fetcher.socket);
let ack = events.request(&Request::Subscribe {});
assert!(ack.ok);
events.set_read_timeout(Duration::from_secs(60));
import_ticket(&mut fetcher_client, &ticket);
let mut saw_progress = false;
let mut saw_complete = false;
while let Some(line) = events.read_event_line() {
let event: serde_json::Value = serde_json::from_str(&line).expect("event is JSON");
match event["event"].as_str() {
Some("transfer_progress") => {
assert_eq!(event["hash"].as_str(), Some(hash.as_str()));
assert_eq!(event["direction"].as_str(), Some("download"));
saw_progress = true;
}
Some("pin_complete") => {
assert_eq!(event["hash"].as_str(), Some(hash.as_str()));
saw_complete = true;
break;
}
_ => {}
}
}
assert!(saw_progress, "no transfer_progress event seen");
assert!(saw_complete, "no pin_complete event seen");
}
/// Two trusted daemons with LAN discovery on: a pin that exists on one
/// side auto-syncs to the other with no ticket exchange. Requires
/// working multicast; skips (with a note) where mdns can't see the
/// sibling process.
#[test]
fn overlapping_pins_auto_sync_via_lan_discovery() {
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
let a = support::spawn_daemon_with_env(dir_a.path(), &[("VARDE_DISCOVERY", "true")]);
let b = support::spawn_daemon_with_env(dir_b.path(), &[("VARDE_DISCOVERY", "true")]);
let mut client_a = support::Client::connect(&a.socket);
let mut client_b = support::Client::connect(&b.socket);
// Mutual trust: A serves B, B auto-fetches from A.
let id_a = node_id(&mut client_a);
let id_b = node_id(&mut client_b);
assert!(client_a.request(&Request::PeerTrust { node_id: id_b }).ok);
assert!(client_b.request(&Request::PeerTrust { node_id: id_a }).ok);
// Content pinned on A only.
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("synced.bin");
let payload = vec![9u8; 500_000];
std::fs::write(&src, &payload).unwrap();
let hash = add_file(&mut client_a, &src);
pin(&mut client_a, &hash, false);
// B pins the same hash without having the bytes.
pin(&mut client_b, &hash, false);
// Wait for B to see A via discovery at all.
let deadline = Instant::now() + Duration::from_secs(30);
let mut discovered = false;
while Instant::now() < deadline {
let reply = client_b.request(&Request::PeerList {});
if let Some(ResponseData::Peers { peers }) = reply.data {
if peers.iter().any(|p| p.connected) {
discovered = true;
break;
}
}
std::thread::sleep(Duration::from_millis(250));
}
if !discovered {
eprintln!("SKIP: mdns discovery saw no peers (multicast unavailable in this environment)");
return;
}
assert!(
wait_complete(&b.socket, &hash, Duration::from_secs(60)),
"pinned content did not auto-sync from trusted LAN peer"
);
let dest = dir.path().join("synced-out.bin");
let reply = client_b.request(&Request::Materialize {
hash,
dest: dest.display().to_string(),
mode: MaterializeMode::Copy,
});
assert!(reply.ok, "materialize failed: {:?}", reply.error);
assert_eq!(std::fs::read(&dest).unwrap(), payload);
}