//! 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); }