#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
use std::sync::Mutex;
use decdn_common::config::ResolvedSecurity;
use decdn_node::dht::routing::RoutingTable;
use decdn_node::dht::{
DhtRateLimiter, RecordStore, RecordStoreConfig, StakerSet, rate_limit::DhtRateLimitConfig,
};
use decdn_node::dispatch::ConnectionLimiter;
use decdn_node::handlers::dht::DhtHandler;
use decdn_node::metrics::Metrics;
use decdn_protocol::{
ALPN_DHT, ContentHash, Coverage, NodeId, decode_message, dht as wire, encode_message,
read_frame, write_frame,
};
use iroh::protocol::ProtocolHandler;
use iroh::{Endpoint, EndpointAddr, RelayMode, SecretKey, endpoint::presets};
mod support;
use support::shutdown;
fn fresh_key() -> SecretKey {
SecretKey::generate()
}
fn permissive_limiter(metrics: &Arc<Metrics>) -> Arc<ConnectionLimiter> {
let cfg = ResolvedSecurity {
max_concurrent_handlers: u32::MAX,
per_source_rate_per_sec: 1_000_000.0,
per_source_burst: u32::MAX,
max_tracked_sources: 4096,
};
Arc::new(ConnectionLimiter::new(&cfg, Arc::clone(metrics)))
}
#[derive(Debug)]
struct AllStaked;
impl AllStaked {
const fn new() -> Self {
Self
}
}
impl StakerSet for AllStaked {
fn is_active(&self, _: &NodeId) -> bool {
true
}
fn active_nodes(&self) -> Vec<NodeId> {
Vec::new()
}
fn len(&self) -> usize {
0
}
}
fn empty_record_store() -> Arc<Mutex<RecordStore>> {
Arc::new(Mutex::new(RecordStore::new(RecordStoreConfig::default())))
}
fn permissive_dht_rate_limiter(metrics: &Arc<Metrics>) -> Arc<DhtRateLimiter> {
let cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1e6,
per_peer_burst: u32::MAX,
per_ip_rate_per_sec: 1e6,
per_ip_burst: u32::MAX,
global_rate_per_sec: 1e6,
global_burst: u32::MAX,
max_tracked_per_ip: 4096,
max_tracked_per_peer: 4096,
};
Arc::new(DhtRateLimiter::new(&cfg, Arc::clone(metrics)))
}
async fn local_endpoint(
secret_key: SecretKey,
alpns: Vec<Vec<u8>>,
) -> anyhow::Result<(Endpoint, SocketAddr)> {
let bind = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0);
let ep = Endpoint::builder(presets::Minimal)
.secret_key(secret_key)
.alpns(alpns)
.relay_mode(RelayMode::Disabled)
.bind_addr(bind)
.map_err(|e| anyhow::anyhow!("bind_addr: {e}"))?
.bind()
.await
.map_err(|e| anyhow::anyhow!("bind: {e}"))?;
let addr = ep
.bound_sockets()
.into_iter()
.find(SocketAddr::is_ipv4)
.ok_or_else(|| anyhow::anyhow!("no IPv4 bound socket"))?;
let addr = match addr {
SocketAddr::V4(v4) if v4.ip().is_unspecified() => {
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, v4.port()))
}
other => other,
};
Ok((ep, addr))
}
#[tokio::test(flavor = "multi_thread")]
async fn find_node_returns_closer_peers_from_routing_table() -> anyhow::Result<()> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let seeded_peers: Vec<[u8; 32]> = (1u8..=3).map(|i| [i; 32]).collect();
{
let mut t = routing.lock().expect("routing lock poisoned");
for p in &seeded_peers {
t.insert(NodeId::from_bytes(*p));
}
}
let handler = Arc::new(DhtHandler::with_routing(
server_id,
Arc::clone(&routing),
rate_limiter,
limiter,
Arc::clone(&metrics),
Arc::new(AllStaked::new()),
empty_record_store(),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let client_id = client_ep.id();
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::FindNodeRequest {
target: NodeId::from_bytes([1u8; 32]),
requester: NodeId::from_bytes(*client_id.as_bytes()),
};
let payload = encode_message(&wire::DhtMessage::FindNode(req))?;
write_frame(&mut send, &payload)
.await
.map_err(|e| anyhow::anyhow!("write: {e}"))?;
send.finish().map_err(|e| anyhow::anyhow!("finish: {e}"))?;
let frame = read_frame(&mut recv)
.await
.map_err(|e| anyhow::anyhow!("read: {e}"))?;
let (msg, _rest) = decode_message::<wire::DhtMessage>(&frame)?;
let resp = match msg {
wire::DhtMessage::FindNodeResponse(r) => r,
other => anyhow::bail!("expected FindNodeResponse, got {other:?}"),
};
assert_eq!(resp.target, req.target, "target echoed");
assert!(
!resp.closer_nodes.is_empty(),
"responder seeded with 3 peers must return at least one"
);
assert!(
resp.closer_nodes
.as_slice()
.contains(&NodeId::from_bytes([1u8; 32])),
"the exact target NodeId is one of the seeded peers and should appear"
);
let server_node_id = NodeId::from_bytes(*server_id.as_bytes());
assert!(
!resp.closer_nodes.as_slice().contains(&server_node_id),
"responder must not list itself"
);
{
let t = routing.lock().expect("routing lock poisoned");
assert!(
t.contains(&NodeId::from_bytes(*client_id.as_bytes())),
"client's NodeId must be in the responder's routing table after a successful FindNode"
);
}
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
support::reap("accept", accept_task).await??;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn find_value_with_empty_store_returns_no_providers_but_closer_nodes() -> anyhow::Result<()> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
{
let mut t = routing.lock().expect("routing lock poisoned");
t.insert(NodeId::from_bytes([0x42u8; 32]));
}
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
Arc::clone(&metrics),
Arc::new(AllStaked::new()),
empty_record_store(),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::FindValueRequest {
hash: ContentHash::from_bytes([0x99u8; 32]),
requester: NodeId::from_bytes(*client_ep.id().as_bytes()),
};
let payload = encode_message(&wire::DhtMessage::FindValue(req))?;
write_frame(&mut send, &payload).await?;
send.finish()?;
let frame = read_frame(&mut recv).await?;
let (msg, _) = decode_message::<wire::DhtMessage>(&frame)?;
let resp = match msg {
wire::DhtMessage::FindValueResponse(r) => r,
other => anyhow::bail!("expected FindValueResponse, got {other:?}"),
};
assert_eq!(resp.hash, req.hash);
assert!(
resp.providers.is_empty(),
"FindValue for a hash nobody stored returns no providers"
);
assert!(
resp.closer_nodes
.iter()
.any(|n| n == &NodeId::from_bytes([0x42u8; 32])),
"seeded peer must appear in closer_nodes"
);
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
support::reap("accept", accept_task).await??;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn find_node_does_not_insert_attacker_supplied_requester() -> anyhow::Result<()> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let handler = Arc::new(DhtHandler::with_routing(
server_id,
Arc::clone(&routing),
rate_limiter,
limiter,
Arc::clone(&metrics),
Arc::new(AllStaked::new()),
empty_record_store(),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let client_id = client_ep.id();
let attacker_id: [u8; 32] = [0xEE; 32];
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::FindNodeRequest {
target: NodeId::from_bytes([0x12; 32]),
requester: NodeId::from_bytes(attacker_id),
};
let payload = encode_message(&wire::DhtMessage::FindNode(req))?;
write_frame(&mut send, &payload).await?;
send.finish()?;
let frame = read_frame(&mut recv).await?;
let (_msg, _) = decode_message::<wire::DhtMessage>(&frame)?;
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
support::reap("accept", accept_task).await??;
shutdown([], [&server_ep]).await?;
let t = routing.lock().expect("routing lock poisoned");
assert!(
t.contains(&NodeId::from_bytes(*client_id.as_bytes())),
"authenticated client must be inserted by the connection-level refresh"
);
assert!(
!t.contains(&NodeId::from_bytes(attacker_id)),
"attacker-supplied req.requester must NOT be inserted (routing-table poisoning guard)"
);
Ok(())
}
mod adr_013_error_codes {
use super::*;
use decdn_protocol::APP_ERR_RATE_LIMITED;
use iroh::endpoint::{ConnectionError, ReadError, ReadToEndError, VarInt};
const APP_ERR_UNSUPPORTED_MESSAGE: u32 = 0x01;
const APP_ERR_MALFORMED_MESSAGE: u32 = 0x03;
async fn spin_up_dht_server() -> anyhow::Result<(
Endpoint,
std::net::SocketAddr,
iroh::PublicKey,
tokio::task::JoinHandle<anyhow::Result<()>>,
)> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
metrics,
Arc::new(AllStaked::new()),
empty_record_store(),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
Ok((server_ep, server_addr, server_id, accept_task))
}
fn assert_close_code(result: Result<Vec<u8>, ReadToEndError>, expected_code: u32) {
let expected = VarInt::from_u32(expected_code);
match result {
Err(ReadToEndError::Read(ReadError::Reset(code))) if code == expected => {}
Err(ReadToEndError::Read(ReadError::ConnectionLost(
ConnectionError::ApplicationClosed(close),
))) if close.error_code == expected => {}
other => panic!(
"expected close code {expected_code:#x} (RESET or ApplicationClosed), got {other:?}"
),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn malformed_frame_returns_malformed_message_code() -> anyhow::Result<()> {
let (server_ep, server_addr, server_id, accept_task) = spin_up_dht_server().await?;
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let truncated = [0x00u8, 0x01, 0x02];
write_frame(&mut send, &truncated).await?;
send.finish()?;
let r = recv.read_to_end(64).await;
assert_close_code(r, APP_ERR_MALFORMED_MESSAGE);
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
let _ = support::reap("accept", accept_task).await;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn unknown_discriminant_returns_unsupported_message_code() -> anyhow::Result<()> {
let (server_ep, server_addr, server_id, accept_task) = spin_up_dht_server().await?;
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let unknown = [0x55u8, 0x00, 0x00, 0x00];
write_frame(&mut send, &unknown).await?;
send.finish()?;
let r = recv.read_to_end(64).await;
assert_close_code(r, APP_ERR_UNSUPPORTED_MESSAGE);
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
let _ = support::reap("accept", accept_task).await;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn response_on_server_stream_returns_unsupported_code() -> anyhow::Result<()> {
let (server_ep, server_addr, server_id, accept_task) = spin_up_dht_server().await?;
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let resp = wire::DhtMessage::FindNodeResponse(wire::FindNodeResponse {
target: NodeId::from_bytes([0u8; 32]),
closer_nodes: wire::CloserNodes::default(),
});
let payload = encode_message(&resp)?;
write_frame(&mut send, &payload).await?;
send.finish()?;
let r = recv.read_to_end(64).await;
assert_close_code(r, APP_ERR_UNSUPPORTED_MESSAGE);
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
let _ = support::reap("accept", accept_task).await;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn batch_store_holder_mismatch_returns_malformed_code() -> anyhow::Result<()> {
let (server_ep, server_addr, server_id, accept_task) = spin_up_dht_server().await?;
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::DhtMessage::BatchStore(wire::BatchStoreRequest {
entries: vec![
(ContentHash::from_bytes([0x01u8; 32]), Coverage::full(1)),
(ContentHash::from_bytes([0x02u8; 32]), Coverage::full(1)),
(ContentHash::from_bytes([0x03u8; 32]), Coverage::full(1)),
],
holder: NodeId::from_bytes([0xAB; 32]),
});
let payload = encode_message(&req)?;
write_frame(&mut send, &payload).await?;
send.finish()?;
let r = recv.read_to_end(64).await;
assert_close_code(r, APP_ERR_MALFORMED_MESSAGE);
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
let _ = support::reap("accept", accept_task).await;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn batch_store_over_cap_returns_malformed_code() -> anyhow::Result<()> {
let (server_ep, server_addr, server_id, accept_task) = spin_up_dht_server().await?;
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::DhtMessage::BatchStore(wire::BatchStoreRequest {
entries: vec![
(ContentHash::from_bytes([0x07u8; 32]), Coverage::full(1));
decdn_protocol::dht::MAX_BATCH_STORE_HASHES + 1
],
holder: NodeId::from_bytes(*client_ep.id().as_bytes()),
});
let payload = encode_message(&req)?;
write_frame(&mut send, &payload).await?;
send.finish()?;
let r = recv.read_to_end(64).await;
assert_close_code(r, APP_ERR_MALFORMED_MESSAGE);
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
let _ = support::reap("accept", accept_task).await;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn rate_limit_rejection_returns_rate_limited_code() -> anyhow::Result<()> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1e6,
per_peer_burst: u32::MAX,
per_ip_rate_per_sec: 1e6,
per_ip_burst: u32::MAX,
global_rate_per_sec: 1.0,
global_burst: 1,
max_tracked_per_ip: 4096,
max_tracked_per_peer: 4096,
};
let rate_limiter = Arc::new(DhtRateLimiter::new(&rate_cfg, Arc::clone(&metrics)));
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
metrics,
Arc::new(AllStaked::new()),
empty_record_store(),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut s1, mut r1) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi #1: {e}"))?;
let req = wire::DhtMessage::FindNode(wire::FindNodeRequest {
target: NodeId::from_bytes([0u8; 32]),
requester: NodeId::from_bytes(*client_ep.id().as_bytes()),
});
let payload = encode_message(&req)?;
write_frame(&mut s1, &payload).await?;
s1.finish()?;
let frame = read_frame(&mut r1).await?;
let _ = decode_message::<wire::DhtMessage>(&frame)?;
let (mut s2, mut r2) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi #2: {e}"))?;
write_frame(&mut s2, &payload).await?;
s2.finish()?;
let rr = r2.read_to_end(2048).await;
assert_close_code(rr, APP_ERR_RATE_LIMITED);
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
let _ = support::reap("accept", accept_task).await;
shutdown([], [&server_ep]).await?;
Ok(())
}
}
mod store_admission {
use super::*;
#[tokio::test(flavor = "multi_thread")]
#[allow(clippy::too_many_lines)] async fn store_then_find_value_roundtrip_for_staked_publisher() -> anyhow::Result<()> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let records = empty_record_store();
let client_sk = fresh_key();
let client_id = client_sk.public();
let mut active = std::collections::HashSet::new();
active.insert(NodeId::from_bytes(*client_id.as_bytes()));
let staker_set: Arc<dyn StakerSet> =
Arc::new(decdn_node::dht::staker_set::ConfigStakerSet::new(active));
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
Arc::clone(&metrics),
staker_set,
Arc::clone(&records),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let (client_ep, _) = local_endpoint(client_sk, vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let target_hash = ContentHash::from_bytes([0xAAu8; 32]);
let partial_coverage = Coverage::from_block_indices(3, [0].into_iter());
{
let (mut s, mut r) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi store: {e}"))?;
let req = wire::DhtMessage::Store(wire::StoreRequest {
hash: target_hash,
holder: NodeId::from_bytes(*client_id.as_bytes()),
coverage: partial_coverage.clone(),
});
let payload = encode_message(&req)?;
write_frame(&mut s, &payload).await?;
s.finish()?;
let frame = read_frame(&mut r).await?;
let (msg, _) = decode_message::<wire::DhtMessage>(&frame)?;
let ack = match msg {
wire::DhtMessage::StoreAck(a) => a,
other => anyhow::bail!("expected StoreAck, got {other:?}"),
};
assert!(ack.accepted, "staked publisher's Store must be accepted");
assert_eq!(ack.hash, target_hash);
}
{
let (mut s, mut r) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi find: {e}"))?;
let req = wire::DhtMessage::FindValue(wire::FindValueRequest {
hash: target_hash,
requester: NodeId::from_bytes(*client_id.as_bytes()),
});
let payload = encode_message(&req)?;
write_frame(&mut s, &payload).await?;
s.finish()?;
let frame = read_frame(&mut r).await?;
let (msg, _) = decode_message::<wire::DhtMessage>(&frame)?;
let resp = match msg {
wire::DhtMessage::FindValueResponse(r) => r,
other => anyhow::bail!("expected FindValueResponse, got {other:?}"),
};
assert_eq!(resp.hash, target_hash);
assert_eq!(
resp.providers,
vec![wire::Provider {
node: NodeId::from_bytes(*client_id.as_bytes()),
coverage: partial_coverage.clone(),
}],
"stored holder and its partial coverage must survive the RecordStore \
round-trip and come back in providers"
);
let got_coverage = &resp.providers[0].coverage;
assert!(got_coverage.covers(0), "block 0 must be covered");
assert!(!got_coverage.covers(1), "block 1 must NOT be covered");
assert!(!got_coverage.covers(2), "block 2 must NOT be covered");
}
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
support::reap("accept", accept_task).await??;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn store_from_non_staked_publisher_is_rejected() -> anyhow::Result<()> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let staker_set: Arc<dyn StakerSet> =
Arc::new(decdn_node::dht::staker_set::ConfigStakerSet::empty());
let records = empty_record_store();
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
Arc::clone(&metrics),
staker_set,
Arc::clone(&records),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut s, mut r) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::DhtMessage::Store(wire::StoreRequest {
hash: ContentHash::from_bytes([0xCCu8; 32]),
holder: NodeId::from_bytes(*client_ep.id().as_bytes()),
coverage: Coverage::full(1),
});
let payload = encode_message(&req)?;
write_frame(&mut s, &payload).await?;
s.finish()?;
let frame = read_frame(&mut r).await?;
let (msg, _) = decode_message::<wire::DhtMessage>(&frame)?;
let ack = match msg {
wire::DhtMessage::StoreAck(a) => a,
other => anyhow::bail!("expected StoreAck, got {other:?}"),
};
assert!(
!ack.accepted,
"Store from non-staked publisher must NOT be accepted"
);
assert!(records.lock().expect("records lock").is_empty());
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
support::reap("accept", accept_task).await??;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn store_with_holder_not_authenticated_is_rejected() -> anyhow::Result<()> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let client_sk = fresh_key();
let client_id = client_sk.public();
let fake_holder: [u8; 32] = [0xBB; 32];
let mut active = std::collections::HashSet::new();
active.insert(NodeId::from_bytes(*client_id.as_bytes()));
active.insert(NodeId::from_bytes(fake_holder));
let staker_set: Arc<dyn StakerSet> =
Arc::new(decdn_node::dht::staker_set::ConfigStakerSet::new(active));
let records = empty_record_store();
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
Arc::clone(&metrics),
staker_set,
Arc::clone(&records),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let (client_ep, _) = local_endpoint(client_sk, vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut s, mut r) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::DhtMessage::Store(wire::StoreRequest {
hash: ContentHash::from_bytes([0xDDu8; 32]),
holder: NodeId::from_bytes(fake_holder), coverage: Coverage::full(1),
});
let payload = encode_message(&req)?;
write_frame(&mut s, &payload).await?;
s.finish()?;
let frame = read_frame(&mut r).await?;
let (msg, _) = decode_message::<wire::DhtMessage>(&frame)?;
let ack = match msg {
wire::DhtMessage::StoreAck(a) => a,
other => anyhow::bail!("expected StoreAck, got {other:?}"),
};
assert!(
!ack.accepted,
"Store with holder != authenticated NodeId must be rejected"
);
assert!(records.lock().expect("records lock").is_empty());
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
support::reap("accept", accept_task).await??;
shutdown([], [&server_ep]).await?;
Ok(())
}
}
mod batch_store_admission {
use super::*;
#[allow(clippy::too_many_lines)]
async fn run_batch_store(
client_sk: SecretKey,
hashes: Vec<[u8; 32]>,
rate_cfg: DhtRateLimitConfig,
) -> anyhow::Result<(wire::BatchStoreAck, Arc<Mutex<RecordStore>>, Arc<Metrics>)> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = Arc::new(DhtRateLimiter::new(&rate_cfg, Arc::clone(&metrics)));
let metrics_handle = Arc::clone(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let records = empty_record_store();
let client_id = client_sk.public();
let mut active = std::collections::HashSet::new();
active.insert(NodeId::from_bytes(*client_id.as_bytes()));
let staker_set: Arc<dyn StakerSet> =
Arc::new(decdn_node::dht::staker_set::ConfigStakerSet::new(active));
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
Arc::clone(&metrics),
staker_set,
Arc::clone(&records),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let (client_ep, _) = local_endpoint(client_sk, vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut s, mut r) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::DhtMessage::BatchStore(wire::BatchStoreRequest {
entries: hashes
.into_iter()
.map(|h| (ContentHash::from_bytes(h), Coverage::full(1)))
.collect(),
holder: NodeId::from_bytes(*client_id.as_bytes()),
});
let payload = encode_message(&req)?;
write_frame(&mut s, &payload).await?;
s.finish()?;
let frame = read_frame(&mut r).await?;
let (msg, _) = decode_message::<wire::DhtMessage>(&frame)?;
let ack = match msg {
wire::DhtMessage::BatchStoreAck(a) => a,
other => anyhow::bail!("expected BatchStoreAck, got {other:?}"),
};
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
support::reap("accept", accept_task).await??;
shutdown([], [&server_ep]).await?;
Ok((ack, records, metrics_handle))
}
const fn permissive_rate_cfg() -> DhtRateLimitConfig {
DhtRateLimitConfig {
per_peer_rate_per_sec: 1e6,
per_peer_burst: u32::MAX,
per_ip_rate_per_sec: 1e6,
per_ip_burst: u32::MAX,
global_rate_per_sec: 1e6,
global_burst: u32::MAX,
max_tracked_per_ip: 4096,
max_tracked_per_peer: 4096,
}
}
#[tokio::test(flavor = "multi_thread")]
async fn batch_store_all_admitted_for_staked_publisher() -> anyhow::Result<()> {
let client_sk = fresh_key();
let holder = NodeId::from_bytes(*client_sk.public().as_bytes());
let hashes: Vec<[u8; 32]> = (1u8..=5).map(|i| [i; 32]).collect();
let (ack, records, _metrics) =
run_batch_store(client_sk, hashes.clone(), permissive_rate_cfg()).await?;
assert_eq!(
ack.results,
vec![true; 5],
"every hash from a staked publisher under permissive limits must be admitted, in order"
);
let mut store = records.lock().expect("records lock");
for h in &hashes {
assert!(
store
.providers_at(&ContentHash::from_bytes(*h), 0)
.iter()
.any(|p| p.node == holder),
"hash {h:?} must be in the record store after a batch admit"
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn batch_store_single_hash_admitted() -> anyhow::Result<()> {
let client_sk = fresh_key();
let holder = NodeId::from_bytes(*client_sk.public().as_bytes());
let (ack, records, _metrics) =
run_batch_store(client_sk, vec![[0x42u8; 32]], permissive_rate_cfg()).await?;
assert_eq!(ack.results, vec![true]);
assert!(
records
.lock()
.expect("records lock")
.providers_at(&ContentHash::from_bytes([0x42u8; 32]), 0)
.iter()
.any(|p| p.node == holder)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn batch_store_holder_mismatch_does_not_bump_requests_failed() -> anyhow::Result<()> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
Arc::clone(&metrics),
Arc::new(AllStaked::new()),
empty_record_store(),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let (client_ep, _) = local_endpoint(fresh_key(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut s, mut r) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::DhtMessage::BatchStore(wire::BatchStoreRequest {
entries: vec![
(ContentHash::from_bytes([0x01u8; 32]), Coverage::full(1)),
(ContentHash::from_bytes([0x02u8; 32]), Coverage::full(1)),
],
holder: NodeId::from_bytes([0xAB; 32]), });
let payload = encode_message(&req)?;
write_frame(&mut s, &payload).await?;
s.finish()?;
let _ = r.read_to_end(64).await;
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
support::reap("accept", accept_task).await??;
shutdown([], [&server_ep]).await?;
let text = metrics.encode().unwrap();
assert!(
text.contains("decdn_dht_store_rejected_holder_mismatch_total 1"),
"holder mismatch must bump its specific counter:\n{text}"
);
assert!(
text.contains("decdn_dht_requests_failed_total 0"),
"a deliberate protocol rejection must NOT inflate dht_requests_failed:\n{text}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn batch_store_non_staked_publisher_all_rejected() -> anyhow::Result<()> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let records = empty_record_store();
let staker_set: Arc<dyn StakerSet> =
Arc::new(decdn_node::dht::staker_set::ConfigStakerSet::empty());
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
Arc::clone(&metrics),
staker_set,
Arc::clone(&records),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
if let Some(incoming) = server_ep_bg.accept().await {
let connecting = incoming
.accept()
.map_err(|e| anyhow::anyhow!("accept: {e}"))?;
let conn = connecting
.await
.map_err(|e| anyhow::anyhow!("handshake: {e}"))?;
handler
.accept(conn)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok::<_, anyhow::Error>(())
});
let client_sk = fresh_key();
let (client_ep, _) = local_endpoint(client_sk.clone(), vec![]).await?;
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
let conn = client_ep
.connect(target, ALPN_DHT)
.await
.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let (mut s, mut r) = conn
.open_bi()
.await
.map_err(|e| anyhow::anyhow!("open_bi: {e}"))?;
let req = wire::DhtMessage::BatchStore(wire::BatchStoreRequest {
entries: vec![
(ContentHash::from_bytes([0x01u8; 32]), Coverage::full(1)),
(ContentHash::from_bytes([0x02u8; 32]), Coverage::full(1)),
],
holder: NodeId::from_bytes(*client_sk.public().as_bytes()),
});
let payload = encode_message(&req)?;
write_frame(&mut s, &payload).await?;
s.finish()?;
let frame = read_frame(&mut r).await?;
let (msg, _) = decode_message::<wire::DhtMessage>(&frame)?;
let ack = match msg {
wire::DhtMessage::BatchStoreAck(a) => a,
other => anyhow::bail!("expected BatchStoreAck, got {other:?}"),
};
assert_eq!(
ack.results,
vec![false, false],
"a non-staked publisher's batch must be fully rejected per-hash"
);
assert!(records.lock().expect("records lock").is_empty());
conn.close(0u32.into(), b"bye");
shutdown([], [&client_ep]).await?;
support::reap("accept", accept_task).await??;
shutdown([], [&server_ep]).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn batch_store_partial_admit_when_over_rate_budget() -> anyhow::Result<()> {
let client_sk = fresh_key();
let holder = NodeId::from_bytes(*client_sk.public().as_bytes());
let hashes: Vec<[u8; 32]> = (1u8..=6).map(|i| [i; 32]).collect();
let rate_cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1.0,
per_peer_burst: 4,
per_ip_rate_per_sec: 1e9,
per_ip_burst: u32::MAX,
global_rate_per_sec: 1e9,
global_burst: u32::MAX,
max_tracked_per_ip: 4096,
max_tracked_per_peer: 4096,
};
let (ack, records, metrics) = run_batch_store(client_sk, hashes.clone(), rate_cfg).await?;
assert_eq!(
ack.results,
vec![true, true, true, true, false, false],
"first k=4 hashes admitted (1 stage-1 + 3 stage-2), tail deferred"
);
let text = metrics.encode().unwrap();
assert!(
text.contains("decdn_dht_batch_store_hashes_deferred_rate_limit_total 2"),
"2 deferred hashes must be reflected in the deferred counter:\n{text}"
);
let mut store = records.lock().expect("records lock");
for h in hashes.iter().take(4) {
assert!(
store
.providers_at(&ContentHash::from_bytes(*h), 0)
.iter()
.any(|p| p.node == holder)
);
}
for h in hashes.iter().skip(4) {
assert!(
!store
.providers_at(&ContentHash::from_bytes(*h), 0)
.iter()
.any(|p| p.node == holder),
"deferred hash {h:?} must NOT be inserted"
);
}
Ok(())
}
}
mod batch_store_client {
use super::*;
use decdn_node::dht::client;
#[allow(clippy::type_complexity)]
async fn spin_up_looping_staked_server(
client_id: [u8; 32],
) -> anyhow::Result<(
Endpoint,
EndpointAddr,
iroh::PublicKey,
Arc<Metrics>,
tokio::task::JoinHandle<()>,
)> {
let server_sk = fresh_key();
let server_id = server_sk.public();
let metrics = Arc::new(Metrics::new());
let limiter = permissive_limiter(&metrics);
let rate_limiter = permissive_dht_rate_limiter(&metrics);
let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
*server_id.as_bytes(),
))));
let mut active = std::collections::HashSet::new();
active.insert(NodeId::from_bytes(client_id));
let staker_set: Arc<dyn StakerSet> =
Arc::new(decdn_node::dht::staker_set::ConfigStakerSet::new(active));
let handler = Arc::new(DhtHandler::with_routing(
server_id,
routing,
rate_limiter,
limiter,
Arc::clone(&metrics),
staker_set,
empty_record_store(),
));
let (server_ep, server_addr) = local_endpoint(server_sk, vec![ALPN_DHT.to_vec()]).await?;
let server_ep_bg = server_ep.clone();
let accept_task = tokio::spawn(async move {
while let Some(incoming) = server_ep_bg.accept().await {
let handler = Arc::clone(&handler);
tokio::spawn(async move {
let Ok(connecting) = incoming.accept() else {
return;
};
let Ok(conn) = connecting.await else {
return;
};
let _ = handler.accept(conn).await;
});
}
});
let target = EndpointAddr::new(server_id).with_ip_addr(server_addr);
Ok((server_ep, target, server_id, metrics, accept_task))
}
#[tokio::test(flavor = "multi_thread")]
async fn batch_store_primitive_roundtrip() -> anyhow::Result<()> {
let client_sk = fresh_key();
let holder = *client_sk.public().as_bytes();
let (server_ep, target, _sid, _metrics, accept_task) =
spin_up_looping_staked_server(holder).await?;
let (client_ep, _) = local_endpoint(client_sk, vec![]).await?;
let hashes: Vec<[u8; 32]> = (1u8..=4).map(|i| [i; 32]).collect();
let ack = client::batch_store(
&client_ep,
target,
hashes
.iter()
.copied()
.map(|h| (ContentHash::from_bytes(h), Coverage::full(1)))
.collect(),
NodeId::from_bytes(holder),
)
.await?;
assert_eq!(ack.results, vec![true; 4]);
shutdown([accept_task], [&client_ep, &server_ep]).await?;
Ok(())
}
}