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;
const APP_BLOB_EVENT_MASK: EventMask = EventMask {
connected: ConnectMode::Intercept,
get: RequestMode::Intercept,
get_many: RequestMode::Disabled,
push: RequestMode::Disabled,
observe: ObserveMode::Intercept,
throttle: ThrottleMode::None,
};
pub struct AppBlobs {
store: FsStore,
endpoint: Endpoint,
events: Option<EventSender>,
scopes: Arc<ScopeStore>,
}
impl AppBlobs {
pub async fn open_fetcher(blobs_dir: PathBuf, endpoint: Endpoint) -> 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,
events: None,
scopes: Arc::new(ScopeStore::new(blobs_dir.join("scopes.json"))),
}))
}
pub async fn load(
blobs_dir: PathBuf,
scopes: Arc<ScopeStore>,
gate: Arc<dyn TrustGate>,
endpoint: Endpoint,
audit: AuditSink,
) -> 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 (events, rx) = EventSender::channel(64, APP_BLOB_EVENT_MASK);
spawn_gate_loop(rx, gate, scopes.clone(), audit);
Ok(Arc::new(Self {
store,
endpoint,
events: Some(events),
scopes,
}))
}
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 = BlobTicket::new(self.endpoint.addr(), tag.hash, BlobFormat::Raw);
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?;
self.scopes.publish_hash(scope, &hash_hex)?;
Ok((ticket, hash_hex))
}
pub fn grant(&self, scope: &str, principal: &str) -> Result<()> {
self.scopes.grant(scope, principal)
}
pub fn list(&self) -> Vec<(String, Vec<String>, Vec<String>)> {
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")?;
self.store
.remote()
.fetch(conn, ticket.hash())
.await
.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")
}
}
fn spawn_gate_loop(
mut rx: tokio::sync::mpsc::Receiver<ProviderMessage>,
gate: Arc<dyn TrustGate>,
scopes: Arc<ScopeStore>,
audit: AuditSink,
) {
tokio::spawn(async move {
let mut conns: HashMap<u64, mcpmesh_net::EndpointId> = HashMap::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::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 principals: HashSet<&str> = mcpmesh_local_api::principal_set(
Some(&identity.name),
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()));
audit.record(AuditRecord::blob_fetch(
now_ts(),
peer,
hash_hex,
if allow { "ok".into() } else { "denied".into() },
));
let res = if allow {
Ok(())
} else {
Err(AbortReason::Permission)
};
msg.tx.send(res).await.ok();
}
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::*;
use crate::blobs::APP_BLOB_ALPN;
use crate::blobs::scope::ScopeStore;
use mcpmesh_net::{EndpointId, PeerIdentity, StaticGate};
use std::sync::Arc;
#[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::Intercept);
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(),
)
.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_nickname_grant_admits_and_unlisted_peer_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: [0u8; 32].into(),
name: "carol".into(),
user_id: None, groups: vec![],
},
);
entries.insert(
mallory_id,
PeerIdentity {
endpoint: [0u8; 32].into(),
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(),
)
.await
.unwrap();
provider.spawn_accept(&provider_ep);
let src = pdir.path().join("attach.bin");
std::fs::write(&src, b"nickname-scoped bytes").unwrap();
let (ticket, _hash) = provider
.publish_scope("kb-attach-carol", &src)
.await
.unwrap();
provider.grant("kb-attach-carol", "carol").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 nickname fetches");
assert_eq!(
&carol.read_bytes(hash).await.unwrap()[..],
b"nickname-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(_))),
"an unlisted peer is refused: {res:?}"
);
})
.await
.expect("nickname-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,
)
.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 {
if let Ok(b) = std::fs::read_to_string(&file)
&& b.contains("\"kind\":\"blob_fetch\"")
&& b.contains("\"peer\":\"alice\"")
&& 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");
}
}