diff --git a/Cargo.lock b/Cargo.lock
index f09f6d2..835dba5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,20 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "acto"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a026259da4f1a13b4af60cda453c392de64c58c12d239c560923e0382f42f2b9"
+dependencies = [
+ "parking_lot",
+ "pin-project-lite",
+ "rustc_version",
+ "smol_str",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "aead"
version = "0.5.2"
@@ -1764,6 +1778,7 @@ dependencies = [
"strum",
"stun-rs",
"surge-ping",
+ "swarm-discovery",
"thiserror 2.0.18",
"time",
"tokio",
@@ -3761,6 +3776,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "smol_str"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9"
+
[[package]]
name = "snafu"
version = "0.8.9"
@@ -3949,6 +3970,21 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "swarm-discovery"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3a95032b94c1dc318f55e0b130e3d2176cda022310a65c3df0092764ea69562"
+dependencies = [
+ "acto",
+ "anyhow",
+ "hickory-proto",
+ "rand 0.8.7",
+ "socket2 0.5.10",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "syn"
version = "1.0.109"
@@ -4572,9 +4608,13 @@ name = "varde-daemon"
version = "0.1.0"
dependencies = [
"anyhow",
+ "async-channel",
+ "bytes",
+ "futures-lite",
"iroh",
"iroh-blobs",
"iroh-io",
+ "n0-future",
"proptest",
"rand 0.8.7",
"reflink-copy",
diff --git a/docs/milestone-4.md b/docs/milestone-4.md
new file mode 100644
index 0000000..b672be6
--- /dev/null
+++ b/docs/milestone-4.md
@@ -0,0 +1,55 @@
+# Milestone 4 — LAN discovery, trust, rate limiting, events
+
+## What landed
+
+- **LAN discovery** via iroh's `MdnsDiscovery` (the documented `_iroh`
+ mDNS service — identifiable on the network, not anonymous noise),
+ enabled by the `discovery` config flag (default on). Sightings stream
+ to the daemon, which tracks peer presence and reacts based on trust.
+- **Trust gate.** The provider is now our own `ProtocolHandler`:
+ connections from trusted NodeIds get the full store; everyone else
+ gets a filtered view that answers "not found" for anything outside the
+ openly-served set. Verified by the forged-ticket test: an untrusted
+ peer that knows a private hash gets nothing until trusted.
+- **Openly-served set** = `open_lan` pins + ticket-exported hashes, plus
+ their HashSeq children, kept in memory and recomputed on pin changes
+ and fetch completion.
+- **Auto-sync.** When a trusted peer appears on the LAN, every
+ incomplete pin is queued for download from them (`pin` also fetches
+ immediately if a trusted peer is present — "fetch if absent" is now
+ real). Verified end-to-end over actual mdns between two daemons in the
+ test suite; the test skips gracefully where multicast is unavailable.
+- **Token-bucket rate limiters** wrap the store itself (`ShapedStore`
+ implements the full iroh-blobs `Store` trait): provider data reads pay
+ into the upload bucket, downloader batch writes pay into the download
+ bucket. Upload cap 0 disables serving outright (connections closed at
+ accept). Enforcement is proven by wall-clock in the test suite.
+- **Event stream** is live: `transfer_progress` (both directions),
+ `peer_joined`, `pin_complete`.
+
+## Decisions and trade-offs
+
+- **Ticket export grants standing serve-consent for that hash**
+ (persisted in `meta.json` as `exported`). Handing out a ticket *is*
+ deliberate publication, and without this either every ticket import
+ would require the exporter to pre-trust the importer, or tickets
+ would silently stop working the moment the trust gate exists. This is
+ the documented consent ladder: trusted peers see everything,
+ open_lan/exported content is public, everything else is private.
+- **Trust is evaluated at connection accept.** Revoking (or granting)
+ trust applies to *new* connections; an existing connection keeps its
+ view until it closes. Cheap, predictable, and the window is bounded by
+ QUIC idle timeouts. Request-level re-checks would need a fork of
+ iroh-blobs' provider internals (`ResponseWriter` is not constructible
+ from outside the crate).
+- **Gating by filtered store view** (get → None) means an untrusted
+ peer probing a private hash can't distinguish "refused" from "absent"
+ — deliberately, that's the less chatty posture.
+- **"Connected" for peers means "seen via LAN discovery in the last two
+ minutes"** — presence, not an open QUIC connection. Good enough for
+ `peer list` and the status count; honest about what it measures.
+- **`fetch only from trusted peers`**: auto-sync only ever dials trusted
+ peers. Ticket import dials the ticket's provider — importing the
+ ticket is the explicit consent for that single fetch.
+- Download progress events report per-child offsets for collections;
+ good enough for progress bars, not an exact byte ledger.
diff --git a/varde-daemon/Cargo.toml b/varde-daemon/Cargo.toml
index 4f68842..beed125 100644
--- a/varde-daemon/Cargo.toml
+++ b/varde-daemon/Cargo.toml
@@ -13,9 +13,15 @@ path = "src/main.rs"
[dependencies]
varde-proto = { workspace = true }
-iroh = { workspace = true }
+iroh = { workspace = true, features = ["discovery-local-network"] }
iroh-blobs = { workspace = true }
rand = "0.8"
+# The next three exist to interoperate with iroh-blobs trait signatures
+# and channels; all are already in the dependency tree via iroh.
+n0-future = "0.1"
+async-channel = "2"
+futures-lite = "2"
+bytes = "1"
iroh-io = { workspace = true }
reflink-copy = { workspace = true }
anyhow = { workspace = true }
@@ -30,3 +36,4 @@ tracing-subscriber = { workspace = true }
[dev-dependencies]
tempfile = "3"
proptest = "1"
+tokio = { workspace = true, features = ["test-util"] }
diff --git a/varde-daemon/src/daemon.rs b/varde-daemon/src/daemon.rs
index 805da93..6780f83 100644
--- a/varde-daemon/src/daemon.rs
+++ b/varde-daemon/src/daemon.rs
@@ -1,13 +1,17 @@
//! Request dispatch and daemon state.
+use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;
+use std::sync::{Arc, Mutex, RwLock};
+use std::time::{Duration, Instant};
use anyhow::Context;
use iroh_blobs::{BlobFormat, Hash, HashAndFormat};
use tokio::sync::broadcast;
+use tracing::{debug, info, warn};
use varde_proto::{
- Envelope, ErrorCode, Event, GlobalStatus, HashStatus, PinInfo, Request, ResponseData,
+ Envelope, ErrorCode, Event, GlobalStatus, HashStatus, PeerInfo, PinInfo, Request, ResponseData,
};
use crate::config::Config;
@@ -15,36 +19,97 @@ use crate::meta::{Meta, StoredFormat};
use crate::store::{BlobStore, OpError};
use crate::transfer::Transfer;
-/// Shared daemon state. Handlers grow with the milestones; anything not
-/// yet wired returns [`ErrorCode::Unimplemented`] rather than pretending.
+/// A LAN peer is considered present if discovery has seen it within this
+/// window (mdns re-announces well below this).
+const PEER_PRESENCE_WINDOW: Duration = Duration::from_secs(120);
+
+/// Suppress duplicate PeerJoined events within this window.
+const PEER_REJOIN_WINDOW: Duration = Duration::from_secs(60);
+
+/// Shared daemon state.
pub struct Daemon {
config: Config,
store: BlobStore,
- meta: Meta,
+ meta: Arc,
transfer: Transfer,
events: broadcast::Sender,
+ /// Hashes servable to untrusted peers (open_lan pins, exported
+ /// tickets, and their hashseq children). Read by the provider filter
+ /// on every untrusted request.
+ open_set: Arc>>,
+ /// LAN peers seen via discovery: node id -> last sighting.
+ lan_peers: Mutex>,
}
impl Daemon {
/// Open the blob store and metadata under the configured store dir,
- /// and bring up the iroh endpoint.
- pub async fn open(config: Config) -> anyhow::Result {
+ /// bring up the iroh endpoint, and start the discovery/sync loops.
+ pub async fn open(config: Config) -> anyhow::Result> {
std::fs::create_dir_all(&config.store_dir)
.with_context(|| format!("creating store directory {}", config.store_dir.display()))?;
let store = BlobStore::open(&config.store_dir).await?;
- let meta = Meta::load(&config.store_dir)?;
+ let meta = Arc::new(Meta::load(&config.store_dir)?);
// Capacity bounds memory if a subscriber stalls; laggards get a
// Lagged error, not unbounded buffering.
let (events, _) = broadcast::channel(1024);
- let transfer =
- Transfer::start(&config, &store, store.pool_handle(), events.clone()).await?;
- Ok(Daemon {
+
+ let open_set: Arc>> = Arc::default();
+ let filter_set = open_set.clone();
+ let open_filter: crate::shaped::ServeFilter =
+ Arc::new(move |hash| filter_set.read().expect("open set lock").contains(hash));
+
+ let transfer = Transfer::start(
+ &config,
+ &store,
+ meta.clone(),
+ open_filter,
+ store.pool_handle(),
+ events.clone(),
+ )
+ .await?;
+
+ let daemon = Arc::new(Daemon {
config,
store,
meta,
transfer,
events,
- })
+ open_set,
+ lan_peers: Mutex::new(BTreeMap::new()),
+ });
+ daemon.recompute_open_set().await;
+
+ // Completed fetches can turn hashseq roots expandable; refresh
+ // the open set whenever a pin finishes.
+ let weak = Arc::downgrade(&daemon);
+ let mut pin_events = daemon.events.subscribe();
+ tokio::spawn(async move {
+ loop {
+ match pin_events.recv().await {
+ Ok(Event::PinComplete { .. }) => {
+ let Some(daemon) = weak.upgrade() else { break };
+ daemon.recompute_open_set().await;
+ }
+ Ok(_) => {}
+ Err(broadcast::error::RecvError::Lagged(_)) => {}
+ Err(broadcast::error::RecvError::Closed) => break,
+ }
+ }
+ });
+
+ // Discovery loop: track LAN peer sightings, emit join events for
+ // trusted peers and sync any incomplete pins from them.
+ if let Some(mut discovered) = daemon.transfer.take_discovery() {
+ let weak = Arc::downgrade(&daemon);
+ tokio::spawn(async move {
+ while let Some(node_id) = discovered.recv().await {
+ let Some(daemon) = weak.upgrade() else { break };
+ daemon.on_peer_seen(node_id).await;
+ }
+ });
+ }
+
+ Ok(daemon)
}
/// Flush the store and close the endpoint.
@@ -61,15 +126,103 @@ impl Daemon {
&self.store
}
- pub fn meta(&self) -> &Meta {
- &self.meta
- }
-
/// New receiver for the daemon event stream.
pub fn subscribe_events(&self) -> broadcast::Receiver {
self.events.subscribe()
}
+ /// Rebuild the set of hashes servable to untrusted peers.
+ async fn recompute_open_set(&self) {
+ let mut open = BTreeSet::new();
+ let mut roots: Vec = self
+ .meta
+ .pins()
+ .into_iter()
+ .filter(|(_, record)| record.policy.open_lan)
+ .map(|(hash, _)| hash)
+ .collect();
+ roots.extend(self.meta.exported());
+
+ for hex in roots {
+ let Ok(hash) = Hash::from_str(&hex) else {
+ continue;
+ };
+ open.insert(hash);
+ if self.meta.format_of(&hex) == StoredFormat::HashSeq {
+ match self.store.hashseq_children(hash).await {
+ Ok(children) => open.extend(children),
+ Err(e) => warn!(hash = %hex, error = %e, "expanding open hashseq"),
+ }
+ }
+ }
+ debug!(hashes = open.len(), "open set recomputed");
+ *self.open_set.write().expect("open set lock") = open;
+ }
+
+ /// Handle a discovery sighting of `node_id`.
+ async fn on_peer_seen(&self, node_id: iroh::NodeId) {
+ let id = node_id.to_string();
+ let rejoined = {
+ let mut peers = self.lan_peers.lock().expect("lan peers lock");
+ let now = Instant::now();
+ let fresh = match peers.get(&id) {
+ Some(last) => now.duration_since(*last) > PEER_REJOIN_WINDOW,
+ None => true,
+ };
+ peers.insert(id.clone(), now);
+ fresh
+ };
+ if !self.meta.is_trusted(&id) {
+ return;
+ }
+ if rejoined {
+ info!(peer = %id, "trusted peer present on LAN");
+ let _ = self.events.send(Event::PeerJoined { node_id: id });
+ }
+ self.sync_pins_from(node_id).await;
+ }
+
+ /// Queue fetches for every incomplete pin from a trusted LAN peer.
+ /// The downloader dedupes by content, so repeated sightings are
+ /// cheap; discovery resolves the peer's addresses.
+ async fn sync_pins_from(&self, node_id: iroh::NodeId) {
+ for (hex, _record) in self.meta.pins() {
+ let Ok(hash) = Hash::from_str(&hex) else {
+ continue;
+ };
+ match self.store.presence(hash).await {
+ Ok((_, _, true)) => {} // complete, nothing to do
+ Ok(_) => {
+ debug!(hash = %hex, peer = %node_id, "syncing incomplete pin");
+ self.transfer.spawn_fetch(
+ HashAndFormat {
+ hash,
+ format: blob_format(self.meta.format_of(&hex)),
+ },
+ vec![iroh::NodeAddr::new(node_id)],
+ );
+ }
+ Err(e) => warn!(hash = %hex, error = %e, "checking pin presence"),
+ }
+ }
+ }
+
+ /// Trusted peers seen on the LAN recently.
+ fn present_trusted_peers(&self) -> Vec {
+ let peers = self.lan_peers.lock().expect("lan peers lock");
+ let now = Instant::now();
+ self.meta
+ .trusted_peers()
+ .into_iter()
+ .map(|node_id| {
+ let connected = peers
+ .get(&node_id)
+ .is_some_and(|last| now.duration_since(*last) < PEER_PRESENCE_WINDOW);
+ PeerInfo { node_id, connected }
+ })
+ .collect()
+ }
+
/// Handle one API request. Infallible at this level: all failures are
/// mapped to structured error envelopes.
pub async fn handle(&self, request: Request) -> Envelope {
@@ -93,8 +246,26 @@ impl Daemon {
Request::Pin { hash, policy } => {
let parsed = parse_hash(&hash)?;
self.meta.pin(&parsed.to_hex(), policy)?;
- // Fetching absent pinned content arrives with the
- // transfer milestone.
+ self.recompute_open_set().await;
+ // Fetch if absent: try every trusted peer currently
+ // present on the LAN.
+ if let Ok((_, _, false)) = self.store.presence(parsed).await {
+ let present: Vec = self
+ .present_trusted_peers()
+ .into_iter()
+ .filter(|p| p.connected)
+ .filter_map(|p| p.node_id.parse().ok().map(iroh::NodeAddr::new))
+ .collect();
+ if !present.is_empty() {
+ self.transfer.spawn_fetch(
+ HashAndFormat {
+ hash: parsed,
+ format: blob_format(self.meta.format_of(&parsed.to_hex())),
+ },
+ present,
+ );
+ }
+ }
Ok(ResponseData::Pinned {
hash: parsed.to_hex().to_string(),
})
@@ -104,6 +275,7 @@ impl Daemon {
if !self.meta.unpin(&parsed.to_hex())? {
return Err(OpError::NotFound(format!("no pin for {}", parsed.to_hex())));
}
+ self.recompute_open_set().await;
Ok(ResponseData::Done {})
}
Request::Materialize { hash, dest, mode } => {
@@ -115,14 +287,21 @@ impl Daemon {
.await?;
Ok(ResponseData::Materialized { bytes, reflinked })
}
- Request::Status { hash: None } => Ok(ResponseData::Status(GlobalStatus {
- version: env!("CARGO_PKG_VERSION").to_string(),
- protocol: varde_proto::PROTOCOL_VERSION,
- node_id: Some(self.transfer.node_id()),
- store_path: self.config.store_dir.display().to_string(),
- pins: self.meta.pins().len() as u64,
- connected_peers: 0,
- })),
+ Request::Status { hash: None } => {
+ let connected = self
+ .present_trusted_peers()
+ .iter()
+ .filter(|p| p.connected)
+ .count() as u64;
+ Ok(ResponseData::Status(GlobalStatus {
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ protocol: varde_proto::PROTOCOL_VERSION,
+ node_id: Some(self.transfer.node_id()),
+ store_path: self.config.store_dir.display().to_string(),
+ pins: self.meta.pins().len() as u64,
+ connected_peers: connected,
+ }))
+ }
Request::Status { hash: Some(hash) } => {
let parsed = parse_hash(&hash)?;
let (have_bytes, total_bytes, complete) = self.store.presence(parsed).await?;
@@ -169,6 +348,10 @@ impl Daemon {
format: blob_format(self.meta.format_of(&parsed.to_hex())),
};
let ticket = self.transfer.export_ticket(content).await?;
+ // Exporting a ticket is deliberate publication: the
+ // holder must be able to fetch without being trusted.
+ self.meta.record_exported(&parsed.to_hex())?;
+ self.recompute_open_set().await;
Ok(ResponseData::Ticket { ticket })
}
Request::TicketImport { ticket, pin_policy } => {
@@ -183,16 +366,44 @@ impl Daemon {
// data alive, and it is the standing intent even if the
// provider is unreachable right now.
self.meta.pin(&hash.to_hex(), pin_policy)?;
+ self.recompute_open_set().await;
self.transfer
.spawn_fetch(HashAndFormat { hash, format }, vec![provider]);
Ok(ResponseData::Pinned {
hash: hash.to_hex().to_string(),
})
}
+ Request::PeerTrust { node_id } => {
+ let parsed: iroh::NodeId = node_id
+ .parse()
+ .map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?;
+ self.meta.trust_peer(&parsed.to_string())?;
+ info!(peer = %parsed, "peer trusted");
+ // If the peer is already visible on the LAN, sync now.
+ let present = {
+ let peers = self.lan_peers.lock().expect("lan peers lock");
+ peers.get(&parsed.to_string()).is_some_and(|last| {
+ Instant::now().duration_since(*last) < PEER_PRESENCE_WINDOW
+ })
+ };
+ if present {
+ self.sync_pins_from(parsed).await;
+ }
+ Ok(ResponseData::Done {})
+ }
+ Request::PeerUntrust { node_id } => {
+ let parsed: iroh::NodeId = node_id
+ .parse()
+ .map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?;
+ if !self.meta.untrust_peer(&parsed.to_string())? {
+ return Err(OpError::NotFound(format!("{parsed} was not trusted")));
+ }
+ Ok(ResponseData::Done {})
+ }
+ Request::PeerList {} => Ok(ResponseData::Peers {
+ peers: self.present_trusted_peers(),
+ }),
Request::Subscribe {} => Ok(ResponseData::Done {}),
- other => Err(OpError::Unimplemented(format!(
- "not implemented yet: {other:?}"
- ))),
}
}
diff --git a/varde-daemon/src/lib.rs b/varde-daemon/src/lib.rs
index 4e1f577..5a80090 100644
--- a/varde-daemon/src/lib.rs
+++ b/varde-daemon/src/lib.rs
@@ -7,11 +7,10 @@ pub mod config;
pub mod daemon;
pub mod meta;
pub mod server;
+pub mod shaped;
pub mod store;
pub mod transfer;
-use std::sync::Arc;
-
use anyhow::Result;
/// Run a daemon with the given config until the future is dropped or the
@@ -19,7 +18,7 @@ use anyhow::Result;
/// once this future has been polled the socket path exists.
pub async fn run(config: config::Config) -> Result<()> {
let socket_path = config.socket_path.clone();
- let daemon = Arc::new(daemon::Daemon::open(config).await?);
+ let daemon = daemon::Daemon::open(config).await?;
let listener = server::bind_socket(&socket_path)?;
server::serve(listener, daemon).await
}
diff --git a/varde-daemon/src/main.rs b/varde-daemon/src/main.rs
index f0b2e2f..c0275e3 100644
--- a/varde-daemon/src/main.rs
+++ b/varde-daemon/src/main.rs
@@ -1,7 +1,6 @@
//! varde-daemon: content-addressed blob mirror daemon.
use std::path::PathBuf;
-use std::sync::Arc;
use anyhow::{bail, Context, Result};
use tracing::info;
@@ -82,7 +81,7 @@ fn main() -> Result<()> {
async fn run(config: Config) -> Result<()> {
let socket_path = config.socket_path.clone();
- let daemon = Arc::new(daemon::Daemon::open(config).await?);
+ let daemon = daemon::Daemon::open(config).await?;
let listener = server::bind_socket(&socket_path)?;
let result = tokio::select! {
diff --git a/varde-daemon/src/meta.rs b/varde-daemon/src/meta.rs
index 9f55189..a857b22 100644
--- a/varde-daemon/src/meta.rs
+++ b/varde-daemon/src/meta.rs
@@ -40,6 +40,10 @@ struct MetaState {
/// Trusted peer NodeIds (z-base-32).
#[serde(default)]
trusted_peers: BTreeSet,
+ /// Hashes (hex) the user shared via ticket export: standing consent
+ /// to serve them to anyone, like an `open_lan` pin.
+ #[serde(default)]
+ exported: BTreeSet,
}
/// Handle to the metadata file. Cheap to share behind an `Arc`.
@@ -125,6 +129,23 @@ impl Meta {
.unwrap_or(StoredFormat::Raw)
}
+ /// Record standing consent to serve `hash` to anyone (set by ticket
+ /// export — sharing a ticket is deliberate publication).
+ pub fn record_exported(&self, hash: &str) -> Result<()> {
+ self.mutate(|s| {
+ s.exported.insert(hash.to_string());
+ })
+ }
+
+ /// Hashes with standing serve-to-anyone consent.
+ pub fn exported(&self) -> BTreeSet {
+ self.state
+ .lock()
+ .expect("meta lock poisoned")
+ .exported
+ .clone()
+ }
+
/// Add a trusted peer. Returns false if it was already trusted.
pub fn trust_peer(&self, node_id: &str) -> Result {
self.mutate(|s| s.trusted_peers.insert(node_id.to_string()))
diff --git a/varde-daemon/src/shaped.rs b/varde-daemon/src/shaped.rs
new file mode 100644
index 0000000..4fa4f15
--- /dev/null
+++ b/varde-daemon/src/shaped.rs
@@ -0,0 +1,438 @@
+//! Traffic shaping and per-hash gating for the blob store.
+//!
+//! [`ShapedStore`] wraps the iroh-blobs fs store and implements the full
+//! `Store` trait, adding two token buckets: reads of entry data (the
+//! bytes a provider sends to peers) draw from the upload bucket, batch
+//! writes of downloaded data draw from the download bucket. An optional
+//! filter hides hashes from `get`, which is how untrusted peers are
+//! limited to openly-served content — the provider simply answers "not
+//! found" for everything else.
+
+use std::future::Future;
+use std::io;
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use iroh_blobs::store::bao_tree::io::fsm::{BaoContentItem, Outboard};
+use iroh_blobs::store::fs::Store as FsStore;
+use iroh_blobs::store::{
+ BaoBatchWriter, ConsistencyCheckProgress, DbIter, EntryStatus, ExportMode, ExportProgressCb,
+ ImportMode, ImportProgress, Map, MapEntry, MapEntryMut, MapMut, ReadableStore, Store,
+};
+use iroh_blobs::util::progress::{BoxedProgressSender, IdGenerator, ProgressSender};
+use iroh_blobs::util::Tag;
+use iroh_blobs::{BlobFormat, Hash, HashAndFormat, TempTag};
+use iroh_io::AsyncSliceReader;
+
+/// An async token bucket. Rate 0 means unlimited.
+#[derive(Debug)]
+pub struct TokenBucket {
+ rate: u64,
+ burst: f64,
+ state: std::sync::Mutex,
+}
+
+#[derive(Debug)]
+struct BucketState {
+ tokens: f64,
+ last_refill: Instant,
+}
+
+impl TokenBucket {
+ pub fn new(rate_bytes_per_sec: u64) -> Arc {
+ // Burst of one second's worth, floored so tiny rates still make
+ // progress chunk by chunk.
+ let burst = (rate_bytes_per_sec as f64).max(64.0 * 1024.0);
+ Arc::new(TokenBucket {
+ rate: rate_bytes_per_sec,
+ burst,
+ state: std::sync::Mutex::new(BucketState {
+ tokens: burst,
+ last_refill: Instant::now(),
+ }),
+ })
+ }
+
+ /// Take `n` tokens, sleeping until the bucket has refilled enough.
+ /// Debt-based: the acquire always succeeds immediately in bookkeeping
+ /// terms, and the caller sleeps off any deficit — this keeps large
+ /// writes simple while converging on the configured rate.
+ pub async fn acquire(&self, n: usize) {
+ if self.rate == 0 {
+ return;
+ }
+ let wait = {
+ let mut state = self.state.lock().expect("bucket lock poisoned");
+ let now = Instant::now();
+ let elapsed = now.duration_since(state.last_refill).as_secs_f64();
+ state.last_refill = now;
+ state.tokens = (state.tokens + elapsed * self.rate as f64).min(self.burst);
+ state.tokens -= n as f64;
+ if state.tokens >= 0.0 {
+ None
+ } else {
+ Some(Duration::from_secs_f64(-state.tokens / self.rate as f64))
+ }
+ };
+ if let Some(wait) = wait {
+ tokio::time::sleep(wait).await;
+ }
+ }
+}
+
+/// Predicate deciding whether a hash may be served on this store view.
+pub type ServeFilter = Arc bool + Send + Sync>;
+
+/// The fs store wrapped with rate limiting and an optional serve filter.
+#[derive(Clone)]
+pub struct ShapedStore {
+ inner: FsStore,
+ upload: Arc,
+ download: Arc,
+ filter: Option,
+}
+
+impl std::fmt::Debug for ShapedStore {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("ShapedStore")
+ .field("inner", &self.inner)
+ .field("upload", &self.upload)
+ .field("download", &self.download)
+ .field("filtered", &self.filter.is_some())
+ .finish()
+ }
+}
+
+impl ShapedStore {
+ pub fn new(
+ inner: FsStore,
+ upload: Arc,
+ download: Arc,
+ ) -> ShapedStore {
+ ShapedStore {
+ inner,
+ upload,
+ download,
+ filter: None,
+ }
+ }
+
+ /// A view of the same store that only serves hashes passing `filter`.
+ pub fn filtered(&self, filter: ServeFilter) -> ShapedStore {
+ ShapedStore {
+ inner: self.inner.clone(),
+ upload: self.upload.clone(),
+ download: self.download.clone(),
+ filter: Some(filter),
+ }
+ }
+}
+
+/// An entry whose data reads draw from the upload bucket.
+#[derive(Debug, Clone)]
+pub struct ShapedEntry {
+ inner: ::Entry,
+ upload: Arc,
+}
+
+impl MapEntry for ShapedEntry {
+ fn hash(&self) -> Hash {
+ self.inner.hash()
+ }
+
+ fn size(&self) -> iroh_blobs::store::BaoBlobSize {
+ self.inner.size()
+ }
+
+ fn is_complete(&self) -> bool {
+ self.inner.is_complete()
+ }
+
+ async fn outboard(&self) -> io::Result {
+ // The fs entry's inherent (synchronous) methods shadow the trait
+ // methods here and below.
+ self.inner.outboard()
+ }
+
+ async fn data_reader(&self) -> io::Result {
+ let inner = self.inner.data_reader();
+ Ok(ThrottledReader {
+ inner,
+ bucket: self.upload.clone(),
+ })
+ }
+}
+
+/// AsyncSliceReader that pays for bytes read from the upload bucket.
+#[derive(Debug)]
+struct ThrottledReader {
+ inner: R,
+ bucket: Arc,
+}
+
+impl AsyncSliceReader for ThrottledReader {
+ async fn read_at(&mut self, offset: u64, len: usize) -> io::Result {
+ let bytes = self.inner.read_at(offset, len).await?;
+ self.bucket.acquire(bytes.len()).await;
+ Ok(bytes)
+ }
+
+ async fn size(&mut self) -> io::Result {
+ self.inner.size().await
+ }
+}
+
+impl Map for ShapedStore {
+ type Entry = ShapedEntry;
+
+ async fn get(&self, hash: &Hash) -> io::Result