Protocol v2: trusted-peer push, truthful partials, split downloads
Three capabilities the iroh-blobs 0.103 line makes possible:
- Push (new API surface, protocol v2): `Push { hash, node_id }` hands
fully-present content to a trusted peer, unprompted. Consent is
mutual — the sender pushes only to peers it trusts, and the receiver
(which now accepts connections unconditionally and gates per request)
admits pushes only from peers *it* trusts, deferring with RateLimited
while metered. An accepted push is pinned by the receiver (default
policy, format inferred from the request ranges) once its transfer
completes, and surfaces as a PushReceived event. Because QUIC writes
are fire-and-forget, the sender confirms delivery by observing the
receiver's bitfields (root + last hashseq child) before replying —
Pushed { bytes } means verified received, not merely sent. New
varde-ctl `push` command.
- Truthful partial presence: Status/List report bytes actually present
and verified for partial blobs, from the store's bitfields via
observe, instead of the old "0 until complete".
- Split downloads: multi-provider fetches stripe one request across
providers (SplitStrategy::Split) instead of trying them serially.
Trusted peers may observe bitfields even when serving is disabled or
metered — bitfields are metadata, and push confirmation rides on them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -280,3 +280,137 @@ fn overlapping_pins_auto_sync_via_lan_discovery() {
|
||||
assert!(reply.ok, "materialize failed: {:?}", reply.error);
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), payload);
|
||||
}
|
||||
|
||||
/// Wait until `client` sees at least one connected trusted peer; false
|
||||
/// if the deadline passes (multicast unavailable).
|
||||
fn wait_peer_connected(client: &mut support::Client, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
let reply = client.request(&Request::PeerList {});
|
||||
if let Some(ResponseData::Peers { peers }) = reply.data {
|
||||
if peers.iter().any(|p| p.connected) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Push: A hands pinned content to B unprompted. Requires mutual trust
|
||||
/// and mdns (skips without multicast). The receiver pins what it
|
||||
/// accepted, so the content survives gc and lists as a pin.
|
||||
#[test]
|
||||
fn push_hands_content_to_trusted_peer() {
|
||||
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);
|
||||
|
||||
let id_a = node_id(&mut client_a);
|
||||
let id_b = node_id(&mut client_b);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src = dir.path().join("gift.bin");
|
||||
let payload = vec![3u8; 300_000];
|
||||
std::fs::write(&src, &payload).unwrap();
|
||||
let hash = add_file(&mut client_a, &src);
|
||||
pin(&mut client_a, &hash, false);
|
||||
|
||||
// Sender-side consent: pushing to a peer we don't trust is refused
|
||||
// locally, before any connection is made.
|
||||
let reply = client_a.request(&Request::Push {
|
||||
hash: hash.clone(),
|
||||
node_id: id_b.clone(),
|
||||
});
|
||||
assert!(!reply.ok, "push to untrusted peer must be refused");
|
||||
|
||||
// Mutual trust, established before any blob connection exists.
|
||||
assert!(client_a.request(&Request::PeerTrust { node_id: id_b }).ok);
|
||||
assert!(client_b.request(&Request::PeerTrust { node_id: id_a }).ok);
|
||||
|
||||
if !wait_peer_connected(&mut client_a, Duration::from_secs(30)) {
|
||||
eprintln!("SKIP: mdns discovery saw no peers (multicast unavailable in this environment)");
|
||||
return;
|
||||
}
|
||||
|
||||
let reply = client_a.request(&Request::Push {
|
||||
hash: hash.clone(),
|
||||
node_id: node_id(&mut client_b),
|
||||
});
|
||||
assert!(reply.ok, "push failed: {:?}", reply.error);
|
||||
match reply.data {
|
||||
Some(ResponseData::Pushed { bytes, .. }) => {
|
||||
assert_eq!(bytes, payload.len() as u64, "push moved the payload")
|
||||
}
|
||||
other => panic!("unexpected push reply: {other:?}"),
|
||||
}
|
||||
|
||||
// The receiver has the bytes, and pinned them.
|
||||
if !wait_complete(&b.socket, &hash, Duration::from_secs(30)) {
|
||||
let reply = client_b.request(&Request::Status {
|
||||
hash: Some(hash.clone()),
|
||||
});
|
||||
panic!("pushed content not complete on receiver; status: {reply:?}");
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let reply = client_b.request(&Request::List {});
|
||||
if let Some(ResponseData::Pins { pins }) = &reply.data {
|
||||
if pins.iter().any(|p| p.hash == hash && p.complete) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"receiver did not pin pushed content"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
let dest = dir.path().join("gift-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);
|
||||
}
|
||||
|
||||
/// Consent is mutual: a receiver that does not trust the sender rejects
|
||||
/// the push, even though the sender trusts the receiver.
|
||||
#[test]
|
||||
fn push_rejected_without_receiver_trust() {
|
||||
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);
|
||||
|
||||
let id_b = node_id(&mut client_b);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src = dir.path().join("unwanted.bin");
|
||||
std::fs::write(&src, vec![5u8; 100_000]).unwrap();
|
||||
let hash = add_file(&mut client_a, &src);
|
||||
pin(&mut client_a, &hash, false);
|
||||
|
||||
// One-way trust only: A trusts B, B has never heard of A.
|
||||
assert!(client_a.request(&Request::PeerTrust { node_id: id_b }).ok);
|
||||
if !wait_peer_connected(&mut client_a, Duration::from_secs(30)) {
|
||||
eprintln!("SKIP: mdns discovery saw no peers (multicast unavailable in this environment)");
|
||||
return;
|
||||
}
|
||||
|
||||
let reply = client_a.request(&Request::Push {
|
||||
hash: hash.clone(),
|
||||
node_id: node_id(&mut client_b),
|
||||
});
|
||||
assert!(!reply.ok, "receiver without trust must reject the push");
|
||||
|
||||
// And the receiver holds none of it.
|
||||
assert!(!is_complete(&b.socket, &hash));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user