use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use bytes::Bytes;
use iroh::Endpoint;
use iroh_blobs::provider::events::{
AbortReason, ConnectMode, EventMask, EventSender, ObserveMode, ProviderMessage, RequestMode,
ThrottleMode,
};
use iroh_blobs::store::fs::FsStore;
use iroh_blobs::ticket::BlobTicket;
use iroh_blobs::{BlobFormat, BlobsProtocol, Hash};
use mcpmesh_net::TrustGate;
use crate::audit::{AuditRecord, AuditSink, now_ts};
use crate::blobs::APP_BLOB_ALPN;
use crate::blobs::scope::ScopeStore;
use crate::daemon::RELAY_READY_TIMEOUT;
const APP_BLOB_EVENT_MASK: EventMask = EventMask {
connected: ConnectMode::Intercept,
get: RequestMode::InterceptLog,
get_many: RequestMode::Disabled,
push: RequestMode::Disabled,
observe: ObserveMode::Intercept,
throttle: ThrottleMode::None,
};
const APP_BLOB_EVENT_MASK_METERED: EventMask = EventMask {
throttle: ThrottleMode::Intercept,
..APP_BLOB_EVENT_MASK
};
fn apply_transfer_update(
st: &mut Option<TransferProgressState>,
update: &iroh_blobs::provider::events::RequestUpdate,
bcast: &tokio::sync::broadcast::Sender<crate::daemon::BlobTransfer>,
peer: &Option<String>,
) -> bool {
use iroh_blobs::provider::events::RequestUpdate;
use mcpmesh_local_api::BlobTransferState as S;
match update {
RequestUpdate::Started(started) => {
let cur = TransferProgressState {
hash: started.hash.to_hex().to_string(),
peer: peer.clone(),
total: Some(started.size),
done: 0,
last_emitted: 0,
epochs: 0,
in_epoch: 0,
};
emit_transfer(bcast, &cur, S::Started);
*st = Some(cur);
false
}
RequestUpdate::Progress(p) => {
if let Some(cur) = st.as_mut() {
cur.done = p.end_offset;
if cur.done.saturating_sub(cur.last_emitted) >= cur.stride() {
cur.note_emitted();
emit_transfer(bcast, cur, S::Progress);
}
}
false
}
RequestUpdate::Completed(_) => {
if let Some(cur) = st.as_mut() {
if let Some(total) = cur.total {
cur.done = cur.done.max(total);
}
emit_transfer(bcast, cur, S::Completed);
}
true
}
RequestUpdate::Aborted(_) => {
if let Some(cur) = st.as_ref() {
emit_transfer(bcast, cur, S::Aborted);
}
true
}
}
}
const PROGRESS_STRIDE_BYTES: u64 = 1024 * 1024;
struct TransferProgressState {
hash: String,
peer: Option<String>,
total: Option<u64>,
done: u64,
last_emitted: u64,
epochs: u32,
in_epoch: u32,
}
const FRAMES_PER_EPOCH: u32 = 16;
impl TransferProgressState {
fn stride(&self) -> u64 {
match self.total {
Some(t) => (t / 100).max(PROGRESS_STRIDE_BYTES),
None => PROGRESS_STRIDE_BYTES
.saturating_mul(1u64 << self.epochs.min(40))
.max(PROGRESS_STRIDE_BYTES),
}
}
fn note_emitted(&mut self) {
self.last_emitted = self.done;
if self.total.is_none() {
self.in_epoch += 1;
if self.in_epoch >= FRAMES_PER_EPOCH {
self.in_epoch = 0;
self.epochs = self.epochs.saturating_add(1);
}
}
}
}
fn emit_fetch(
bcast: &tokio::sync::broadcast::Sender<crate::daemon::BlobTransfer>,
st: &TransferProgressState,
state: mcpmesh_local_api::BlobTransferState,
) {
let _ = bcast.send(crate::daemon::BlobTransfer {
direction: mcpmesh_local_api::BlobDirection::Fetch,
hash: st.hash.clone(),
bytes_done: st.done,
bytes_total: st.total,
state,
peer: None,
});
}
fn emit_transfer(
bcast: &tokio::sync::broadcast::Sender<crate::daemon::BlobTransfer>,
st: &TransferProgressState,
state: mcpmesh_local_api::BlobTransferState,
) {
let _ = bcast.send(crate::daemon::BlobTransfer {
direction: mcpmesh_local_api::BlobDirection::Serve,
hash: st.hash.clone(),
bytes_done: st.done,
bytes_total: st.total,
state,
peer: st.peer.clone(),
});
}
const IROH_CHUNK_BYTES: u64 = 16 * 1024;
pub struct AppBlobs {
store: FsStore,
endpoint: Endpoint,
transfers: Option<tokio::sync::broadcast::Sender<crate::daemon::BlobTransfer>>,
events: Option<EventSender>,
scopes: Arc<ScopeStore>,
gate_loop: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
relay_wait: std::sync::atomic::AtomicBool,
hash_membership: tokio::sync::Mutex<()>,
#[cfg(test)]
republish_delay: std::sync::Mutex<Option<std::time::Duration>>,
#[cfg(test)]
publish_delay: std::sync::Mutex<Option<std::time::Duration>>,
}
impl AppBlobs {
pub async fn shutdown(&self) {
let handle = self.gate_loop.lock().await.take();
if let Some(h) = handle {
h.abort();
let _ = h.await;
}
}
}
impl AppBlobs {
pub async fn open_fetcher(blobs_dir: PathBuf, endpoint: Endpoint) -> Result<Arc<Self>> {
Self::open_fetcher_with_progress(blobs_dir, endpoint, None).await
}
pub async fn open_fetcher_with_progress(
blobs_dir: PathBuf,
endpoint: Endpoint,
transfers: Option<tokio::sync::broadcast::Sender<crate::daemon::BlobTransfer>>,
) -> Result<Arc<Self>> {
tokio::fs::create_dir_all(&blobs_dir)
.await
.with_context(|| format!("create blobs dir {}", blobs_dir.display()))?;
let store = FsStore::load(&blobs_dir)
.await
.with_context(|| format!("load blob store {}", blobs_dir.display()))?;
Ok(Arc::new(Self {
store,
endpoint,
transfers,
events: None,
relay_wait: std::sync::atomic::AtomicBool::new(false),
hash_membership: tokio::sync::Mutex::new(()),
#[cfg(test)]
republish_delay: std::sync::Mutex::new(None),
#[cfg(test)]
publish_delay: std::sync::Mutex::new(None),
scopes: Arc::new(ScopeStore::new(blobs_dir.join("scopes.json"))),
gate_loop: tokio::sync::Mutex::new(None),
}))
}
pub async fn load(
blobs_dir: PathBuf,
scopes: Arc<ScopeStore>,
gate: Arc<dyn TrustGate>,
endpoint: Endpoint,
audit: AuditSink,
limits: Arc<crate::limits::MeshLimiters>,
transfers: Option<tokio::sync::broadcast::Sender<crate::daemon::BlobTransfer>>,
) -> Result<Arc<Self>> {
tokio::fs::create_dir_all(&blobs_dir)
.await
.with_context(|| format!("create blobs dir {}", blobs_dir.display()))?;
let store = FsStore::load(&blobs_dir)
.await
.with_context(|| format!("load blob store {}", blobs_dir.display()))?;
let mask = if limits.blob_bytes_enabled() {
APP_BLOB_EVENT_MASK_METERED
} else {
APP_BLOB_EVENT_MASK
};
let (events, rx) = EventSender::channel(64, mask);
let gate_loop = spawn_gate_loop(rx, gate, scopes.clone(), audit, limits, transfers.clone());
Ok(Arc::new(Self {
store,
endpoint,
transfers,
events: Some(events),
scopes,
gate_loop: tokio::sync::Mutex::new(Some(gate_loop)),
relay_wait: std::sync::atomic::AtomicBool::new(false),
hash_membership: tokio::sync::Mutex::new(()),
#[cfg(test)]
republish_delay: std::sync::Mutex::new(None),
#[cfg(test)]
publish_delay: std::sync::Mutex::new(None),
}))
}
pub fn protocol(&self) -> BlobsProtocol {
BlobsProtocol::new(&self.store, self.events.clone())
}
#[cfg(test)]
pub(crate) fn spawn_accept(&self, endpoint: &Endpoint) {
let proto = self.protocol();
let ep = endpoint.clone();
tokio::spawn(async move {
while let Some(incoming) = ep.accept().await {
if let Ok(conn) = incoming.await
&& conn.alpn() == APP_BLOB_ALPN
{
let _ = iroh::protocol::ProtocolHandler::accept(&proto, conn).await;
}
}
});
}
pub async fn publish_path(&self, path: &Path) -> Result<(String, String)> {
let tag = self
.store
.blobs()
.add_path(path)
.await
.with_context(|| format!("add blob from {}", path.display()))?;
let ticket = self.ticket_for(tag.hash).await;
Ok((ticket.to_string(), tag.hash.to_hex().to_string()))
}
pub async fn publish_scope(&self, scope: &str, path: &Path) -> Result<(String, String)> {
let (ticket, hash_hex) = self.publish_path(path).await?;
let _membership = self.hash_membership.lock().await;
#[cfg(test)]
{
let d = *self
.publish_delay
.lock()
.expect("publish delay lock not poisoned");
if let Some(d) = d {
tokio::time::sleep(d).await;
}
}
self.scopes.publish_hash(scope, &hash_hex)?;
Ok((ticket, hash_hex))
}
pub async fn republish(&self, scope: &str, hash_hex: &str) -> Result<(String, String)> {
let _membership = self.hash_membership.lock().await;
if !self.scopes.has_scope(scope) {
anyhow::bail!(crate::daemon::NoSuchBlobScope(scope.to_string()));
}
let hash = crate::blobs::parse_blob_hash(hash_hex)?;
let canonical = hash.to_hex().to_string();
if !self.store.blobs().has(hash).await.unwrap_or(false) {
anyhow::bail!(crate::daemon::NoSuchBlob(canonical));
}
if self.scopes.is_withdrawn(scope, &canonical) {
anyhow::bail!(crate::daemon::BlobWithdrawn {
scope: scope.to_string(),
hash: canonical,
});
}
#[cfg(test)]
{
let d = *self
.republish_delay
.lock()
.expect("republish delay lock not poisoned");
if let Some(d) = d {
tokio::time::sleep(d).await;
}
}
self.scopes.publish_hash(scope, &canonical)?;
drop(_membership);
Ok((self.ticket_for(hash).await.to_string(), canonical))
}
async fn ticket_for(&self, hash: Hash) -> BlobTicket {
if self.relay_wait.load(std::sync::atomic::Ordering::Relaxed) {
let _ = tokio::time::timeout(RELAY_READY_TIMEOUT, self.endpoint.online()).await;
}
BlobTicket::new(self.endpoint.addr(), hash, BlobFormat::Raw)
}
#[cfg(test)]
pub(crate) fn relay_wait_enabled(&self) -> bool {
self.relay_wait.load(std::sync::atomic::Ordering::Relaxed)
}
pub(crate) fn enable_relay_wait(&self) {
self.relay_wait
.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub fn grant(&self, scope: &str, principal: &str) -> Result<()> {
self.scopes.grant(scope, principal)
}
pub fn revoke_principals(&self, principals: &[String]) -> Result<bool> {
self.scopes.revoke_principals(principals)
}
pub fn revoke_from_scope(&self, scope: &str, principals: &[String]) -> Result<bool> {
self.scopes.revoke_from_scope(scope, principals)
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.has_scope(scope)
}
pub async fn unpublish(&self, scope: &str, hash_hex: &str) -> Result<bool> {
let canonical = crate::blobs::parse_blob_hash(hash_hex)?
.to_hex()
.to_string();
let _membership = self.hash_membership.lock().await;
self.scopes.unpublish_hash(scope, &canonical)
}
#[cfg(test)]
pub(crate) fn set_publish_delay(&self, d: std::time::Duration) {
*self
.publish_delay
.lock()
.expect("publish delay lock not poisoned") = Some(d);
}
#[cfg(test)]
pub(crate) fn set_republish_delay(&self, d: std::time::Duration) {
*self
.republish_delay
.lock()
.expect("republish delay lock not poisoned") = Some(d);
}
pub fn list_page(
&self,
q: &crate::blobs::scope::ListQuery,
) -> anyhow::Result<crate::blobs::scope::ScopePage> {
self.scopes.list_page(q)
}
pub fn list(&self) -> Vec<crate::blobs::scope::ScopeRow> {
self.scopes.list()
}
pub async fn fetch(&self, ticket_str: &str) -> Result<Hash> {
let ticket: BlobTicket = ticket_str.parse().context("parse blob ticket")?;
let conn = self
.endpoint
.connect(ticket.addr().clone(), APP_BLOB_ALPN)
.await
.context("dial app-blob provider")?;
use n0_future::StreamExt as _;
let hash_hex = ticket.hash().to_hex().to_string();
let mut stream = std::pin::pin!(self.store.remote().fetch(conn, ticket.hash()).stream());
let mut st = TransferProgressState {
hash: hash_hex,
peer: None,
total: None,
done: 0,
last_emitted: 0,
epochs: 0,
in_epoch: 0,
};
if let Some(b) = &self.transfers {
emit_fetch(b, &st, mcpmesh_local_api::BlobTransferState::Started);
}
let mut outcome: Result<()> = Err(anyhow::anyhow!("fetch stream closed without a result"));
while let Some(item) = stream.next().await {
match item {
iroh_blobs::api::remote::GetProgressItem::Progress(done) => {
st.done = done;
if let Some(b) = &self.transfers
&& st.done.saturating_sub(st.last_emitted) >= st.stride()
{
st.note_emitted();
emit_fetch(b, &st, mcpmesh_local_api::BlobTransferState::Progress);
}
}
iroh_blobs::api::remote::GetProgressItem::Done(_) => {
if let Some(b) = &self.transfers {
emit_fetch(b, &st, mcpmesh_local_api::BlobTransferState::Completed);
}
outcome = Ok(());
}
iroh_blobs::api::remote::GetProgressItem::Error(e) => {
if let Some(b) = &self.transfers {
emit_fetch(b, &st, mcpmesh_local_api::BlobTransferState::Aborted);
}
outcome = Err(anyhow::anyhow!("{e:#}"));
}
}
}
outcome.context("fetch app blob")?;
Ok(ticket.hash())
}
pub async fn read_bytes(&self, hash: Hash) -> Result<Bytes> {
self.store
.get_bytes(hash)
.await
.context("read fetched app blob")
}
pub async fn export_to(&self, hash: Hash, dest: &Path) -> Result<u64> {
self.store
.blobs()
.export(hash, dest)
.await
.with_context(|| format!("export app blob to {}", dest.display()))
}
}
fn audit_status(
decision: &Result<(), AbortReason>,
endpoint: Option<mcpmesh_net::EndpointId>,
reported: &mut HashSet<mcpmesh_net::EndpointId>,
) -> Option<&'static str> {
match decision {
Err(AbortReason::Permission) => Some("denied"),
Err(AbortReason::RateLimited) => match endpoint {
Some(eid) if !reported.insert(eid) => None,
_ => Some("rate_limited"),
},
Ok(()) => {
if let Some(eid) = endpoint {
reported.remove(&eid); }
Some("ok")
}
}
}
fn get_admission(
allow: bool,
endpoint: Option<&mcpmesh_net::EndpointId>,
limits: &crate::limits::MeshLimiters,
) -> Result<(), AbortReason> {
if !allow {
return Err(AbortReason::Permission);
}
let Some(eid) = endpoint else {
return Err(AbortReason::Permission);
};
if !request_budget_ok(Some(eid), limits) {
return Err(AbortReason::RateLimited);
}
Ok(())
}
fn request_budget_ok(
endpoint: Option<&mcpmesh_net::EndpointId>,
limits: &crate::limits::MeshLimiters,
) -> bool {
endpoint.is_some_and(|eid| limits.admit_blob_bytes(eid, IROH_CHUNK_BYTES))
}
fn throttle_decision(
endpoint: Option<&mcpmesh_net::EndpointId>,
size: u64,
limits: &crate::limits::MeshLimiters,
) -> Result<(), AbortReason> {
match endpoint {
None => Err(AbortReason::Permission),
Some(eid) if !limits.admit_blob_bytes(eid, size) => Err(AbortReason::RateLimited),
Some(_) => Ok(()),
}
}
fn spawn_gate_loop(
mut rx: tokio::sync::mpsc::Receiver<ProviderMessage>,
gate: Arc<dyn TrustGate>,
scopes: Arc<ScopeStore>,
audit: AuditSink,
limits: Arc<crate::limits::MeshLimiters>,
transfers: Option<tokio::sync::broadcast::Sender<crate::daemon::BlobTransfer>>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut conns: HashMap<u64, mcpmesh_net::EndpointId> = HashMap::new();
let mut budget_reported: HashSet<mcpmesh_net::EndpointId> = HashSet::new();
while let Some(msg) = rx.recv().await {
match msg {
ProviderMessage::ClientConnected(msg) => {
let res = match msg.endpoint_id {
Some(eid) => {
conns.insert(msg.connection_id, (*eid.as_bytes()).into());
Ok(())
}
None => Err(AbortReason::Permission),
};
msg.tx.send(res).await.ok();
}
ProviderMessage::Throttle(msg) => {
let res =
throttle_decision(conns.get(&msg.connection_id), msg.size, limits.as_ref());
msg.tx.send(res).await.ok();
}
ProviderMessage::GetRequestReceived(msg) => {
let identity = conns
.get(&msg.connection_id)
.and_then(|eid| gate.resolve(eid));
let hash_hex = msg.request.hash.to_hex().to_string();
let allow = msg.request.ranges.is_blob()
&& identity.as_ref().is_some_and(|identity| {
let eid = identity.endpoint.principal();
let principals: HashSet<&str> = mcpmesh_local_api::principal_set(
Some(&eid),
identity.user_id.as_deref(),
&identity.groups,
)
.into_iter()
.collect();
scopes.snapshot().allows(&hash_hex, &principals)
});
let peer = identity
.as_ref()
.map(|i| i.user_id.clone().unwrap_or_else(|| i.name.clone()));
let decision =
get_admission(allow, conns.get(&msg.connection_id), limits.as_ref());
let conn_eid = conns.get(&msg.connection_id).copied();
let status = audit_status(&decision, conn_eid, &mut budget_reported);
if let Some(status) = status {
audit.record(AuditRecord::blob_fetch(
now_ts(),
peer,
hash_hex,
status.into(),
conn_eid.map(|eid| eid.principal()),
));
}
let admitted = decision.is_ok();
msg.tx.send(decision).await.ok();
if admitted {
let bcast = transfers.clone();
let peer_principal = conn_eid.map(|eid| eid.principal());
let mut updates = msg.rx;
tokio::spawn(async move {
let mut st = None;
while let Ok(Some(update)) = updates.recv().await {
let terminal = match &bcast {
Some(b) => {
apply_transfer_update(&mut st, &update, b, &peer_principal)
}
None => matches!(
update,
iroh_blobs::provider::events::RequestUpdate::Completed(_)
| iroh_blobs::provider::events::RequestUpdate::Aborted(
_
)
),
};
if terminal {
return;
}
}
if let (Some(b), Some(cur)) = (&bcast, st.as_ref()) {
emit_transfer(
b,
cur,
mcpmesh_local_api::BlobTransferState::Aborted,
);
}
});
}
}
ProviderMessage::GetManyRequestReceived(msg) => {
msg.tx.send(Err(AbortReason::Permission)).await.ok();
}
ProviderMessage::PushRequestReceived(msg) => {
msg.tx.send(Err(AbortReason::Permission)).await.ok();
}
ProviderMessage::ObserveRequestReceived(msg) => {
msg.tx.send(Err(AbortReason::Permission)).await.ok();
}
ProviderMessage::ConnectionClosed(msg) => {
conns.remove(&msg.connection_id);
}
_ => {}
}
}
})
}
#[cfg(test)]
mod tests {
use super::{PROGRESS_STRIDE_BYTES, TransferProgressState, apply_transfer_update};
use iroh_blobs::provider::events::{RequestUpdate, TransferProgress, TransferStarted};
use mcpmesh_local_api::BlobTransferState as S;
fn started(size: u64) -> RequestUpdate {
RequestUpdate::Started(TransferStarted {
index: 0,
hash: iroh_blobs::Hash::new(b"blob"),
size,
})
}
fn progress(end_offset: u64) -> RequestUpdate {
RequestUpdate::Progress(TransferProgress { end_offset })
}
fn frames_for(size: u64, chunk: u64) -> Vec<crate::daemon::BlobTransfer> {
let (tx, mut rx) = tokio::sync::broadcast::channel(4096);
let mut st = None;
let peer = Some("eid:abc".to_string());
apply_transfer_update(&mut st, &started(size), &tx, &peer);
let mut at = 0;
while at < size {
at = (at + chunk).min(size);
apply_transfer_update(&mut st, &progress(at), &tx, &peer);
}
apply_transfer_update(
&mut st,
&RequestUpdate::Completed(iroh_blobs::provider::events::TransferCompleted {
stats: Box::new(iroh_blobs::provider::TransferStats {
payload_bytes_sent: 0,
other_bytes_sent: 0,
other_bytes_read: 0,
duration: std::time::Duration::ZERO,
}),
}),
&tx,
&peer,
);
let mut out = Vec::new();
while let Ok(f) = rx.try_recv() {
out.push(f);
}
out
}
#[test]
fn progress_frames_are_coalesced_not_one_per_chunk() {
const GIB: u64 = 1024 * 1024 * 1024;
let chunks = 4 * GIB / (16 * 1024);
let frames = frames_for(4 * GIB, 16 * 1024);
assert!(
frames.len() <= 110,
"a 4 GiB transfer produced {} frames from {chunks} chunks — the stride must bound this \
to ~102 (Started + ~100 Progress + Completed), or every subscriber lags out",
frames.len()
);
assert!(
frames.len() >= 3,
"…but it must still report PROGRESS, not just start and end: {}",
frames.len()
);
assert_eq!(frames.first().unwrap().state, S::Started);
assert_eq!(frames.last().unwrap().state, S::Completed);
assert!(
frames
.windows(2)
.all(|w| w[0].bytes_done <= w[1].bytes_done),
"bytes_done must never go backwards"
);
assert_eq!(
frames.last().unwrap().bytes_done,
4 * GIB,
"Completed must carry the FINAL count — the last Progress is skipped by the stride, so \
a consumer treating it as the total would stop short of 100%"
);
}
#[test]
fn completed_reports_the_total_even_when_the_last_progress_lagged() {
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut st = None;
apply_transfer_update(&mut st, &started(1000), &tx, &None);
apply_transfer_update(&mut st, &progress(400), &tx, &None);
apply_transfer_update(
&mut st,
&RequestUpdate::Completed(iroh_blobs::provider::events::TransferCompleted {
stats: Box::new(iroh_blobs::provider::TransferStats {
payload_bytes_sent: 0,
other_bytes_sent: 0,
other_bytes_read: 0,
duration: std::time::Duration::ZERO,
}),
}),
&tx,
&None,
);
let frames: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
let last = frames.last().unwrap();
assert_eq!(last.state, S::Completed);
assert_eq!(
last.bytes_done, 1000,
"Completed must report the total, not the 400 the last Progress reached — otherwise \
the consumer's bar stops at 40% on a fully successful transfer"
);
}
#[test]
fn a_small_transfer_still_reports_both_ends() {
let frames = frames_for(1024, 512);
assert_eq!(frames.first().unwrap().state, S::Started);
assert_eq!(frames.last().unwrap().state, S::Completed);
assert_eq!(frames.last().unwrap().bytes_done, 1024);
assert_eq!(
frames.first().unwrap().bytes_total,
Some(1024),
"bytes_total is known from Started onward"
);
assert_eq!(
frames.first().unwrap().peer.as_deref(),
Some("eid:abc"),
"the SERVING side attributes the stable principal (#38), never a nickname"
);
}
#[test]
fn the_stride_scales_with_the_transfer_size() {
let small = TransferProgressState {
hash: "h".into(),
peer: None,
total: Some(1024),
done: 0,
last_emitted: 0,
epochs: 0,
in_epoch: 0,
};
assert_eq!(
small.stride(),
PROGRESS_STRIDE_BYTES,
"a tiny transfer floors at the fixed stride rather than emitting per byte"
);
let big = TransferProgressState {
total: Some(4 * 1024 * 1024 * 1024),
..small
};
assert_eq!(
big.stride(),
4 * 1024 * 1024 * 1024 / 100,
"a big one uses 1% so the frame COUNT stays bounded instead of the byte gap"
);
}
#[test]
fn an_unknown_total_still_bounds_the_frame_count() {
const GIB: u64 = 1024 * 1024 * 1024;
let mut st = TransferProgressState {
hash: "h".into(),
peer: None,
total: None, done: 0,
last_emitted: 0,
epochs: 0,
in_epoch: 0,
};
let mut frames = 0u32;
let mut at = 0u64;
let chunk = 16 * 1024;
while at < 4 * GIB {
at += chunk;
st.done = at;
if st.done.saturating_sub(st.last_emitted) >= st.stride() {
st.note_emitted();
frames += 1;
}
}
assert!(
frames <= 200,
"a 4 GiB FETCH emitted {frames} progress frames into a {}-deep ring — the stride must \
widen when the total is unknown, or a subscriber lags out on the very transfer this \
feature exists to show",
256
);
assert!(
frames >= 20,
"…but it must still report meaningfully often: {frames}"
);
}
#[test]
fn an_aborted_transfer_is_reported_not_silently_dropped() {
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut st = None;
apply_transfer_update(&mut st, &started(4096), &tx, &None);
let terminal = apply_transfer_update(
&mut st,
&RequestUpdate::Aborted(iroh_blobs::provider::events::TransferAborted {
stats: Box::new(iroh_blobs::provider::TransferStats {
payload_bytes_sent: 0,
other_bytes_sent: 0,
other_bytes_read: 0,
duration: std::time::Duration::ZERO,
}),
}),
&tx,
&None,
);
assert!(terminal, "Aborted must end the drain");
let frames: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
assert_eq!(frames.len(), 2);
assert_eq!(frames[1].state, S::Aborted);
}
#[test]
fn the_audit_status_follows_the_decision_and_reports_a_refusal_once() {
use mcpmesh_net::EndpointId;
let eid = EndpointId::from_bytes([2u8; 32]);
let mut seen = HashSet::new();
assert_eq!(
super::audit_status(&Err(AbortReason::Permission), Some(eid), &mut seen),
Some("denied"),
"a refused GET must never be audited as a successful fetch"
);
assert_eq!(
super::audit_status(&Err(AbortReason::Permission), None, &mut seen),
Some("denied")
);
assert_eq!(
super::audit_status(&Err(AbortReason::RateLimited), Some(eid), &mut seen),
Some("rate_limited"),
"the first refusal is news — the issue's complaint was that nothing reported it"
);
for _ in 0..500 {
assert_eq!(
super::audit_status(&Err(AbortReason::RateLimited), Some(eid), &mut seen),
None,
"and every later one is silent — refusals are cheap, so recording each would \
trade an uplink DoS for an audit-log DoS (~2250 records/s measured)"
);
}
assert_eq!(
super::audit_status(&Ok(()), Some(eid), &mut seen),
Some("ok")
);
assert_eq!(
super::audit_status(&Err(AbortReason::RateLimited), Some(eid), &mut seen),
Some("rate_limited"),
"a peer that recovered and re-offended must be reported again"
);
let other = EndpointId::from_bytes([3u8; 32]);
assert_eq!(
super::audit_status(&Err(AbortReason::RateLimited), Some(other), &mut seen),
Some("rate_limited")
);
}
#[test]
fn request_budget_ok_fails_closed_on_an_unattributable_connection() {
use crate::config::LimitsCfg;
use crate::limits::MeshLimiters;
let off = MeshLimiters::from_config(&LimitsCfg::default());
assert!(
!super::request_budget_ok(None, &off),
"a connection with no ClientConnected record must be refused — metering it against \
nobody is the per-connection bypass by another route"
);
let on = MeshLimiters::from_config(&LimitsCfg {
blob_bytes_per_min: super::IROH_CHUNK_BYTES * 4,
..Default::default()
});
assert!(!super::request_budget_ok(None, &on));
}
#[test]
fn get_admission_refuses_on_budget_as_well_as_authz() {
use crate::config::LimitsCfg;
use crate::limits::MeshLimiters;
use mcpmesh_net::EndpointId;
let eid = EndpointId::from_bytes([6u8; 32]);
let lim = MeshLimiters::from_config(&LimitsCfg {
blob_bytes_per_min: super::IROH_CHUNK_BYTES * 2,
..Default::default()
});
assert!(matches!(
super::get_admission(false, Some(&eid), &lim),
Err(AbortReason::Permission)
));
assert!(super::get_admission(true, Some(&eid), &lim).is_ok());
assert!(super::get_admission(true, Some(&eid), &lim).is_ok());
assert!(
matches!(
super::get_admission(true, Some(&eid), &lim),
Err(AbortReason::RateLimited)
),
"an over-budget REQUEST must be refused before any bytes — metering only per chunk \
let a peer take one free chunk per request forever"
);
assert!(matches!(
super::get_admission(true, None, &lim),
Err(AbortReason::Permission)
));
}
#[test]
fn the_documented_minimum_budget_admits_a_request_and_a_chunk() {
use crate::config::LimitsCfg;
use crate::limits::MeshLimiters;
use mcpmesh_net::EndpointId;
let eid = EndpointId::from_bytes([8u8; 32]);
const DOCUMENTED_MIN: u64 = 32_768;
let lim = MeshLimiters::from_config(&LimitsCfg {
blob_bytes_per_min: DOCUMENTED_MIN,
..Default::default()
});
assert!(
super::request_budget_ok(Some(&eid), &lim),
"the documented minimum must admit a request"
);
assert!(
super::throttle_decision(Some(&eid), super::IROH_CHUNK_BYTES, &lim).is_ok(),
"and must still have budget for the first CHUNK — otherwise the value we tell \
operators to use serves zero bytes, which is the state the doc warns against"
);
let floored = MeshLimiters::from_config(&LimitsCfg {
blob_bytes_per_min: super::IROH_CHUNK_BYTES, ..Default::default()
});
assert!(super::request_budget_ok(Some(&eid), &floored));
assert!(
super::throttle_decision(Some(&eid), super::IROH_CHUNK_BYTES, &floored).is_ok(),
"a sub-floor budget must be raised to a usable one, not honoured into a daemon that \
admits a request and then truncates every blob"
);
}
#[test]
fn the_metered_mask_differs_from_the_default_in_throttle_alone() {
let d = super::APP_BLOB_EVENT_MASK;
let m = super::APP_BLOB_EVENT_MASK_METERED;
assert_eq!(
d.throttle,
ThrottleMode::None,
"a deployment with no budget must not arm the per-chunk intercept"
);
assert_eq!(m.throttle, ThrottleMode::Intercept);
assert_eq!(d.connected, m.connected, "connect gate");
assert_eq!(d.get, m.get, "the GET scope gate");
assert_eq!(d.get_many, m.get_many, "get_many stays denied");
assert_eq!(d.push, m.push, "push stays denied");
assert_eq!(d.observe, m.observe, "observe stays intercepted");
}
#[test]
fn a_request_is_refused_once_the_endpoint_budget_is_spent() {
use crate::config::LimitsCfg;
use crate::limits::MeshLimiters;
use mcpmesh_net::EndpointId;
let eid = EndpointId::from_bytes([4u8; 32]);
let lim = MeshLimiters::from_config(&LimitsCfg {
blob_bytes_per_min: super::IROH_CHUNK_BYTES * 2,
..Default::default()
});
assert!(super::request_budget_ok(Some(&eid), &lim), "first request");
assert!(super::request_budget_ok(Some(&eid), &lim), "second request");
assert!(
!super::request_budget_ok(Some(&eid), &lim),
"the THIRD request must be refused before any bytes — metering only per chunk lets a \
peer take one free chunk per request forever, which is ~1800x the budget in practice"
);
let off = MeshLimiters::from_config(&LimitsCfg::default());
for _ in 0..100 {
assert!(super::request_budget_ok(Some(&eid), &off));
}
}
#[test]
fn a_chunk_is_refused_over_budget_and_when_it_cannot_be_attributed() {
use crate::config::LimitsCfg;
use crate::limits::MeshLimiters;
use mcpmesh_net::EndpointId;
let eid = EndpointId::from_bytes([1u8; 32]);
let lim = MeshLimiters::from_config(&LimitsCfg {
blob_bytes_per_min: 32_768, ..Default::default()
});
assert!(
super::throttle_decision(Some(&eid), 16_384, &lim).is_ok(),
"the first chunk is inside the budget"
);
assert!(
super::throttle_decision(Some(&eid), 16_384, &lim).is_ok(),
"and the second"
);
assert!(
matches!(
super::throttle_decision(Some(&eid), 16_384, &lim),
Err(AbortReason::RateLimited)
),
"over budget must be RateLimited — the peer IS authorized and pacing failed, so \
reporting Permission would put a bandwidth event in the audit trail as an authz denial"
);
assert!(
matches!(
super::throttle_decision(None, 16_384, &lim),
Err(AbortReason::Permission)
),
"an unattributable chunk must be REFUSED — metering it against nobody is the same \
bypass as metering per connection"
);
let off = MeshLimiters::from_config(&LimitsCfg::default());
assert!(super::throttle_decision(Some(&eid), u64::MAX, &off).is_ok());
assert!(
super::throttle_decision(None, 1, &off).is_err(),
"fail-closed does not depend on a budget being configured"
);
}
use super::*;
use crate::blobs::APP_BLOB_ALPN;
use crate::blobs::scope::ScopeStore;
use mcpmesh_net::{EndpointId, PeerIdentity, StaticGate};
use std::sync::Arc;
#[tokio::test]
async fn republishing_a_blob_we_do_not_hold_fails_and_leaves_the_scope_untouched() {
let dir = tempfile::tempdir().unwrap();
let provider = AppBlobs::open_fetcher(dir.path().join("blobs"), ep().await)
.await
.unwrap();
provider.grant("room", "b64u:alice").unwrap();
let absent = blake3::hash(b"never fetched").to_hex().to_string();
let err = provider
.republish("room", &absent)
.await
.expect_err("republishing a blob we do not hold must fail");
assert!(
err.downcast_ref::<crate::daemon::NoSuchBlob>().is_some(),
"must be NoSuchBlob so the client can tell it apart from a bad scope, got: {err}"
);
let hashes: Vec<String> = provider
.list()
.into_iter()
.flat_map(|(_, hashes, _, _)| hashes)
.collect();
assert!(
!hashes.contains(&absent),
"a FAILED republish must not half-advertise the hash, got {hashes:?}"
);
}
#[tokio::test]
async fn an_unknown_scope_outranks_a_missing_blob() {
let dir = tempfile::tempdir().unwrap();
let provider = AppBlobs::open_fetcher(dir.path().join("blobs"), ep().await)
.await
.unwrap();
let absent = blake3::hash(b"nope").to_hex().to_string();
let err = provider
.republish("no-such-scope", &absent)
.await
.expect_err("unknown scope must fail");
assert!(
err.downcast_ref::<crate::daemon::NoSuchBlobScope>()
.is_some(),
"an unknown scope outranks a missing blob, got: {err}"
);
}
#[tokio::test]
async fn a_fetched_blob_is_servable_from_the_fetcher_after_the_publisher_goes_away() {
tokio::time::timeout(std::time::Duration::from_secs(60), async {
let c_ep = ep().await;
let c_eid = EndpointId::from_bytes(*c_ep.id().as_bytes());
let mut entries = HashMap::new();
entries.insert(
c_eid,
PeerIdentity {
endpoint: c_eid,
name: "carol".into(),
user_id: Some("carol".into()),
groups: vec![],
},
);
let b_gate: Arc<dyn mcpmesh_net::TrustGate> = Arc::new(StaticGate::new(entries));
let adir = tempfile::tempdir().unwrap();
let a_ep = ep().await;
let a = AppBlobs::open_fetcher(adir.path().join("blobs"), a_ep.clone())
.await
.unwrap();
a.spawn_accept(&a_ep);
let src = adir.path().join("shared.bin");
std::fs::write(&src, b"the file everyone wants").unwrap();
let (a_ticket, hash_hex) = a.publish_path(&src).await.unwrap();
let bdir = tempfile::tempdir().unwrap();
let b_ep = ep().await;
let b = AppBlobs::load(
bdir.path().join("blobs"),
Arc::new(ScopeStore::new(bdir.path().join("scopes.json"))),
b_gate,
b_ep.clone(),
crate::audit::AuditSink::disabled(),
crate::limits::MeshLimiters::unlimited(),
None,
)
.await
.unwrap();
b.spawn_accept(&b_ep);
b.fetch(&a_ticket).await.unwrap();
b.grant("b-room", "carol").unwrap();
let (b_ticket, _canon) = b.republish("b-room", &hash_hex).await.unwrap();
assert_ne!(b_ticket, a_ticket, "the ticket must name B, not A");
a_ep.close().await;
let cdir = tempfile::tempdir().unwrap();
let c = AppBlobs::open_fetcher(cdir.path().join("blobs"), c_ep)
.await
.unwrap();
let got = c
.fetch(&b_ticket)
.await
.expect("C must fetch from B with A offline — the whole point of #83");
assert_eq!(
&c.read_bytes(got).await.unwrap()[..],
b"the file everyone wants"
);
})
.await
.expect("republish round-trip timed out");
}
#[tokio::test]
async fn republish_does_not_inherit_the_publishers_grants() {
tokio::time::timeout(std::time::Duration::from_secs(90), async {
let m_ep = ep().await;
let m_eid = EndpointId::from_bytes(*m_ep.id().as_bytes());
let mut entries = HashMap::new();
entries.insert(
m_eid,
PeerIdentity {
endpoint: m_eid,
name: "mallory".into(),
user_id: Some("mallory".into()),
groups: vec![],
},
);
let b_gate: Arc<dyn mcpmesh_net::TrustGate> = Arc::new(StaticGate::new(entries));
let adir = tempfile::tempdir().unwrap();
let a_ep = ep().await;
let a = AppBlobs::open_fetcher(adir.path().join("blobs"), a_ep.clone())
.await
.unwrap();
a.spawn_accept(&a_ep);
let src = adir.path().join("f.bin");
std::fs::write(&src, b"a's file").unwrap();
let (a_ticket, hash_hex) = a.publish_path(&src).await.unwrap();
a.grant("a-room", "mallory").unwrap();
let bdir = tempfile::tempdir().unwrap();
let b_ep = ep().await;
let b = AppBlobs::load(
bdir.path().join("blobs"),
Arc::new(ScopeStore::new(bdir.path().join("scopes.json"))),
b_gate,
b_ep.clone(),
crate::audit::AuditSink::disabled(),
crate::limits::MeshLimiters::unlimited(),
None,
)
.await
.unwrap();
b.spawn_accept(&b_ep);
b.fetch(&a_ticket).await.unwrap();
b.grant("b-room", "someone-else").unwrap();
let (b_ticket, _canon) = b.republish("b-room", &hash_hex).await.unwrap();
let mdir = tempfile::tempdir().unwrap();
let mallory = AppBlobs::open_fetcher(mdir.path().join("blobs"), m_ep)
.await
.unwrap();
let res =
tokio::time::timeout(std::time::Duration::from_secs(10), mallory.fetch(&b_ticket))
.await;
assert!(
!matches!(res, Ok(Ok(_))),
"republishing must not transfer A's grants to B's copy — that would silently widen \
access to everyone the previous holder shared with (got {res:?})"
);
})
.await
.expect("grant-isolation test timed out");
}
#[tokio::test]
async fn a_non_canonical_hash_is_normalized_before_it_is_recorded() {
let dir = tempfile::tempdir().unwrap();
let provider = AppBlobs::open_fetcher(dir.path().join("blobs"), ep().await)
.await
.unwrap();
provider.grant("room", "b64u:alice").unwrap();
let src = dir.path().join("f.bin");
std::fs::write(&src, b"canonical me").unwrap();
let (_t, canonical) = provider.publish_path(&src).await.unwrap();
let parsed = crate::blobs::parse_blob_hash(&canonical).unwrap();
let base32 = data_encoding::BASE32_NOPAD
.encode(parsed.as_bytes())
.to_ascii_lowercase();
assert_ne!(base32, canonical, "the fixture must actually differ");
let (_ticket, returned) = provider
.republish("room", &base32)
.await
.expect("an alternative rendering of a held hash must republish");
assert_eq!(
returned, canonical,
"the RESULT must carry canonical hex — blob_publish does, and the docs promise the two \
are interchangeable"
);
let recorded: Vec<String> = provider
.list()
.into_iter()
.filter(|(name, _, _, _)| name == "room")
.flat_map(|(_, hashes, _, _)| hashes)
.collect();
assert_eq!(
recorded,
vec![canonical],
"the SCOPE must record canonical hex — the gate compares against it, so a raw-string \
entry would authorize nobody and be unremovable"
);
}
#[tokio::test]
async fn a_concurrent_unpublish_is_not_lost_to_a_republish() {
tokio::time::timeout(std::time::Duration::from_secs(120), async {
let dir = tempfile::tempdir().unwrap();
let provider = AppBlobs::open_fetcher(dir.path().join("blobs"), ep().await)
.await
.unwrap();
provider.grant("room", "b64u:alice").unwrap();
let src = dir.path().join("f.bin");
std::fs::write(&src, b"contested").unwrap();
let (_t, hash_hex) = provider.publish_scope("room", &src).await.unwrap();
provider.set_republish_delay(std::time::Duration::from_millis(600));
let p2 = provider.clone();
let h2 = hash_hex.clone();
let republish =
tokio::spawn(async move { p2.republish("room", &h2).await.map(|_| ()) });
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
let removed = provider.unpublish("room", &hash_hex).await.unwrap();
republish.await.unwrap().unwrap();
assert!(removed, "the unpublish must actually have removed the hash");
let hashes: Vec<String> = provider
.list()
.into_iter()
.flat_map(|(_, hashes, _, _)| hashes)
.collect();
assert!(
!hashes.contains(&hash_hex),
"the revocation must survive — a republish that overwrites a concurrent unpublish \
tells the operator the file was withdrawn while it is still being served (scope \
now holds {hashes:?})"
);
})
.await
.expect("republish/unpublish race test timed out");
}
#[tokio::test]
async fn a_concurrent_unpublish_is_not_lost_to_a_publish() {
tokio::time::timeout(std::time::Duration::from_secs(120), async {
let dir = tempfile::tempdir().unwrap();
let provider = AppBlobs::open_fetcher(dir.path().join("blobs"), ep().await)
.await
.unwrap();
provider.grant("room", "b64u:alice").unwrap();
let src = dir.path().join("f.bin");
std::fs::write(&src, b"contested by publish").unwrap();
let (_t, hash_hex) = provider.publish_scope("room", &src).await.unwrap();
provider.set_publish_delay(std::time::Duration::from_millis(600));
let p2 = provider.clone();
let src2 = src.clone();
let publish =
tokio::spawn(async move { p2.publish_scope("room", &src2).await.map(|_| ()) });
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
let removed = provider.unpublish("room", &hash_hex).await.unwrap();
publish.await.unwrap().unwrap();
assert!(removed, "the unpublish must actually have removed the hash");
let hashes: Vec<String> = provider
.list()
.into_iter()
.flat_map(|(_, hashes, _, _)| hashes)
.collect();
assert!(
!hashes.contains(&hash_hex),
"a re-publish of identical bytes must not overwrite a concurrent revocation \
(scope now holds {hashes:?})"
);
})
.await
.expect("publish/unpublish race test timed out");
}
#[tokio::test]
async fn the_relay_wait_actually_runs_and_is_capped() {
let dir = tempfile::tempdir().unwrap();
let provider_ep = ep().await;
let provider = AppBlobs::open_fetcher(dir.path().join("blobs"), provider_ep.clone())
.await
.unwrap();
assert!(
!provider.relay_wait_enabled(),
"the wait must default OFF — every hand-built fixture would otherwise pay the full cap \
per mint, and the boot guard would stop guarding anything"
);
provider.enable_relay_wait();
provider.spawn_accept(&provider_ep);
let src = dir.path().join("capped.bin");
std::fs::write(&src, b"capped").unwrap();
let started = std::time::Instant::now();
let published = provider.publish_path(&src).await.unwrap();
let elapsed = started.elapsed();
assert!(
elapsed >= crate::daemon::RELAY_READY_TIMEOUT,
"the wait must actually RUN — `online()` never completes on a relay-disabled endpoint, \
so an enabled wait consumes the full cap. Minting in {elapsed:?} means the wait was \
skipped or removed"
);
assert!(
elapsed < crate::daemon::RELAY_READY_TIMEOUT + std::time::Duration::from_secs(2),
"and it must be CAPPED — minting took {elapsed:?}, so the bound is longer than \
RELAY_READY_TIMEOUT or the wait is unbounded"
);
let cdir = tempfile::tempdir().unwrap();
let caller = AppBlobs::open_fetcher(cdir.path().join("blobs"), ep().await)
.await
.unwrap();
let hash = tokio::time::timeout(
std::time::Duration::from_secs(30),
caller.fetch(&published.0),
)
.await
.expect("fetch timed out")
.expect("the fallback direct-address ticket must still round-trip");
assert_eq!(&caller.read_bytes(hash).await.unwrap()[..], b"capped");
}
#[tokio::test]
async fn a_completed_unpublish_is_not_undone_by_a_later_republish() {
tokio::time::timeout(std::time::Duration::from_secs(90), async {
let dir = tempfile::tempdir().unwrap();
let provider = AppBlobs::open_fetcher(dir.path().join("blobs"), ep().await)
.await
.unwrap();
provider.grant("room", "b64u:alice").unwrap();
let src = dir.path().join("f.bin");
std::fs::write(&src, b"withdrawn content").unwrap();
let (_t, hash_hex) = provider.publish_scope("room", &src).await.unwrap();
assert!(provider.unpublish("room", &hash_hex).await.unwrap());
let err = provider
.republish("room", &hash_hex)
.await
.expect_err("a withdrawn hash must not republish");
assert!(
err.downcast_ref::<crate::daemon::BlobWithdrawn>().is_some(),
"must be BlobWithdrawn so a client can tell it from 'fetch it first', got: {err}"
);
let hashes: Vec<String> = provider
.list()
.into_iter()
.flat_map(|(_, hashes, _, _)| hashes)
.collect();
assert!(
!hashes.contains(&hash_hex),
"and the scope must still not list it (got {hashes:?})"
);
})
.await
.expect("durable revocation test timed out");
}
#[tokio::test]
async fn publishing_from_the_file_again_lifts_the_withdrawal() {
tokio::time::timeout(std::time::Duration::from_secs(90), async {
let dir = tempfile::tempdir().unwrap();
let provider = AppBlobs::open_fetcher(dir.path().join("blobs"), ep().await)
.await
.unwrap();
provider.grant("room", "b64u:alice").unwrap();
let src = dir.path().join("f.bin");
std::fs::write(&src, b"re-shared on purpose").unwrap();
let (_t, hash_hex) = provider.publish_scope("room", &src).await.unwrap();
provider.unpublish("room", &hash_hex).await.unwrap();
provider.republish("room", &hash_hex).await.unwrap_err();
provider.publish_scope("room", &src).await.unwrap();
provider
.republish("room", &hash_hex)
.await
.expect("after a deliberate re-publish, republish is allowed again");
})
.await
.expect("un-withdraw test timed out");
}
#[tokio::test]
async fn republishing_twice_is_not_an_error_and_records_one_entry() {
let dir = tempfile::tempdir().unwrap();
let provider = AppBlobs::open_fetcher(dir.path().join("blobs"), ep().await)
.await
.unwrap();
provider.grant("room", "b64u:alice").unwrap();
let src = dir.path().join("f.bin");
std::fs::write(&src, b"dupe").unwrap();
let (_t, hash_hex) = provider.publish_path(&src).await.unwrap();
provider.republish("room", &hash_hex).await.unwrap();
provider.republish("room", &hash_hex).await.unwrap();
let rooms: Vec<(String, Vec<String>)> = provider
.list()
.into_iter()
.map(|(name, hashes, _, _)| (name, hashes))
.collect();
assert_eq!(
rooms,
vec![("room".to_string(), vec![hash_hex.clone()])],
"exactly one entry, in the NAMED scope, not two and not elsewhere"
);
}
#[test]
fn app_blob_event_mask_pins_non_get_request_types_to_deny_by_default() {
assert_eq!(APP_BLOB_EVENT_MASK.connected, ConnectMode::Intercept);
assert_eq!(APP_BLOB_EVENT_MASK.get, RequestMode::InterceptLog);
assert_eq!(APP_BLOB_EVENT_MASK.get_many, RequestMode::Disabled);
assert_eq!(APP_BLOB_EVENT_MASK.push, RequestMode::Disabled);
assert_eq!(APP_BLOB_EVENT_MASK.observe, ObserveMode::Intercept);
assert_eq!(APP_BLOB_EVENT_MASK.throttle, ThrottleMode::None);
}
async fn ep() -> iroh::Endpoint {
iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)
.relay_mode(iroh::RelayMode::Disabled)
.alpns(vec![APP_BLOB_ALPN.to_vec()])
.bind()
.await
.expect("bind endpoint")
}
#[tokio::test]
async fn ungated_fetcher_still_round_trips() {
tokio::time::timeout(std::time::Duration::from_secs(30), async {
let pdir = tempfile::tempdir().unwrap();
let provider_ep = ep().await;
let provider = AppBlobs::open_fetcher(pdir.path().join("blobs"), provider_ep.clone())
.await
.unwrap();
provider.spawn_accept(&provider_ep);
let src = pdir.path().join("p.bin");
std::fs::write(&src, b"hello scopes").unwrap();
let (ticket, _hash) = provider.publish_path(&src).await.unwrap();
let cdir = tempfile::tempdir().unwrap();
let caller_ep = ep().await;
let caller = AppBlobs::open_fetcher(cdir.path().join("blobs"), caller_ep.clone())
.await
.unwrap();
let hash = caller.fetch(&ticket).await.unwrap();
assert_eq!(&caller.read_bytes(hash).await.unwrap()[..], b"hello scopes");
})
.await
.expect("timed out");
}
#[tokio::test]
async fn granted_caller_fetches_but_ungranted_and_uncontained_are_denied() {
tokio::time::timeout(std::time::Duration::from_secs(30), async {
let alice_ep = ep().await;
let bob_ep = ep().await;
let alice_id: EndpointId = alice_ep.id().into();
let bob_id: EndpointId = bob_ep.id().into();
let mut entries = std::collections::HashMap::new();
entries.insert(
alice_id,
PeerIdentity {
endpoint: [0u8; 32].into(),
name: "alice".into(),
user_id: Some("alice".into()),
groups: vec!["team-eng".into()],
},
);
entries.insert(
bob_id,
PeerIdentity {
endpoint: [0u8; 32].into(),
name: "bob".into(),
user_id: Some("bob".into()),
groups: vec!["team-eng".into()],
},
);
let gate: Arc<dyn mcpmesh_net::TrustGate> = Arc::new(StaticGate::new(entries));
let pdir = tempfile::tempdir().unwrap();
let scopes = Arc::new(ScopeStore::new(pdir.path().join("scopes.json")));
let provider_ep = ep().await;
let provider = AppBlobs::load(
pdir.path().join("blobs"),
scopes,
gate,
provider_ep.clone(),
crate::audit::AuditSink::disabled(),
crate::limits::MeshLimiters::unlimited(),
None,
)
.await
.unwrap();
provider.spawn_accept(&provider_ep);
let src = pdir.path().join("secret.bin");
std::fs::write(&src, b"top secret bytes").unwrap();
let (ticket, _hash) = provider.publish_scope("docs", &src).await.unwrap();
provider.grant("docs", "alice").unwrap();
let cdir = tempfile::tempdir().unwrap();
let alice = AppBlobs::open_fetcher(cdir.path().join("a"), alice_ep.clone())
.await
.unwrap();
let hash = alice.fetch(&ticket).await.expect("granted alice fetches");
assert_eq!(
&alice.read_bytes(hash).await.unwrap()[..],
b"top secret bytes"
);
let bob = AppBlobs::open_fetcher(cdir.path().join("b"), bob_ep.clone())
.await
.unwrap();
let bob_res =
tokio::time::timeout(std::time::Duration::from_secs(10), bob.fetch(&ticket)).await;
assert!(
matches!(bob_res, Ok(Err(_))),
"ungranted bob is refused: {bob_res:?}"
);
})
.await
.expect("timed out");
}
#[tokio::test]
async fn pairing_mode_eid_grant_admits_and_nickname_grant_stays_denied() {
tokio::time::timeout(std::time::Duration::from_secs(30), async {
let carol_ep = ep().await; let mallory_ep = ep().await; let carol_id: EndpointId = carol_ep.id().into();
let mallory_id: EndpointId = mallory_ep.id().into();
let mut entries = std::collections::HashMap::new();
entries.insert(
carol_id,
PeerIdentity {
endpoint: carol_id, name: "carol".into(),
user_id: None, groups: vec![],
},
);
entries.insert(
mallory_id,
PeerIdentity {
endpoint: mallory_id,
name: "mallory".into(),
user_id: None,
groups: vec![],
},
);
let gate: Arc<dyn mcpmesh_net::TrustGate> = Arc::new(StaticGate::new(entries));
let pdir = tempfile::tempdir().unwrap();
let scopes = Arc::new(ScopeStore::new(pdir.path().join("scopes.json")));
let provider_ep = ep().await;
let provider = AppBlobs::load(
pdir.path().join("blobs"),
scopes,
gate,
provider_ep.clone(),
crate::audit::AuditSink::disabled(),
crate::limits::MeshLimiters::unlimited(),
None,
)
.await
.unwrap();
provider.spawn_accept(&provider_ep);
let src = pdir.path().join("attach.bin");
std::fs::write(&src, b"eid-scoped bytes").unwrap();
let (ticket, _hash) = provider
.publish_scope("kb-attach-carol", &src)
.await
.unwrap();
provider
.grant("kb-attach-carol", &format!("eid:{}", carol_ep.id()))
.unwrap();
provider.grant("kb-attach-carol", "mallory").unwrap();
let cdir = tempfile::tempdir().unwrap();
let carol = AppBlobs::open_fetcher(cdir.path().join("c"), carol_ep.clone())
.await
.unwrap();
let hash = carol
.fetch(&ticket)
.await
.expect("a pairing-mode peer granted by its eid: principal fetches");
assert_eq!(
&carol.read_bytes(hash).await.unwrap()[..],
b"eid-scoped bytes"
);
let mallory = AppBlobs::open_fetcher(cdir.path().join("m"), mallory_ep.clone())
.await
.unwrap();
let res =
tokio::time::timeout(std::time::Duration::from_secs(10), mallory.fetch(&ticket))
.await;
assert!(
matches!(res, Ok(Err(_))),
"a nickname-only grant is refused: {res:?}"
);
})
.await
.expect("eid-grant test timed out");
}
#[tokio::test]
async fn served_get_records_blob_fetch_audit() {
use crate::audit::{AuditLog, AuditSink};
tokio::time::timeout(std::time::Duration::from_secs(30), async {
let alice_ep = ep().await;
let alice_id: EndpointId = alice_ep.id().into();
let mut entries = std::collections::HashMap::new();
entries.insert(
alice_id,
PeerIdentity {
endpoint: [0u8; 32].into(),
name: "alice".into(),
user_id: Some("alice".into()),
groups: vec![],
},
);
let gate: Arc<dyn mcpmesh_net::TrustGate> = Arc::new(StaticGate::new(entries));
let pdir = tempfile::tempdir().unwrap();
let audit_dir = pdir.path().join("audit");
let sink = AuditSink::new(AuditLog::spawn(audit_dir.clone()));
let scopes = Arc::new(ScopeStore::new(pdir.path().join("scopes.json")));
let provider_ep = ep().await;
let provider = AppBlobs::load(
pdir.path().join("blobs"),
scopes,
gate,
provider_ep.clone(),
sink,
crate::limits::MeshLimiters::unlimited(),
None,
)
.await
.unwrap();
provider.spawn_accept(&provider_ep);
let src = pdir.path().join("doc.bin");
std::fs::write(&src, b"auditable bytes").unwrap();
let (ticket, hash_hex) = provider.publish_scope("docs", &src).await.unwrap();
provider.grant("docs", "alice").unwrap();
let cdir = tempfile::tempdir().unwrap();
let alice = AppBlobs::open_fetcher(cdir.path().join("a"), alice_ep.clone())
.await
.unwrap();
let _ = alice.fetch(&ticket).await.expect("granted alice fetches");
let month = &crate::audit::now_ts()[..7];
let file = audit_dir.join(format!("{month}.jsonl"));
let mut ok = false;
for _ in 0..50 {
let alice_eid = format!("eid:{}", alice_ep.id());
if let Ok(b) = std::fs::read_to_string(&file)
&& b.contains("\"kind\":\"blob_fetch\"")
&& b.contains("\"peer\":\"alice\"")
&& b.contains(&format!("\"principal\":\"{alice_eid}\""))
&& b.contains(&hash_hex)
{
ok = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(
ok,
"a served GET records blob_fetch(peer=alice, hash, status)"
);
})
.await
.expect("blob_fetch audit test timed out");
}
}