use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use decdn_cache::{
CacheEngine, CacheError, CacheMetrics, DecompressMode, FilesystemOrigin, Hash, HttpOrigin,
Origin, OriginError, OriginFetch, OriginKind, OriginPullError, PinnedHashes, RetryPolicy,
SupportedEncoding,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn serve_blob(payload: &'static [u8]) -> (MockServer, Hash) {
let server = MockServer::start().await;
let hash = Hash::new(payload);
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&server)
.await;
(server, hash)
}
async fn build_engine(origin_url: &str) -> anyhow::Result<(CacheEngine, tempfile::TempDir)> {
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(origin_url)?);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
Ok((engine, tmp))
}
async fn build_engine_no_retry(
origin_url: &str,
) -> anyhow::Result<(CacheEngine, tempfile::TempDir)> {
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(origin_url)?);
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
decdn_cache::PinnedHashes::empty(),
RetryPolicy::disabled(),
decdn_cache::CircuitBreakerPolicy::default(),
None,
Duration::ZERO,
)
.await?;
Ok((engine, tmp))
}
fn err_of<T: std::fmt::Debug, E>(r: Result<T, E>) -> anyhow::Result<E> {
r.err()
.ok_or_else(|| anyhow::anyhow!("expected error, got Ok"))
}
#[tokio::test]
async fn cache_miss_pulls_from_origin_and_caches() -> anyhow::Result<()> {
let payload: &[u8] = b"hello, decdn";
let (server, hash) = serve_blob(payload).await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
anyhow::ensure!(!engine.has(hash).await?, "blob should be absent initially");
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload, "first get should return origin bytes");
anyhow::ensure!(engine.has(hash).await?, "blob should be cached after miss");
drop(server);
let got2 = engine.get(hash).await?;
anyhow::ensure!(&got2[..] == payload, "second get should be served locally");
Ok(())
}
#[tokio::test]
async fn hash_mismatch_is_rejected_and_not_cached() -> anyhow::Result<()> {
let server = MockServer::start().await;
let expected = Hash::new(b"expected");
let mismatched_payload: &[u8] = b"something else entirely";
Mock::given(method("GET"))
.and(path(format!("/{}", expected.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mismatched_payload))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let err = err_of(engine.get(expected).await)?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch, got: {err:?}"
);
anyhow::ensure!(
!engine.has(expected).await?,
"expected hash must not be cached"
);
Ok(())
}
#[tokio::test]
async fn streaming_mismatch_does_not_censor_actual_hash() -> anyhow::Result<()> {
let server = MockServer::start().await;
let victim: &[u8] = b"a legitimate blob the attacker also holds";
let actual = Hash::new(victim);
let expected = Hash::new(b"what the client actually asked for");
Mock::given(method("GET"))
.and(path(format!("/{}", expected.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(victim))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/{}", actual.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(victim))
.mount(&server)
.await;
let (engine, _tmp) = build_engine_no_retry(&server.uri()).await?;
let err = err_of(engine.get(expected).await)?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch, got: {err:?}"
);
anyhow::ensure!(
!engine.inspect(actual).await?.already_evicted,
"actual must NOT be logically evicted on hash mismatch (#853 censorship vector)"
);
let got = engine.get(actual).await?;
anyhow::ensure!(&got[..] == victim, "actual must serve its real bytes");
Ok(())
}
#[tokio::test]
async fn drain_mismatch_tag_deleted_lets_gc_reclaim() -> anyhow::Result<()> {
let server = MockServer::start().await;
let expected = Hash::new(b"expected-drain");
let mismatched: &[u8] = b"a small body that fits the drain buffer but hashes wrong";
let actual = Hash::new(mismatched);
Mock::given(method("GET"))
.and(path(format!("/{}", expected.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mismatched))
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let metrics = Arc::new(CacheMetrics::default());
let gc_interval = Duration::from_millis(200);
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
PinnedHashes::empty(),
RetryPolicy::default(),
decdn_cache::CircuitBreakerPolicy::default(),
Some(Arc::clone(&metrics)),
gc_interval,
)
.await?;
let err = err_of(engine.get(expected).await)?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch, got: {err:?}"
);
let pre = engine.inspect(actual).await?;
anyhow::ensure!(
pre.size_bytes.is_some(),
"drain-committed bytes must be on disk before GC runs"
);
anyhow::ensure!(
!pre.already_evicted,
"wrong-hash bytes must NOT be logically evicted (#853)"
);
let deadline = std::time::Instant::now() + gc_interval * 16;
while std::time::Instant::now() < deadline {
tokio::time::sleep(gc_interval).await;
if engine.inspect(actual).await?.size_bytes.is_none()
&& metrics.gc_bytes_reclaimed.get() > 0
{
break;
}
}
anyhow::ensure!(
engine.inspect(actual).await?.size_bytes.is_none(),
"drain-path wrong-hash bytes must be GC-reclaimed once the named tag is deleted (#837)"
);
anyhow::ensure!(
metrics.gc_bytes_reclaimed.get() > 0,
"gc_bytes_reclaimed must be nonzero after reclaim; got {}",
metrics.gc_bytes_reclaimed.get()
);
Ok(())
}
#[tokio::test]
async fn evict_makes_blob_gc_eligible() -> anyhow::Result<()> {
let payload: &[u8] = b"content to evict and reclaim";
let (server, hash) = serve_blob(payload).await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let metrics = Arc::new(CacheMetrics::default());
let gc_interval = Duration::from_millis(200);
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
PinnedHashes::empty(),
RetryPolicy::default(),
decdn_cache::CircuitBreakerPolicy::default(),
Some(Arc::clone(&metrics)),
gc_interval,
)
.await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload, "blob should be served from origin");
anyhow::ensure!(
engine.inspect(hash).await?.size_bytes.is_some(),
"blob must be on disk after caching"
);
engine.evict(hash).await?;
anyhow::ensure!(
engine.inspect(hash).await?.already_evicted,
"evict must logically stop serving"
);
let deadline = std::time::Instant::now() + gc_interval * 16;
while std::time::Instant::now() < deadline {
tokio::time::sleep(gc_interval).await;
if engine.inspect(hash).await?.size_bytes.is_none() && metrics.gc_bytes_reclaimed.get() > 0
{
break;
}
}
anyhow::ensure!(
engine.inspect(hash).await?.size_bytes.is_none(),
"evicted blob's bytes must be GC-reclaimed once its tag is deleted (#860)"
);
anyhow::ensure!(
metrics.gc_bytes_reclaimed.get() > 0,
"gc_bytes_reclaimed must be nonzero after reclaiming the evicted blob; got {}",
metrics.gc_bytes_reclaimed.get()
);
Ok(())
}
#[tokio::test]
async fn evict_makes_streaming_cached_blob_gc_eligible() -> anyhow::Result<()> {
let payload: &[u8] = b"streaming-cached content to evict and reclaim";
let (server, hash) = serve_blob(payload).await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let metrics = Arc::new(CacheMetrics::default());
let gc_interval = Duration::from_millis(200);
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
PinnedHashes::empty(),
RetryPolicy::disabled(),
decdn_cache::CircuitBreakerPolicy::default(),
Some(Arc::clone(&metrics)),
gc_interval,
)
.await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload, "blob should be served from origin");
anyhow::ensure!(
engine.inspect(hash).await?.size_bytes.is_some(),
"blob must be on disk after caching via the streaming path"
);
engine.evict(hash).await?;
let deadline = std::time::Instant::now() + gc_interval * 16;
while std::time::Instant::now() < deadline {
tokio::time::sleep(gc_interval).await;
if engine.inspect(hash).await?.size_bytes.is_none() && metrics.gc_bytes_reclaimed.get() > 0
{
break;
}
}
anyhow::ensure!(
engine.inspect(hash).await?.size_bytes.is_none(),
"streaming-cached evicted blob must be GC-reclaimed once its tag is deleted (#860)"
);
anyhow::ensure!(
metrics.gc_bytes_reclaimed.get() > 0,
"gc_bytes_reclaimed must be nonzero after reclaim; got {}",
metrics.gc_bytes_reclaimed.get()
);
Ok(())
}
#[tokio::test]
async fn gc_reclaims_partial_import_bytes() -> anyhow::Result<()> {
let server = MockServer::start().await;
let expected = Hash::new(b"expected");
let mismatched_payload: &[u8] = b"a body the origin pretends matches the requested hash";
let actual = Hash::new(mismatched_payload);
Mock::given(method("GET"))
.and(path(format!("/{}", expected.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mismatched_payload))
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let metrics = Arc::new(CacheMetrics::default());
let gc_interval = Duration::from_millis(200);
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
PinnedHashes::empty(),
RetryPolicy::disabled(),
decdn_cache::CircuitBreakerPolicy::default(),
Some(Arc::clone(&metrics)),
gc_interval,
)
.await?;
let err = err_of(engine.get(expected).await)?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch, got: {err:?}"
);
let pre = engine.inspect(actual).await?;
anyhow::ensure!(
pre.size_bytes.is_some(),
"actual-hash bytes must be on disk before GC runs (got size_bytes = None)"
);
anyhow::ensure!(
!pre.already_evicted,
"wrong-hash bytes must NOT be logically evicted on mismatch (#853 censorship vector)"
);
let deadline = std::time::Instant::now() + gc_interval * 16;
while std::time::Instant::now() < deadline {
tokio::time::sleep(gc_interval).await;
let post = engine.inspect(actual).await?;
if post.size_bytes.is_none() && metrics.gc_bytes_reclaimed.get() > 0 {
break;
}
}
let post = engine.inspect(actual).await?;
anyhow::ensure!(
post.size_bytes.is_none(),
"actual-hash bytes must be reclaimed by iroh-blobs GC; got size_bytes = {:?}",
post.size_bytes
);
anyhow::ensure!(
metrics.gc_runs.get() >= 1,
"gc_runs_total should be >=1 after the periodic loop has fired; got {}",
metrics.gc_runs.get()
);
anyhow::ensure!(
metrics.gc_bytes_reclaimed.get() >= mismatched_payload.len() as u64,
"gc_bytes_reclaimed_total should cover at least the mismatched payload \
({} bytes); got {}",
mismatched_payload.len(),
metrics.gc_bytes_reclaimed.get()
);
Ok(())
}
#[tokio::test]
async fn gc_attribution_lags_one_cycle() -> anyhow::Result<()> {
let server = MockServer::start().await;
let expected = Hash::new(b"expected-lag");
let mismatched_payload: &[u8] = b"a body whose blake3 disagrees with the requested hash";
Mock::given(method("GET"))
.and(path(format!("/{}", expected.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mismatched_payload))
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let metrics = Arc::new(CacheMetrics::default());
let gc_interval = Duration::from_millis(500);
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
PinnedHashes::empty(),
RetryPolicy::disabled(),
decdn_cache::CircuitBreakerPolicy::default(),
Some(Arc::clone(&metrics)),
gc_interval,
)
.await?;
let err = err_of(engine.get(expected).await)?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch, got: {err:?}"
);
let cycle1_deadline = std::time::Instant::now() + gc_interval * 4;
while metrics.gc_runs.get() < 1 {
anyhow::ensure!(
std::time::Instant::now() < cycle1_deadline,
"cycle 1 did not fire within {}ms",
(gc_interval * 4).as_millis()
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
anyhow::ensure!(
metrics.gc_bytes_reclaimed.get() == 0,
"after cycle 1, gc_bytes_reclaimed_total must be 0 (no prior baseline to diff against); got {}",
metrics.gc_bytes_reclaimed.get()
);
let cycle2_deadline = std::time::Instant::now() + gc_interval * 4;
while metrics.gc_runs.get() < 2 {
anyhow::ensure!(
std::time::Instant::now() < cycle2_deadline,
"cycle 2 did not fire within {}ms after cycle 1",
(gc_interval * 4).as_millis()
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
anyhow::ensure!(
metrics.gc_bytes_reclaimed.get() > 0,
"cycle 2 must attribute cycle-1's reclaim; got 0 bytes after 2 cycles"
);
Ok(())
}
#[tokio::test]
async fn gc_reclaims_cap_breach_partial_bytes() -> anyhow::Result<()> {
let payload_bytes = 2 * 1024 * 1024;
let addr = spawn_chunked_oversize_server(payload_bytes).await?;
let origin_url = format!("http://{addr}/");
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&origin_url)?);
let metrics = Arc::new(CacheMetrics::default());
let gc_interval = Duration::from_millis(200);
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
1, PinnedHashes::empty(),
RetryPolicy::disabled(),
decdn_cache::CircuitBreakerPolicy::default(),
Some(Arc::clone(&metrics)),
gc_interval,
)
.await?;
let err = err_of(engine.get(Hash::new(b"doesn't matter")).await)?;
anyhow::ensure!(
matches!(err, CacheError::BlobTooLarge { .. }),
"expected BlobTooLarge from mid-stream cap, got: {err:?}"
);
let deadline = std::time::Instant::now() + gc_interval * 16;
while std::time::Instant::now() < deadline {
tokio::time::sleep(gc_interval).await;
if metrics.gc_bytes_reclaimed.get() > 0 {
break;
}
}
anyhow::ensure!(
metrics.gc_runs.get() >= 1,
"gc_runs_total should be >=1 after the periodic loop has fired; got {}",
metrics.gc_runs.get()
);
anyhow::ensure!(
metrics.gc_bytes_reclaimed.get() > 0,
"gc_bytes_reclaimed_total should be nonzero after cap-breach + GC; got {}",
metrics.gc_bytes_reclaimed.get()
);
Ok(())
}
#[tokio::test]
async fn gc_disabled_does_not_reclaim_or_emit_metrics() -> anyhow::Result<()> {
let server = MockServer::start().await;
let expected = Hash::new(b"expected-disabled");
let mismatched_payload: &[u8] = b"a body with a different blake3 than the requested hash";
let actual = Hash::new(mismatched_payload);
Mock::given(method("GET"))
.and(path(format!("/{}", expected.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mismatched_payload))
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let metrics = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
PinnedHashes::empty(),
RetryPolicy::disabled(),
decdn_cache::CircuitBreakerPolicy::default(),
Some(Arc::clone(&metrics)),
Duration::ZERO, )
.await?;
let err = err_of(engine.get(expected).await)?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch, got: {err:?}"
);
tokio::time::sleep(Duration::from_millis(800)).await;
let post = engine.inspect(actual).await?;
anyhow::ensure!(
post.size_bytes.is_some(),
"actual-hash bytes must remain on disk when GC is disabled; got size_bytes = None"
);
anyhow::ensure!(
metrics.gc_runs.get() == 0,
"gc_runs_total must stay at 0 when GC is disabled; got {}",
metrics.gc_runs.get()
);
anyhow::ensure!(
metrics.gc_bytes_reclaimed.get() == 0,
"gc_bytes_reclaimed_total must stay at 0 when GC is disabled; got {}",
metrics.gc_bytes_reclaimed.get()
);
Ok(())
}
#[tokio::test]
async fn origin_not_found_surfaces_not_found() -> anyhow::Result<()> {
let server = MockServer::start().await;
let hash = Hash::new(b"absent");
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::NotFound { .. }),
"expected NotFound, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn blob_too_large_is_rejected_via_http_origin() -> anyhow::Result<()> {
let payload = vec![0xABu8; 2 * 1024 * 1024];
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 1).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError (fast-path content-length rejection), got: {err:?}"
);
Ok(())
}
#[derive(Debug)]
struct OversizedOrigin {
payload: bytes::Bytes,
}
impl Origin for OversizedOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, decdn_cache::OriginPullError>> + Send + '_>>
{
let payload = self.payload.clone();
Box::pin(async move { Ok(OriginFetch::found_one_shot(payload)) })
}
}
#[tokio::test]
async fn engine_rejects_oversize_bytes_from_misbehaving_origin() -> anyhow::Result<()> {
let payload = bytes::Bytes::from(vec![0xCDu8; 2 * 1024 * 1024]);
let hash = Hash::new(&payload);
let origin = Arc::new(OversizedOrigin { payload });
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 1).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::BlobTooLarge { .. }),
"expected BlobTooLarge from engine-level check, got: {err:?}"
);
anyhow::ensure!(
!engine.has(hash).await?,
"oversize bytes must not be cached"
);
Ok(())
}
#[derive(Debug)]
struct PeerOrigin {
payload: bytes::Bytes,
fetches: Arc<std::sync::atomic::AtomicUsize>,
}
impl Origin for PeerOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Peer
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
self.fetches
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let payload = self.payload.clone();
Box::pin(async move { Ok(OriginFetch::found_one_shot(payload)) })
}
}
#[tokio::test]
async fn populate_local_skips_peer_origin_and_fills_from_local() -> anyhow::Result<()> {
use std::sync::atomic::{AtomicUsize, Ordering};
let payload: &[u8] = b"served from the operator's own origin";
let hash = Hash::new(payload);
let peer_fetches = Arc::new(AtomicUsize::new(0));
let origin_dir = tempfile::tempdir()?;
seed_fs_blob(origin_dir.path(), hash, payload)?;
let cache_dir = tempfile::tempdir()?;
let peer = Arc::new(PeerOrigin {
payload: bytes::Bytes::from(payload.to_vec()),
fetches: Arc::clone(&peer_fetches),
});
let fs = Arc::new(FilesystemOrigin::new(origin_dir.path()).await?);
let engine = CacheEngine::open(
cache_dir.path(),
vec![peer as Arc<dyn Origin>, fs as Arc<dyn Origin>],
16,
)
.await?;
engine.populate_local(hash).await?;
anyhow::ensure!(
engine.has(hash).await?,
"local populate must fill the store"
);
anyhow::ensure!(
peer_fetches.load(Ordering::SeqCst) == 0,
"populate_local must NOT consult the Peer origin, got {} fetches",
peer_fetches.load(Ordering::SeqCst)
);
Ok(())
}
#[tokio::test]
async fn populate_local_with_only_peer_origin_is_no_origin() -> anyhow::Result<()> {
use std::sync::atomic::{AtomicUsize, Ordering};
let payload: &[u8] = b"only reachable via a paid peer";
let hash = Hash::new(payload);
let peer_fetches = Arc::new(AtomicUsize::new(0));
let cache_dir = tempfile::tempdir()?;
let peer = Arc::new(PeerOrigin {
payload: bytes::Bytes::from(payload.to_vec()),
fetches: Arc::clone(&peer_fetches),
});
let engine = CacheEngine::open(cache_dir.path(), vec![peer as Arc<dyn Origin>], 16).await?;
let err = err_of(engine.populate_local(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::NoOrigin { .. }),
"populate_local over a peer-only chain must be NoOrigin, got: {err:?}"
);
anyhow::ensure!(
peer_fetches.load(Ordering::SeqCst) == 0,
"peer-only populate_local must not consult the peer"
);
engine.populate(hash).await?;
anyhow::ensure!(
engine.has(hash).await?,
"full populate should fill via the peer"
);
anyhow::ensure!(
peer_fetches.load(Ordering::SeqCst) == 1,
"full populate should consult the peer exactly once"
);
Ok(())
}
#[tokio::test]
async fn shutdown_flushes_without_drop() -> anyhow::Result<()> {
let payload: &[u8] = b"persisted via shutdown, not drop";
let (server, hash) = serve_blob(payload).await;
let tmp = tempfile::tempdir()?;
{
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let _ = engine.get(hash).await?;
engine.shutdown().await?;
std::mem::forget(engine);
}
drop(server);
let engine = CacheEngine::open(tmp.path(), Vec::new(), 16).await?;
anyhow::ensure!(
engine.has(hash).await?,
"reopened engine should see the blob flushed by shutdown()"
);
let got = engine.get(hash).await?;
anyhow::ensure!(
&got[..] == payload,
"reopened engine should serve the cached payload without an origin"
);
Ok(())
}
async fn spawn_chunked_oversize_server(
payload_bytes: usize,
) -> anyhow::Result<std::net::SocketAddr> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let mut buf = [0u8; 4096];
loop {
match sock.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
let seen = buf.get(..n).unwrap_or(&[]);
if seen.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
}
}
let header = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: application/octet-stream\r\n\r\n";
if sock.write_all(header).await.is_err() {
return;
}
let chunk_size = 64 * 1024;
let pad = vec![0xEEu8; chunk_size];
let mut remaining = payload_bytes;
while remaining > 0 {
let send = remaining.min(chunk_size);
let hdr = format!("{send:x}\r\n");
if sock.write_all(hdr.as_bytes()).await.is_err() {
return;
}
let chunk = pad.get(..send).unwrap_or(&[]);
if sock.write_all(chunk).await.is_err() {
return;
}
if sock.write_all(b"\r\n").await.is_err() {
return;
}
remaining -= send;
}
let _ = sock.write_all(b"0\r\n\r\n").await;
});
Ok(addr)
}
async fn spawn_silent_server() -> anyhow::Result<std::net::SocketAddr> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
if let Ok((sock, _)) = listener.accept().await {
let _sock = sock;
std::future::pending::<()>().await;
}
});
Ok(addr)
}
async fn spawn_stall_after_partial_body_server() -> anyhow::Result<std::net::SocketAddr> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let mut buf = [0u8; 4096];
loop {
match sock.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
let seen = buf.get(..n).unwrap_or(&[]);
if seen.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
}
}
let resp = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nABCD\r\n";
let _ = sock.write_all(resp).await;
let _sock = sock;
std::future::pending::<()>().await;
});
Ok(addr)
}
#[tokio::test]
async fn response_headers_timeout_fires_on_silent_server() -> anyhow::Result<()> {
let addr = spawn_silent_server().await?;
let origin = HttpOrigin::parse(&format!("http://{addr}/"))?
.with_timeouts(Duration::from_millis(200), Duration::from_secs(30));
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
16,
PinnedHashes::empty(),
RetryPolicy::disabled(),
decdn_cache::CircuitBreakerPolicy::default(),
None,
Duration::ZERO,
)
.await?;
let err = err_of(engine.get(Hash::new(b"anything")).await)?;
let msg = err.display_chain().to_string();
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from headers timeout, got: {err:?}"
);
anyhow::ensure!(
msg.contains("headers timed out"),
"error message missing headers-timeout marker: {msg}"
);
Ok(())
}
#[tokio::test]
async fn chunk_idle_timeout_fires_when_origin_stalls_mid_body() -> anyhow::Result<()> {
let addr = spawn_stall_after_partial_body_server().await?;
let origin = HttpOrigin::parse(&format!("http://{addr}/"))?
.with_timeouts(Duration::from_secs(30), Duration::from_millis(200));
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
16,
PinnedHashes::empty(),
RetryPolicy::disabled(),
decdn_cache::CircuitBreakerPolicy::default(),
None,
Duration::ZERO,
)
.await?;
let err = err_of(engine.get(Hash::new(b"anything")).await)?;
let msg = err.display_chain().to_string();
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from idle timeout, got: {err:?}"
);
anyhow::ensure!(
msg.contains("body read stalled"),
"error message missing stalled marker: {msg}"
);
anyhow::ensure!(
msg.contains("4 bytes buffered"),
"error message missing progress info: {msg}"
);
Ok(())
}
#[tokio::test]
async fn pull_through_succeeds_above_one_mib_payload() -> anyhow::Result<()> {
let payload = vec![0x7Fu8; 2 * 1024 * 1024];
let hash = Hash::new(&payload);
let origin_dir = tempfile::tempdir()?;
seed_fs_blob(origin_dir.path(), hash, &payload)?;
let cache_dir = tempfile::tempdir()?;
let origin = Arc::new(FilesystemOrigin::new(origin_dir.path()).await?);
let engine = CacheEngine::open(cache_dir.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(got.len() == payload.len(), "size mismatch: {}", got.len());
anyhow::ensure!(got[..] == payload[..], "content mismatch");
anyhow::ensure!(engine.has(hash).await?, "blob should be cached");
Ok(())
}
#[tokio::test]
async fn mid_stream_overrun_is_rejected_by_http_origin() -> anyhow::Result<()> {
let payload_bytes = 2 * 1024 * 1024;
let addr = spawn_chunked_oversize_server(payload_bytes).await?;
let origin_url = format!("http://{addr}/");
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&origin_url)?);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 1).await?;
let err = err_of(engine.get(Hash::new(b"doesn't matter")).await)?;
anyhow::ensure!(
matches!(err, CacheError::BlobTooLarge { .. }),
"expected BlobTooLarge from mid-stream cap, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn open_on_file_path_yields_store_error() -> anyhow::Result<()> {
let tmp = tempfile::NamedTempFile::new()?;
let err = CacheEngine::open(tmp.path(), Vec::new(), 16)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected open to fail on a file path"))?;
anyhow::ensure!(
matches!(err, CacheError::Store(_)),
"expected Store variant, got: {err:?}"
);
Ok(())
}
fn seed_fs_blob(base: &std::path::Path, hash: Hash, payload: &[u8]) -> anyhow::Result<()> {
let hex = hash.to_hex();
let shard = hex
.get(..2)
.ok_or_else(|| anyhow::anyhow!("hex too short"))?;
let dir = base.join(shard);
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join(hex.as_str()), payload)?;
Ok(())
}
#[tokio::test]
async fn fs_origin_pulls_and_caches() -> anyhow::Result<()> {
let payload: &[u8] = b"content-addressed from local disk";
let hash = Hash::new(payload);
let origin_dir = tempfile::tempdir()?;
seed_fs_blob(origin_dir.path(), hash, payload)?;
let cache_dir = tempfile::tempdir()?;
let origin = Arc::new(FilesystemOrigin::new(origin_dir.path()).await?);
let engine = CacheEngine::open(cache_dir.path(), vec![origin as Arc<dyn Origin>], 16).await?;
anyhow::ensure!(!engine.has(hash).await?, "blob should be absent initially");
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload, "first get should return fs bytes");
anyhow::ensure!(engine.has(hash).await?, "blob should be cached after miss");
drop(origin_dir);
let got2 = engine.get(hash).await?;
anyhow::ensure!(&got2[..] == payload, "second get should be served locally");
Ok(())
}
#[tokio::test]
async fn fs_origin_reports_not_found() -> anyhow::Result<()> {
let origin_dir = tempfile::tempdir()?;
let cache_dir = tempfile::tempdir()?;
let origin = Arc::new(FilesystemOrigin::new(origin_dir.path()).await?);
let engine = CacheEngine::open(cache_dir.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let err = err_of(engine.get(Hash::new(b"absent")).await)?;
anyhow::ensure!(
matches!(err, CacheError::NotFound { .. }),
"expected NotFound, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn fs_origin_rejects_oversize() -> anyhow::Result<()> {
let payload = vec![0x42u8; 2 * 1024 * 1024];
let hash = Hash::new(&payload);
let origin_dir = tempfile::tempdir()?;
seed_fs_blob(origin_dir.path(), hash, &payload)?;
let cache_dir = tempfile::tempdir()?;
let origin = Arc::new(FilesystemOrigin::new(origin_dir.path()).await?);
let engine = CacheEngine::open(cache_dir.path(), vec![origin as Arc<dyn Origin>], 1).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from fs metadata-length fast-path, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn miss_without_origin_returns_no_origin() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 16).await?;
let err = err_of(engine.get(Hash::new(b"whatever")).await)?;
anyhow::ensure!(
matches!(err, CacheError::NoOrigin { .. }),
"expected NoOrigin, got: {err:?}"
);
Ok(())
}
use std::io::Write;
fn gzip(payload: &[u8]) -> anyhow::Result<Vec<u8>> {
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
e.write_all(payload)?;
Ok(e.finish()?)
}
fn zstd_compress(payload: &[u8]) -> anyhow::Result<Vec<u8>> {
Ok(zstd::stream::encode_all(payload, 1)?)
}
#[tokio::test]
async fn http_origin_decompresses_gzip_response() -> anyhow::Result<()> {
let payload: &[u8] = b"hello, gzipped world! repeat repeat repeat repeat";
let hash = Hash::new(payload);
let server = MockServer::start().await;
let compressed = gzip(payload)?;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "gzip")
.set_body_bytes(compressed),
)
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
&got[..] == payload,
"decompressed bytes should match canonical payload"
);
Ok(())
}
#[tokio::test]
async fn http_origin_decompresses_zstd_response() -> anyhow::Result<()> {
let payload: &[u8] = b"hello, zstd! and a longer body to compress meaningfully xxxxx";
let hash = Hash::new(payload);
let server = MockServer::start().await;
let compressed = zstd_compress(payload)?;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "zstd")
.set_body_bytes(compressed),
)
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload, "decompressed bytes should match");
Ok(())
}
#[tokio::test]
async fn http_origin_compressed_above_buffer_threshold_succeeds() -> anyhow::Result<()> {
let payload = vec![0u8; 8 * 1024 * 1024];
let hash = Hash::new(&payload);
let compressed = gzip(&payload)?;
anyhow::ensure!(
compressed.len() < 4 * 1024 * 1024,
"test setup: encoded length must be under buffered_max_bytes (4 MiB) \
to route into the drain path pre-fix, was {} bytes",
compressed.len()
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "gzip")
.set_body_bytes(compressed),
)
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
got.len() == payload.len() && got[..] == payload[..],
"in-bounds compressed blob must decode and cache, got {} bytes",
got.len()
);
Ok(())
}
#[tokio::test]
async fn http_origin_passes_through_identity_encoding() -> anyhow::Result<()> {
let payload: &[u8] = b"plain identity payload";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "identity")
.set_body_bytes(payload),
)
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload);
Ok(())
}
#[tokio::test]
async fn http_origin_rejects_unknown_encoding() -> anyhow::Result<()> {
let payload: &[u8] = b"who knows what encoding";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "br") .set_body_bytes(payload),
)
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let err = err_of(engine.get(hash).await)?;
let formatted = format!("{err:?}");
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError, got: {formatted}"
);
anyhow::ensure!(
formatted.contains("Content-Encoding") || formatted.contains("br"),
"error should mention the unsupported encoding: {formatted}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_decompress_off_rejects_compressed_response() -> anyhow::Result<()> {
let payload: &[u8] = b"canonical payload";
let hash = Hash::new(payload);
let server = MockServer::start().await;
let compressed = gzip(payload)?;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "gzip")
.set_body_bytes(compressed),
)
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(
HttpOrigin::parse(&server.uri())?.with_decompress_mode(decdn_cache::DecompressMode::Strict),
);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let err = err_of(engine.get(hash).await)?;
let formatted = format!("{err:?}");
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError when decompression is off and encoding is set, got: {formatted}"
);
anyhow::ensure!(
formatted.contains("unsupported Content-Encoding") || formatted.contains("gzip"),
"error should name the unsupported encoding: {formatted}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_decompress_off_passes_through_identity() -> anyhow::Result<()> {
let payload: &[u8] = b"canonical payload";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec()))
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin =
Arc::new(HttpOrigin::parse(&server.uri())?.with_decompress_mode(DecompressMode::Strict));
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let bytes = engine.get(hash).await?;
anyhow::ensure!(bytes.as_ref() == payload, "unexpected payload");
Ok(())
}
async fn serve_encoded(encoding: &'static str, body: Vec<u8>, canonical_hash: Hash) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", canonical_hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", encoding)
.set_body_bytes(body),
)
.mount(&server)
.await;
server
}
#[tokio::test]
async fn http_origin_rejects_decompression_bomb() -> anyhow::Result<()> {
let payload = vec![0u8; 4 * 1024 * 1024];
let canonical = Hash::new(&payload);
let compressed = gzip(&payload)?;
anyhow::ensure!(
compressed.len() < 1024 * 1024,
"test setup: compressed body must be smaller than the cap to \
exercise the running-total check, was {} bytes",
compressed.len()
);
let server = serve_encoded("gzip", compressed, canonical).await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 1).await?;
let err = err_of(engine.get(canonical).await)?;
anyhow::ensure!(
matches!(err, CacheError::BlobTooLarge { .. }),
"expected BlobTooLarge from bomb cap, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_truncated_gzip_surfaces_decompression_failed() -> anyhow::Result<()> {
let payload: &[u8] = b"truncate me, please, but only the gzip wrapper";
let canonical = Hash::new(payload);
let mut compressed = gzip(payload)?;
let drop = compressed.len().saturating_sub(20);
compressed.truncate(drop);
let server = serve_encoded("gzip", compressed, canonical).await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let err = err_of(engine.get(canonical).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError, got: {err:?}"
);
let kind = err
.origin_error_kind()
.ok_or_else(|| anyhow::anyhow!("expected typed downcast"))?;
anyhow::ensure!(
matches!(
kind,
OriginError::DecompressionFailed {
encoding: SupportedEncoding::Gzip,
..
}
),
"expected DecompressionFailed(Gzip), got: {kind:?}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_unknown_encoding_is_typed() -> anyhow::Result<()> {
let payload: &[u8] = b"who knows";
let hash = Hash::new(payload);
let server = serve_encoded("br", payload.to_vec(), hash).await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let err = err_of(engine.get(hash).await)?;
let kind = err
.origin_error_kind()
.ok_or_else(|| anyhow::anyhow!("expected typed downcast"))?;
let OriginError::UnsupportedEncoding { encoding } = kind else {
anyhow::bail!("expected UnsupportedEncoding, got: {kind:?}");
};
anyhow::ensure!(
encoding.as_ref() == "br",
"encoding string should round-trip: {encoding:?}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_accepts_uppercase_gzip_encoding() -> anyhow::Result<()> {
let payload: &[u8] = b"upper case is fine, RFC says so";
let hash = Hash::new(payload);
let server = serve_encoded("GZIP", gzip(payload)?, hash).await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload);
Ok(())
}
#[tokio::test]
async fn http_origin_accepts_x_gzip_encoding() -> anyhow::Result<()> {
let payload: &[u8] = b"the x prefix predates RFC 2616";
let hash = Hash::new(payload);
let server = serve_encoded("x-gzip", gzip(payload)?, hash).await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload);
Ok(())
}
#[tokio::test]
async fn http_origin_rejects_multi_encoding_header() -> anyhow::Result<()> {
let payload: &[u8] = b"don't try to be clever";
let hash = Hash::new(payload);
let server = serve_encoded("gzip, zstd", gzip(payload)?, hash).await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let err = err_of(engine.get(hash).await)?;
let kind = err
.origin_error_kind()
.ok_or_else(|| anyhow::anyhow!("expected typed downcast"))?;
anyhow::ensure!(
matches!(kind, OriginError::UnsupportedEncoding { .. }),
"expected UnsupportedEncoding for multi-encoding, got: {kind:?}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_decompresses_above_blocking_threshold() -> anyhow::Result<()> {
let payload = vec![0xC0u8; 2 * 1024 * 1024];
let hash = Hash::new(&payload);
let server = serve_encoded("gzip", gzip(&payload)?, hash).await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(got.len() == payload.len(), "size mismatch: {}", got.len());
anyhow::ensure!(got[..] == payload[..], "decompressed content mismatch");
Ok(())
}
#[tokio::test]
async fn http_origin_rejects_oversized_compressed_content_length() -> anyhow::Result<()> {
let canonical = Hash::new(b"doesn't matter");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", canonical.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "gzip")
.set_body_bytes(vec![0u8; 5 * 1024 * 1024]),
)
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 1).await?;
let err = err_of(engine.get(canonical).await)?;
let msg = err.display_chain().to_string();
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from compressed C-L cap, got: {err:?}"
);
anyhow::ensure!(
msg.contains("exceeds max"),
"error should name the cap: {msg}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_empty_gzip_body_surfaces_decompression_failed() -> anyhow::Result<()> {
let canonical = Hash::new(b"phantom");
let server = serve_encoded("gzip", Vec::new(), canonical).await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let err = err_of(engine.get(canonical).await)?;
let kind = err
.origin_error_kind()
.ok_or_else(|| anyhow::anyhow!("expected typed downcast"))?;
anyhow::ensure!(
matches!(
kind,
OriginError::DecompressionFailed {
encoding: SupportedEncoding::Gzip,
..
}
),
"expected DecompressionFailed(Gzip), got: {kind:?}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_blake3_verify_runs_over_decompressed_bytes() -> anyhow::Result<()> {
let payload: &[u8] = b"verify-after-decompress, not before";
let canonical = Hash::new(payload);
let compressed = gzip(payload)?;
let raw_hash = Hash::new(&compressed);
anyhow::ensure!(canonical != raw_hash, "test premise: hashes differ");
let server = serve_encoded("gzip", compressed, raw_hash).await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let err = err_of(engine.get(raw_hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch (verify ran over decompressed form), got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_strict_mode_accepts_explicit_identity() -> anyhow::Result<()> {
let payload: &[u8] = b"explicit identity";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "identity")
.set_body_bytes(payload),
)
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin =
Arc::new(HttpOrigin::parse(&server.uri())?.with_decompress_mode(DecompressMode::Strict));
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload);
Ok(())
}
async fn mount_status_mocks(server: &MockServer, statuses: &[u16]) -> Vec<Hash> {
let mut hashes = Vec::with_capacity(statuses.len());
for &status in statuses {
let label = format!("status-mapping-{status}");
let hash = Hash::new(label.as_bytes());
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(status))
.mount(server)
.await;
hashes.push(hash);
}
hashes
}
#[tokio::test]
async fn http_origin_4xx_other_than_404_surfaces_origin_error() -> anyhow::Result<()> {
let statuses = [400u16, 401, 403, 410, 422, 429];
let server = MockServer::start().await;
let hashes = mount_status_mocks(&server, &statuses).await;
let (engine, _tmp) = build_engine_no_retry(&server.uri()).await?;
for (status, hash) in statuses.iter().zip(hashes.iter()) {
let err = err_of(engine.get(*hash).await)?;
let msg = err.display_chain().to_string();
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError for status {status}, got: {err:?}"
);
anyhow::ensure!(
msg.contains(&status.to_string()),
"error should name status {status}: {msg}"
);
anyhow::ensure!(
!engine.has(*hash).await?,
"non-404 4xx must not populate the cache for {status}"
);
}
Ok(())
}
#[tokio::test]
async fn http_origin_5xx_surfaces_origin_error() -> anyhow::Result<()> {
let statuses = [500u16, 502, 503, 504];
let server = MockServer::start().await;
let hashes = mount_status_mocks(&server, &statuses).await;
let (engine, _tmp) = build_engine_no_retry(&server.uri()).await?;
for (status, hash) in statuses.iter().zip(hashes.iter()) {
let err = err_of(engine.get(*hash).await)?;
let msg = err.display_chain().to_string();
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError for status {status}, got: {err:?}"
);
anyhow::ensure!(
msg.contains(&status.to_string()),
"error should name status {status}: {msg}"
);
anyhow::ensure!(
!engine.has(*hash).await?,
"5xx must not populate the cache for {status}"
);
}
Ok(())
}
#[tokio::test]
async fn http_origin_2xx_non_200_succeeds() -> anyhow::Result<()> {
let payload: &[u8] = b"two-oh-three is fine";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(203).set_body_bytes(payload))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload, "2xx body must be served");
Ok(())
}
async fn spawn_spoofed_content_length_server(
advertised: u64,
body: Vec<u8>,
) -> anyhow::Result<std::net::SocketAddr> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let mut buf = [0u8; 4096];
loop {
match sock.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
let seen = buf.get(..n).unwrap_or(&[]);
if seen.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
}
}
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {advertised}\r\nContent-Type: application/octet-stream\r\n\r\n",
);
if sock.write_all(header.as_bytes()).await.is_err() {
return;
}
let _ = sock.write_all(&body).await;
let _sock = sock;
std::future::pending::<()>().await;
});
Ok(addr)
}
#[tokio::test]
async fn http_origin_rejects_advertised_oversize_before_reading_body() -> anyhow::Result<()> {
let advertised: u64 = 5 * 1024 * 1024 * 1024;
let addr = spawn_spoofed_content_length_server(advertised, Vec::new()).await?;
let origin = HttpOrigin::parse(&format!("http://{addr}/"))?
.with_timeouts(Duration::from_secs(5), Duration::from_millis(500));
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 1).await?;
let err = err_of(engine.get(Hash::new(b"anything")).await)?;
let msg = err.display_chain().to_string();
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from Content-Length fast-path, got: {err:?}"
);
anyhow::ensure!(
msg.contains("exceeds max"),
"error should name the fast-path message: {msg}"
);
anyhow::ensure!(
msg.contains(&advertised.to_string()),
"error should name the advertised length {advertised}: {msg}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_accepts_content_length_exactly_at_cap() -> anyhow::Result<()> {
const CAP_MB: u64 = 1;
const CAP_BYTES: usize = 1024 * 1024;
let payload = vec![0xA5u8; CAP_BYTES];
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], CAP_MB).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
got.len() == payload.len(),
"exact-cap size mismatch: {} vs {}",
got.len(),
payload.len()
);
Ok(())
}
#[tokio::test]
async fn http_origin_rejects_content_length_one_over_cap() -> anyhow::Result<()> {
const CAP_MB: u64 = 1;
const CAP_BYTES: usize = 1024 * 1024;
let payload = vec![0xA6u8; CAP_BYTES + 1];
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&server)
.await;
let tmp = tempfile::tempdir()?;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], CAP_MB).await?;
let err = err_of(engine.get(hash).await)?;
let msg = err.display_chain().to_string();
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError at cap+1, got: {err:?}"
);
anyhow::ensure!(
msg.contains("exceeds max"),
"error should name the fast-path message at boundary: {msg}"
);
Ok(())
}
#[tokio::test]
async fn http_origin_rejects_302_redirect() -> anyhow::Result<()> {
let payload: &[u8] = b"behind a 302";
let hash = Hash::new(payload);
let final_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&final_server)
.await;
let redirect_server = MockServer::start().await;
let location = format!("{}/{}", final_server.uri(), hash.to_hex());
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(302).insert_header("Location", location.as_str()))
.mount(&redirect_server)
.await;
let (engine, _tmp) = build_engine(&redirect_server.uri()).await?;
let err = err_of(engine.get(hash).await)?;
let msg = format!("{err:?}");
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from 302, got: {err:?}"
);
anyhow::ensure!(
msg.to_ascii_lowercase().contains("redirect"),
"error message should name redirects, got: {msg}"
);
let final_hits = received_requests_or_fail(&final_server, "302 SSRF test").await?;
anyhow::ensure!(
final_hits.is_empty(),
"redirect target must not be contacted (SSRF defence), got {} hit(s)",
final_hits.len()
);
let source_hits = received_requests_or_fail(&redirect_server, "302 SSRF test source").await?;
anyhow::ensure!(
source_hits.len() == 1,
"expected exactly 1 request to the redirect source, got {}",
source_hits.len()
);
anyhow::ensure!(!engine.has(hash).await?, "rejected fetch must not cache");
Ok(())
}
async fn received_requests_or_fail(
server: &MockServer,
label: &str,
) -> anyhow::Result<Vec<wiremock::Request>> {
server.received_requests().await.ok_or_else(|| {
anyhow::anyhow!(
"wiremock recording disabled in `{label}` — SSRF assertion would silently pass; \
enable recording or rewrite the test"
)
})
}
#[tokio::test]
async fn http_origin_rejects_301_redirect() -> anyhow::Result<()> {
let payload: &[u8] = b"behind a 301";
let hash = Hash::new(payload);
let final_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&final_server)
.await;
let redirect_server = MockServer::start().await;
let location = format!("{}/{}", final_server.uri(), hash.to_hex());
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(301).insert_header("Location", location.as_str()))
.mount(&redirect_server)
.await;
let (engine, _tmp) = build_engine(&redirect_server.uri()).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from 301, got: {err:?}"
);
let final_hits = received_requests_or_fail(&final_server, "301 SSRF test").await?;
anyhow::ensure!(
final_hits.is_empty(),
"redirect target must not be contacted (SSRF defence), got {} hit(s)",
final_hits.len()
);
let source_hits = received_requests_or_fail(&redirect_server, "301 SSRF test source").await?;
anyhow::ensure!(
source_hits.len() == 1,
"expected exactly 1 request to the redirect source, got {}",
source_hits.len()
);
Ok(())
}
#[tokio::test]
async fn http_origin_rejects_307_redirect() -> anyhow::Result<()> {
let payload: &[u8] = b"behind a 307";
let hash = Hash::new(payload);
let final_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&final_server)
.await;
let redirect_server = MockServer::start().await;
let location = format!("{}/{}", final_server.uri(), hash.to_hex());
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(307).insert_header("Location", location.as_str()))
.mount(&redirect_server)
.await;
let (engine, _tmp) = build_engine(&redirect_server.uri()).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from 307, got: {err:?}"
);
let final_hits = received_requests_or_fail(&final_server, "307 SSRF test").await?;
anyhow::ensure!(
final_hits.is_empty(),
"redirect target must not be contacted (SSRF defence), got {} hit(s)",
final_hits.len()
);
let source_hits = received_requests_or_fail(&redirect_server, "307 SSRF test source").await?;
anyhow::ensure!(
source_hits.len() == 1,
"expected exactly 1 request to the redirect source, got {}",
source_hits.len()
);
Ok(())
}
#[tokio::test]
async fn http_origin_redirect_to_404_origin_surfaces_redirect_error() -> anyhow::Result<()> {
let hash = Hash::new(b"absent at the would-be redirect target");
let final_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(404))
.mount(&final_server)
.await;
let redirect_server = MockServer::start().await;
let location = format!("{}/{}", final_server.uri(), hash.to_hex());
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(302).insert_header("Location", location.as_str()))
.mount(&redirect_server)
.await;
let (engine, _tmp) = build_engine(&redirect_server.uri()).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError (3xx is terminal), got: {err:?}"
);
let final_hits = received_requests_or_fail(&final_server, "redirect-to-404 SSRF test").await?;
anyhow::ensure!(
final_hits.is_empty(),
"redirect target must not be contacted (SSRF defence), got {} hit(s)",
final_hits.len()
);
let source_hits =
received_requests_or_fail(&redirect_server, "redirect-to-404 SSRF test source").await?;
anyhow::ensure!(
source_hits.len() == 1,
"expected exactly 1 request to the redirect source, got {}",
source_hits.len()
);
Ok(())
}
#[tokio::test]
async fn http_origin_rejects_redirect_to_internal_address() -> anyhow::Result<()> {
let hash = Hash::new(b"attacker-pointed");
let redirect_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(302)
.insert_header("Location", format!("http://127.0.0.1:1/{}", hash.to_hex())),
)
.mount(&redirect_server)
.await;
let origin = HttpOrigin::parse(&redirect_server.uri())?
.with_timeouts(Duration::from_secs(10), Duration::from_secs(5));
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 16).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from 302-to-internal, got: {err:?}"
);
let source_hits =
received_requests_or_fail(&redirect_server, "internal-address SSRF test").await?;
anyhow::ensure!(
source_hits.len() == 1,
"expected exactly 1 request to the redirect source, got {}: redirects \
may be being followed",
source_hits.len()
);
Ok(())
}
async fn build_engine_with_retry(
origin: Arc<dyn Origin>,
policy: RetryPolicy,
) -> anyhow::Result<(CacheEngine, tempfile::TempDir)> {
build_engine_with_retry_and_breaker(
origin,
policy,
decdn_cache::CircuitBreakerPolicy::default(),
)
.await
}
async fn build_engine_with_retry_and_breaker(
origin: Arc<dyn Origin>,
policy: RetryPolicy,
breaker: decdn_cache::CircuitBreakerPolicy,
) -> anyhow::Result<(CacheEngine, tempfile::TempDir)> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
PinnedHashes::empty(),
policy,
breaker,
None,
Duration::ZERO,
)
.await?;
Ok((engine, tmp))
}
const fn fast_retry_policy(max_retries: u32) -> RetryPolicy {
RetryPolicy {
max_retries,
initial_backoff_ms: 1,
max_backoff_ms: 5,
jitter_ratio: 0.0,
buffered_max_bytes: 4 << 20,
}
}
#[derive(Debug)]
struct FailingThenSucceedingOrigin {
payload: bytes::Bytes,
target: Hash,
fail_count: std::sync::atomic::AtomicUsize,
fetch_count: std::sync::atomic::AtomicUsize,
max_failures: usize,
}
impl FailingThenSucceedingOrigin {
fn new(payload: &[u8], max_failures: usize) -> Self {
Self {
target: Hash::new(payload),
payload: bytes::Bytes::from(payload.to_vec()),
fail_count: std::sync::atomic::AtomicUsize::new(0),
fetch_count: std::sync::atomic::AtomicUsize::new(0),
max_failures,
}
}
fn fetches(&self) -> usize {
self.fetch_count.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Origin for FailingThenSucceedingOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
self.fetch_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let prior = self
.fail_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let max_failures = self.max_failures;
let target = self.target;
let payload = self.payload.clone();
Box::pin(async move {
if prior < max_failures {
Err(OriginPullError::Transient(anyhow::anyhow!(
"synthetic transient failure {prior}"
)))
} else if hash == target {
Ok(OriginFetch::found_one_shot(payload))
} else {
Ok(OriginFetch::NotFound)
}
})
}
}
#[derive(Debug)]
struct SlowOnceOrigin {
payload: bytes::Bytes,
target: Hash,
fetch_count: std::sync::atomic::AtomicUsize,
delay: Duration,
}
impl SlowOnceOrigin {
fn new(payload: &[u8], delay: Duration) -> Self {
Self {
target: Hash::new(payload),
payload: bytes::Bytes::from(payload.to_vec()),
fetch_count: std::sync::atomic::AtomicUsize::new(0),
delay,
}
}
fn fetches(&self) -> usize {
self.fetch_count.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Origin for SlowOnceOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
self.fetch_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let target = self.target;
let payload = self.payload.clone();
let delay = self.delay;
Box::pin(async move {
tokio::time::sleep(delay).await;
if hash == target {
Ok(OriginFetch::found_one_shot(payload))
} else {
Ok(OriginFetch::NotFound)
}
})
}
}
#[derive(Debug)]
struct PermanentlyFailingOrigin {
fetch_count: std::sync::atomic::AtomicUsize,
marker: String,
}
impl PermanentlyFailingOrigin {
fn new() -> Self {
Self::with_marker(String::new())
}
fn with_marker(marker: impl Into<String>) -> Self {
Self {
fetch_count: std::sync::atomic::AtomicUsize::new(0),
marker: marker.into(),
}
}
fn fetches(&self) -> usize {
self.fetch_count.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Origin for PermanentlyFailingOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
self.fetch_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let marker = self.marker.clone();
Box::pin(async move {
if marker.is_empty() {
Err(OriginPullError::Permanent(anyhow::anyhow!(
"synthetic permanent failure (e.g. 403)"
)))
} else {
Err(OriginPullError::Permanent(anyhow::anyhow!(
"synthetic permanent failure with marker={marker}"
)))
}
})
}
}
#[tokio::test]
async fn retry_succeeds_after_transient_failures() -> anyhow::Result<()> {
let payload: &[u8] = b"recovered";
let origin = Arc::new(FailingThenSucceedingOrigin::new(payload, 2));
let (engine, _tmp) =
build_engine_with_retry(origin.clone() as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let hash = Hash::new(payload);
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload, "payload survived retry chain");
anyhow::ensure!(
origin.fetches() == 3,
"expected exactly 3 origin attempts (2 fail + 1 success), got {}",
origin.fetches()
);
Ok(())
}
#[tokio::test]
async fn retry_exhausts_on_persistent_transient_failures() -> anyhow::Result<()> {
let payload: &[u8] = b"unreachable";
let origin = Arc::new(FailingThenSucceedingOrigin::new(payload, usize::MAX));
let (engine, _tmp) =
build_engine_with_retry(origin.clone() as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let hash = Hash::new(payload);
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"exhausted retries should surface OriginError, got: {err:?}"
);
anyhow::ensure!(
origin.fetches() == 4,
"expected exactly 4 origin attempts (1 + 3 retries), got {}",
origin.fetches()
);
Ok(())
}
#[tokio::test]
async fn retry_skips_permanent_failures() -> anyhow::Result<()> {
let origin = Arc::new(PermanentlyFailingOrigin::new());
let (engine, _tmp) =
build_engine_with_retry(origin.clone() as Arc<dyn Origin>, fast_retry_policy(5)).await?;
let hash = Hash::new(b"anything");
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"permanent failure should surface OriginError, got: {err:?}"
);
anyhow::ensure!(
origin.fetches() == 1,
"expected exactly 1 attempt on permanent failure, got {}",
origin.fetches()
);
Ok(())
}
#[tokio::test]
async fn retry_skips_not_found_responses() -> anyhow::Result<()> {
let server = MockServer::start().await;
let hash = Hash::new(b"missing");
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&server)
.await;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::NotFound { .. }),
"404 should map to CacheError::NotFound, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn retry_disabled_policy_makes_a_single_attempt() -> anyhow::Result<()> {
let payload: &[u8] = b"unreached";
let origin = Arc::new(FailingThenSucceedingOrigin::new(payload, usize::MAX));
let (engine, _tmp) =
build_engine_with_retry(origin.clone() as Arc<dyn Origin>, RetryPolicy::disabled()).await?;
let hash = Hash::new(payload);
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(matches!(err, CacheError::OriginError { .. }));
anyhow::ensure!(
origin.fetches() == 1,
"disabled policy must perform exactly one attempt, got {}",
origin.fetches()
);
Ok(())
}
#[tokio::test]
async fn retry_classifies_http_5xx_as_transient_then_succeeds() -> anyhow::Result<()> {
let payload: &[u8] = b"after backoff";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(503))
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&server)
.await;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload);
Ok(())
}
#[tokio::test]
async fn retry_classifies_http_4xx_non_404_as_permanent() -> anyhow::Result<()> {
let payload: &[u8] = b"forbidden";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(403))
.expect(1)
.mount(&server)
.await;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(5)).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(matches!(err, CacheError::OriginError { .. }));
Ok(())
}
#[tokio::test]
async fn coalesced_owner_retry_unblocks_waiters_on_success() -> anyhow::Result<()> {
let payload: &[u8] = b"shared via coalescing";
let origin = Arc::new(FailingThenSucceedingOrigin::new(payload, 2));
let (engine, _tmp) =
build_engine_with_retry(origin.clone() as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let hash = Hash::new(payload);
let mut handles = Vec::new();
for _ in 0..6 {
let e = engine.clone();
handles.push(tokio::spawn(async move { e.get(hash).await }));
}
for h in handles {
let bytes = h.await??;
anyhow::ensure!(&bytes[..] == payload);
}
anyhow::ensure!(
origin.fetches() == 3,
"coalesced retry should fire exactly 3 origin attempts, got {}",
origin.fetches()
);
Ok(())
}
#[tokio::test]
async fn coalesced_owner_exhaustion_bounded_by_circuit_breaker() -> anyhow::Result<()> {
const N_WAITERS: u32 = 12;
const MAX_RETRIES: u32 = 2;
async fn drive_down_origin(
breaker: decdn_cache::CircuitBreakerPolicy,
payload: &'static [u8],
hash: Hash,
n: u32,
) -> anyhow::Result<usize> {
let origin = Arc::new(FailingThenSucceedingOrigin::new(payload, usize::MAX));
let (engine, _tmp) = build_engine_with_retry_and_breaker(
origin.clone() as Arc<dyn Origin>,
fast_retry_policy(MAX_RETRIES),
breaker,
)
.await?;
let mut handles = Vec::new();
for _ in 0..n {
let e = engine.clone();
handles.push(tokio::spawn(async move { e.get(hash).await }));
}
for h in handles {
match h.await? {
Err(CacheError::OriginError { .. }) => {}
other => anyhow::bail!("expected OriginError, got: {other:?}"),
}
}
Ok(origin.fetches())
}
let budget = 1 + MAX_RETRIES as usize; let payload: &[u8] = b"never available";
let hash = Hash::new(payload);
let unbounded = drive_down_origin(
decdn_cache::CircuitBreakerPolicy::disabled(),
payload,
hash,
N_WAITERS,
)
.await?;
let o_n = (N_WAITERS as usize) * budget;
anyhow::ensure!(
unbounded == o_n,
"breaker disabled: expected the O(N) worst case {o_n}, got {unbounded}",
);
let threshold = decdn_cache::CircuitBreakerPolicy::default().failure_threshold as usize;
anyhow::ensure!(
threshold < N_WAITERS as usize,
"test is only meaningful when failure_threshold ({threshold}) < N_WAITERS ({N_WAITERS})",
);
let bounded = drive_down_origin(
decdn_cache::CircuitBreakerPolicy::default(),
payload,
hash,
N_WAITERS,
)
.await?;
let cap = threshold * budget;
anyhow::ensure!(
bounded <= cap,
"breaker enabled: fetches {bounded} exceed the cap {cap} (failure_threshold * budget)",
);
anyhow::ensure!(
bounded < o_n,
"breaker enabled: fetches {bounded} not below the O(N) case {o_n} — breaker never shed load",
);
anyhow::ensure!(
bounded >= budget,
"breaker enabled: fetches {bounded} below the {budget}-attempt minimum — retry never ran?",
);
Ok(())
}
#[tokio::test]
async fn coalesced_concurrent_misses_fire_origin_exactly_once() -> anyhow::Result<()> {
let payload: &[u8] = b"single shared origin fetch";
let origin = Arc::new(SlowOnceOrigin::new(payload, Duration::from_millis(25)));
let (engine, _tmp) =
build_engine_with_retry(origin.clone() as Arc<dyn Origin>, RetryPolicy::disabled()).await?;
let hash = Hash::new(payload);
let mut handles = Vec::new();
for _ in 0..10 {
let e = engine.clone();
handles.push(tokio::spawn(async move { e.get(hash).await }));
}
for h in handles {
let bytes = h.await??;
anyhow::ensure!(&bytes[..] == payload);
}
anyhow::ensure!(
origin.fetches() == 1,
"coalesced concurrent misses must fire origin exactly once, got {}",
origin.fetches()
);
Ok(())
}
#[tokio::test]
async fn fs_origin_retries_on_transient_io_kind_then_succeeds() -> anyhow::Result<()> {
#[derive(Debug)]
struct InterruptOnceOrigin {
inner: FilesystemOrigin,
first: std::sync::atomic::AtomicBool,
}
impl Origin for InterruptOnceOrigin {
fn kind(&self) -> OriginKind {
self.inner.kind()
}
fn fetch(
&self,
hash: Hash,
max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>>
{
let already_failed = self.first.swap(true, std::sync::atomic::Ordering::SeqCst);
if already_failed {
self.inner.fetch(hash, max_bytes)
} else {
Box::pin(async move {
Err(OriginPullError::Transient(
std::io::Error::from(std::io::ErrorKind::Interrupted).into(),
))
})
}
}
}
let base = tempfile::tempdir()?;
let payload: &[u8] = b"after interrupt";
let hash = Hash::new(payload);
let hex = hash.to_hex().clone();
let shard = hex.get(..2).ok_or_else(|| anyhow::anyhow!("hex prefix"))?;
let shard_dir = base.path().join(shard);
tokio::fs::create_dir_all(&shard_dir).await?;
tokio::fs::write(shard_dir.join(&hex), payload).await?;
let inner = FilesystemOrigin::new(base.path()).await?;
let origin = Arc::new(InterruptOnceOrigin {
inner,
first: std::sync::atomic::AtomicBool::new(false),
});
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(2)).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload);
Ok(())
}
#[tokio::test]
async fn fs_origin_does_not_retry_on_permission_denied() -> anyhow::Result<()> {
#[derive(Debug)]
struct DeniedOrigin {
count: std::sync::atomic::AtomicUsize,
}
impl Origin for DeniedOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Filesystem
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>>
{
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Box::pin(async move {
Err(OriginPullError::Permanent(
std::io::Error::from(std::io::ErrorKind::PermissionDenied).into(),
))
})
}
}
let origin = Arc::new(DeniedOrigin {
count: std::sync::atomic::AtomicUsize::new(0),
});
let (engine, _tmp) =
build_engine_with_retry(origin.clone() as Arc<dyn Origin>, fast_retry_policy(5)).await?;
let hash = Hash::new(b"denied");
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(matches!(err, CacheError::OriginError { .. }));
anyhow::ensure!(
origin.count.load(std::sync::atomic::Ordering::SeqCst) == 1,
"PermissionDenied must short-circuit after one attempt"
);
Ok(())
}
#[tokio::test]
async fn http_origin_sends_configured_user_agent() -> anyhow::Result<()> {
use decdn_cache::parse_origin_url;
let payload: &[u8] = b"hello, decdn";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.and(header("user-agent", "MyCdn/1.0 (+ops@example.com)"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&server)
.await;
let url = parse_origin_url(&server.uri())?;
let origin = Arc::new(HttpOrigin::new_with_user_agent(
url,
"MyCdn/1.0 (+ops@example.com)",
)?);
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
&got[..] == payload,
"fetch should have succeeded — if the configured UA never reached the wire, \
the mock's header matcher would have served 404"
);
Ok(())
}
#[tokio::test]
async fn http_origin_sends_default_user_agent_when_unset() -> anyhow::Result<()> {
use decdn_cache::{DEFAULT_USER_AGENT, parse_origin_url};
let payload: &[u8] = b"hello, decdn";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.and(header("user-agent", DEFAULT_USER_AGENT))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&server)
.await;
let url = parse_origin_url(&server.uri())?;
let origin = Arc::new(HttpOrigin::new(url)?);
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
&got[..] == payload,
"default UA fetch should have succeeded"
);
Ok(())
}
async fn spawn_mid_body_reset_server(
payload: bytes::Bytes,
partial_bytes: usize,
succeed_on_attempt: Option<usize>, ) -> anyhow::Result<(std::net::SocketAddr, Arc<std::sync::atomic::AtomicUsize>)> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let cnt = counter.clone();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let attempt = cnt.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
if drain_http_request_headers(&mut sock).await.is_err() {
continue;
}
let succeed = matches!(succeed_on_attempt, Some(n) if attempt >= n);
let hdr = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
payload.len()
);
if sock.write_all(hdr.as_bytes()).await.is_err() {
continue;
}
if succeed {
let _ = sock.write_all(&payload).await;
let _ = sock.shutdown().await;
} else {
let cut = partial_bytes.min(payload.len());
let slice = payload.get(..cut).unwrap_or(&[]);
let _ = sock.write_all(slice).await;
drop(sock);
}
}
});
Ok((addr, counter))
}
async fn drain_http_request_headers(sock: &mut tokio::net::TcpStream) -> std::io::Result<()> {
let mut buf = [0u8; 4096];
let mut tail: [u8; 3] = [0; 3];
let mut tail_len: usize = 0;
loop {
let n = match sock.read(&mut buf).await {
Ok(0) => return Err(std::io::Error::other("peer closed before end of headers")),
Err(e) => return Err(e),
Ok(n) => n,
};
let mut window: Vec<u8> = Vec::with_capacity(tail_len + n);
window.extend_from_slice(tail.get(..tail_len).unwrap_or(&[]));
window.extend_from_slice(buf.get(..n).unwrap_or(&[]));
if window.windows(4).any(|w| w == b"\r\n\r\n") {
return Ok(());
}
let carry_from = window.len().saturating_sub(3);
let carry = window.get(carry_from..).unwrap_or(&[]);
tail_len = carry.len();
if let Some(slot) = tail.get_mut(..tail_len) {
slot.copy_from_slice(carry);
}
}
}
#[tokio::test]
async fn mid_stream_transient_retries_small_blob() -> anyhow::Result<()> {
let payload = bytes::Bytes::from_static(b"a small blob that fits in the buffered drain path");
let hash = Hash::new(&payload);
let (addr, counter) = spawn_mid_body_reset_server(payload.clone(), 8, Some(3)).await?;
let origin = Arc::new(HttpOrigin::parse(&format!("http://{addr}/"))?);
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
got[..] == payload[..],
"payload survived mid-stream retry chain"
);
let dispatched = counter.load(std::sync::atomic::Ordering::SeqCst);
anyhow::ensure!(
dispatched == 3,
"expected exactly 3 dispatches (2 fail + 1 success), got {dispatched}"
);
Ok(())
}
#[tokio::test]
async fn mid_stream_transient_retries_large_blob_via_abort_restart() -> anyhow::Result<()> {
let payload = bytes::Bytes::from(vec![0xa5_u8; 5 * 1024 * 1024]);
let hash = Hash::new(&payload);
let (addr, counter) = spawn_mid_body_reset_server(payload.clone(), 64 * 1024, Some(3)).await?;
let origin = Arc::new(HttpOrigin::parse(&format!("http://{addr}/"))?);
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
got[..] == payload[..],
"5 MiB payload survived streaming abort+restart chain"
);
let dispatched = counter.load(std::sync::atomic::Ordering::SeqCst);
anyhow::ensure!(
dispatched == 3,
"expected exactly 3 dispatches on streaming abort+restart, got {dispatched}"
);
Ok(())
}
#[tokio::test]
async fn mid_stream_transient_exhausts_budget_large_blob() -> anyhow::Result<()> {
let payload = bytes::Bytes::from(vec![0x5a_u8; 5 * 1024 * 1024]);
let hash = Hash::new(&payload);
let (addr, counter) = spawn_mid_body_reset_server(payload, 64 * 1024, None).await?;
let origin = Arc::new(HttpOrigin::parse(&format!("http://{addr}/"))?);
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"exhausted mid-stream retries should surface OriginError, got: {err:?}"
);
let dispatched = counter.load(std::sync::atomic::Ordering::SeqCst);
anyhow::ensure!(
dispatched == 4,
"expected exactly 4 dispatches (1 + 3 retries), got {dispatched}"
);
Ok(())
}
#[tokio::test]
async fn unknown_size_hint_uses_streaming_path_and_retries() -> anyhow::Result<()> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let cnt = counter.clone();
let payload: Vec<u8> = b"tiny chunked payload that needs retry".to_vec();
let payload_owned = payload.clone();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let attempt = cnt.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
if drain_http_request_headers(&mut sock).await.is_err() {
continue;
}
if attempt >= 2 {
let resp = format!(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n",
payload_owned.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.write_all(&payload_owned).await;
let _ = sock.write_all(b"\r\n0\r\n\r\n").await;
let _ = sock.shutdown().await;
} else {
let resp = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nABCD\r\n";
let _ = sock.write_all(resp).await;
drop(sock);
}
}
});
let hash = Hash::new(&payload);
let origin = Arc::new(HttpOrigin::parse(&format!("http://{addr}/"))?);
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(got[..] == payload[..], "chunked payload retried");
let dispatched = counter.load(std::sync::atomic::Ordering::SeqCst);
anyhow::ensure!(
dispatched == 2,
"expected 2 dispatches (1 fail + 1 success), got {dispatched}"
);
Ok(())
}
#[tokio::test]
async fn decompression_failure_is_permanent_under_threshold() -> anyhow::Result<()> {
let payload: &[u8] = b"a payload that gzip would compress but the response is truncated";
let hash = Hash::new(payload);
let mut compressed = Vec::new();
{
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write as _;
let mut enc = GzEncoder::new(&mut compressed, Compression::default());
enc.write_all(payload)?;
enc.finish()?;
}
let drop = 8;
anyhow::ensure!(compressed.len() > drop, "gzip stream too short to truncate");
compressed.truncate(compressed.len() - drop);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "gzip")
.set_body_bytes(compressed),
)
.expect(1)
.mount(&server)
.await;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected OriginError from truncated gzip, got: {err:?}"
);
anyhow::ensure!(
matches!(
err.origin_error_kind(),
Some(OriginError::DecompressionFailed {
encoding: SupportedEncoding::Gzip,
..
})
),
"expected typed DecompressionFailed(Gzip) on the error chain, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn buffered_max_bytes_zero_routes_through_streaming_path() -> anyhow::Result<()> {
let payload = bytes::Bytes::from_static(b"tiny payload that the operator chose not to buffer");
let hash = Hash::new(&payload);
let (addr, counter) = spawn_mid_body_reset_server(payload.clone(), 4, Some(2)).await?;
let origin = Arc::new(HttpOrigin::parse(&format!("http://{addr}/"))?);
let policy = RetryPolicy {
buffered_max_bytes: 0, ..fast_retry_policy(3)
};
let (engine, _tmp) = build_engine_with_retry(origin as Arc<dyn Origin>, policy).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(got[..] == payload[..]);
let dispatched = counter.load(std::sync::atomic::Ordering::SeqCst);
anyhow::ensure!(
dispatched == 2,
"expected 2 dispatches (streaming path with retry), got {dispatched}"
);
Ok(())
}
#[tokio::test]
async fn drain_overrun_surfaces_blob_too_large() -> anyhow::Result<()> {
#[derive(Debug)]
struct LyingSizeOrigin {
chunks: Vec<bytes::Bytes>,
advertised: u64,
}
impl Origin for LyingSizeOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>>
{
let chunks = self.chunks.clone();
let advertised = self.advertised;
Box::pin(async move {
use futures_util::stream;
let stream =
stream::iter(chunks.into_iter().map(Ok::<bytes::Bytes, std::io::Error>));
Ok(OriginFetch::Found {
stream: Box::pin(stream),
size_hint: Some(advertised),
})
})
}
}
let chunks: Vec<bytes::Bytes> = (0..4)
.map(|_| bytes::Bytes::from(vec![0xAB_u8; 4 * 1024]))
.collect();
let origin: Arc<dyn Origin> = Arc::new(LyingSizeOrigin {
chunks,
advertised: 1024,
});
let policy = RetryPolicy {
buffered_max_bytes: 4 * 1024,
..fast_retry_policy(0)
};
let (engine, _tmp) = build_engine_with_retry(origin, policy).await?;
let err = err_of(engine.get(Hash::new(b"unreached")).await)?;
anyhow::ensure!(
matches!(err, CacheError::BlobTooLarge { .. }),
"expected BlobTooLarge from drain overrun, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn hash_mismatch_after_streaming_is_not_retried() -> anyhow::Result<()> {
let expected = Hash::new(b"expected payload");
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let wrong: Vec<u8> = (0..6 * 1024 * 1024_i32).map(|i| i as u8).collect();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", expected.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(wrong))
.expect(1)
.mount(&server)
.await;
let origin = Arc::new(HttpOrigin::parse(&server.uri())?);
let (engine, _tmp) =
build_engine_with_retry(origin as Arc<dyn Origin>, fast_retry_policy(3)).await?;
let err = err_of(engine.get(expected).await)?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch, got: {err:?}"
);
Ok(())
}
#[derive(Debug)]
struct NotFoundOrigin {
fetch_count: std::sync::atomic::AtomicUsize,
}
impl NotFoundOrigin {
const fn new() -> Self {
Self {
fetch_count: std::sync::atomic::AtomicUsize::new(0),
}
}
fn fetches(&self) -> usize {
self.fetch_count.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Origin for NotFoundOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
self.fetch_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Box::pin(async move { Ok(OriginFetch::NotFound) })
}
}
#[derive(Debug)]
struct LyingHashOrigin {
bogus: bytes::Bytes,
}
impl LyingHashOrigin {
fn new(bogus_payload: &[u8]) -> Self {
Self {
bogus: bytes::Bytes::from(bogus_payload.to_vec()),
}
}
}
impl Origin for LyingHashOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
let payload = self.bogus.clone();
Box::pin(async move { Ok(OriginFetch::found_one_shot(payload)) })
}
}
#[derive(Debug)]
struct CountingOrigin {
inner: Arc<dyn Origin>,
fetch_count: std::sync::atomic::AtomicUsize,
}
impl CountingOrigin {
fn new(inner: Arc<dyn Origin>) -> Self {
Self {
inner,
fetch_count: std::sync::atomic::AtomicUsize::new(0),
}
}
fn fetches(&self) -> usize {
self.fetch_count.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Origin for CountingOrigin {
fn kind(&self) -> OriginKind {
self.inner.kind()
}
fn fetch(
&self,
hash: Hash,
max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
self.fetch_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.fetch(hash, max_bytes)
}
}
#[derive(Debug)]
struct PayloadOrigin {
payload: bytes::Bytes,
}
impl PayloadOrigin {
fn new(payload: &[u8]) -> Self {
Self {
payload: bytes::Bytes::from(payload.to_vec()),
}
}
}
impl Origin for PayloadOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
let payload = self.payload.clone();
Box::pin(async move { Ok(OriginFetch::found_one_shot(payload)) })
}
}
async fn build_engine_with_origins(
origins: Vec<Arc<dyn Origin>>,
metrics: Arc<CacheMetrics>,
) -> anyhow::Result<(CacheEngine, tempfile::TempDir)> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open_full(
tmp.path(),
origins,
16,
PinnedHashes::empty(),
fast_retry_policy(3),
decdn_cache::CircuitBreakerPolicy::default(),
Some(metrics),
Duration::ZERO,
)
.await?;
Ok((engine, tmp))
}
#[tokio::test]
async fn fallback_advances_on_notfound_to_next_origin() -> anyhow::Result<()> {
let payload: &[u8] = b"served by mirror #2";
let hash = Hash::new(payload);
let first = Arc::new(NotFoundOrigin::new());
let second = Arc::new(CountingOrigin::new(Arc::new(PayloadOrigin::new(payload))));
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(
vec![
Arc::clone(&first) as Arc<dyn Origin>,
Arc::clone(&second) as Arc<dyn Origin>,
],
Arc::clone(&metrics),
)
.await?;
let bytes = engine.get(hash).await?;
anyhow::ensure!(&bytes[..] == payload, "second origin must have served");
anyhow::ensure!(first.fetches() == 1, "primary must have been tried once");
anyhow::ensure!(second.fetches() == 1, "fallback must have been reached");
anyhow::ensure!(
metrics.origin_fallback.get() == 1,
"exactly one fallback advance step (primary -> secondary)"
);
anyhow::ensure!(
metrics.origin_fetches.get() == 1,
"origin_fetches must bump exactly once per pull_through call regardless of \
chain length (regression guard for #285 alerting ratios)",
);
Ok(())
}
#[tokio::test]
async fn fallback_advances_on_permanent_error_to_next_origin() -> anyhow::Result<()> {
let payload: &[u8] = b"served after Permanent failure";
let hash = Hash::new(payload);
let first = Arc::new(PermanentlyFailingOrigin::new());
let second = Arc::new(CountingOrigin::new(Arc::new(PayloadOrigin::new(payload))));
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(
vec![
Arc::clone(&first) as Arc<dyn Origin>,
Arc::clone(&second) as Arc<dyn Origin>,
],
Arc::clone(&metrics),
)
.await?;
let bytes = engine.get(hash).await?;
anyhow::ensure!(&bytes[..] == payload, "second origin must have served");
anyhow::ensure!(
first.fetches() == 1,
"Permanent error must not burn retry budget on origin 0"
);
anyhow::ensure!(second.fetches() == 1, "fallback must have been reached");
anyhow::ensure!(
metrics.origin_fallback.get() == 1,
"exactly one fallback advance step",
);
anyhow::ensure!(
metrics.origin_fetches.get() == 1,
"origin_fetches must bump exactly once per pull_through call",
);
Ok(())
}
#[tokio::test]
async fn fallback_advances_on_transient_after_retry_exhaustion() -> anyhow::Result<()> {
let payload: &[u8] = b"served after retry exhaustion";
let hash = Hash::new(payload);
let first = Arc::new(FailingThenSucceedingOrigin::new(payload, usize::MAX));
let second = Arc::new(CountingOrigin::new(Arc::new(PayloadOrigin::new(payload))));
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(
vec![
Arc::clone(&first) as Arc<dyn Origin>,
Arc::clone(&second) as Arc<dyn Origin>,
],
Arc::clone(&metrics),
)
.await?;
let bytes = engine.get(hash).await?;
anyhow::ensure!(&bytes[..] == payload, "second origin must have served");
anyhow::ensure!(
first.fetches() == 4,
"retry budget should be fully spent on origin 0 (1 + 3 retries), got {}",
first.fetches(),
);
anyhow::ensure!(second.fetches() == 1, "fallback must have been reached");
anyhow::ensure!(
metrics.origin_retry_exhausted.get() == 1,
"retry exhaustion on origin 0 must increment counter",
);
anyhow::ensure!(
metrics.origin_fallback.get() == 1,
"exactly one chain-walk step",
);
anyhow::ensure!(
metrics.origin_fetches.get() == 1,
"origin_fetches counts pull_through calls, not per-origin attempts",
);
Ok(())
}
#[tokio::test]
async fn fallback_does_not_advance_on_hash_mismatch() -> anyhow::Result<()> {
let requested: &[u8] = b"requested payload";
let bogus: &[u8] = b"bogus payload that hashes differently";
let hash = Hash::new(requested);
let first = Arc::new(LyingHashOrigin::new(bogus));
let second = Arc::new(CountingOrigin::new(Arc::new(PayloadOrigin::new(requested))));
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(
vec![
Arc::clone(&first) as Arc<dyn Origin>,
Arc::clone(&second) as Arc<dyn Origin>,
],
Arc::clone(&metrics),
)
.await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch from origin 0; chain must not advance, got: {err:?}",
);
anyhow::ensure!(
second.fetches() == 0,
"second origin must not be consulted after a hash mismatch",
);
anyhow::ensure!(
metrics.origin_fallback.get() == 0,
"hash mismatch must not bump the fallback counter",
);
Ok(())
}
#[tokio::test]
async fn fallback_does_not_advance_on_blob_too_large() -> anyhow::Result<()> {
let payload = bytes::Bytes::from(vec![0xCDu8; 2 * 1024 * 1024]);
let hash = Hash::new(&payload);
let first = Arc::new(OversizedOrigin { payload });
let second = Arc::new(CountingOrigin::new(Arc::new(PayloadOrigin::new(b"x"))));
let metrics = Arc::new(CacheMetrics::default());
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open_full(
tmp.path(),
vec![
Arc::clone(&first) as Arc<dyn Origin>,
Arc::clone(&second) as Arc<dyn Origin>,
],
1,
PinnedHashes::empty(),
fast_retry_policy(3),
decdn_cache::CircuitBreakerPolicy::default(),
Some(Arc::clone(&metrics)),
Duration::ZERO,
)
.await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::BlobTooLarge { .. }),
"expected BlobTooLarge from origin 0; chain must not advance, got: {err:?}",
);
anyhow::ensure!(
second.fetches() == 0,
"second origin must not be consulted after BlobTooLarge",
);
anyhow::ensure!(
metrics.origin_fallback.get() == 0,
"BlobTooLarge must not bump the fallback counter",
);
Ok(())
}
#[tokio::test]
async fn all_origins_notfound_surfaces_notfound() -> anyhow::Result<()> {
let hash = Hash::new(b"never-served");
let first = Arc::new(NotFoundOrigin::new());
let second = Arc::new(NotFoundOrigin::new());
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(
vec![
Arc::clone(&first) as Arc<dyn Origin>,
Arc::clone(&second) as Arc<dyn Origin>,
],
Arc::clone(&metrics),
)
.await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::NotFound { .. }),
"all-NotFound chain must surface NotFound (=> 404 at edge), got: {err:?}",
);
anyhow::ensure!(first.fetches() == 1 && second.fetches() == 1);
anyhow::ensure!(
metrics.origin_fallback.get() == 1,
"exactly one chain advance from origin 0 -> origin 1",
);
Ok(())
}
#[tokio::test]
async fn all_origins_error_surfaces_origin_error_with_last_failure() -> anyhow::Result<()> {
let hash = Hash::new(b"never-served");
let first = Arc::new(PermanentlyFailingOrigin::with_marker("primary-marker"));
let second = Arc::new(PermanentlyFailingOrigin::with_marker("mirror-marker"));
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(
vec![
Arc::clone(&first) as Arc<dyn Origin>,
Arc::clone(&second) as Arc<dyn Origin>,
],
Arc::clone(&metrics),
)
.await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"all-error chain must surface OriginError (=> 5xx at edge), got: {err:?}",
);
let chain = err.display_chain().to_string();
anyhow::ensure!(
chain.contains("mirror-marker"),
"OriginError must carry the LAST origin's failure marker; got chain: {chain}",
);
anyhow::ensure!(
!chain.contains("primary-marker"),
"OriginError must NOT carry the discarded earlier failure; got chain: {chain}",
);
Ok(())
}
#[tokio::test]
async fn fallback_counter_increments_per_step_in_three_origin_chain() -> anyhow::Result<()> {
let payload: &[u8] = b"served from third mirror";
let hash = Hash::new(payload);
let first = Arc::new(NotFoundOrigin::new());
let second = Arc::new(NotFoundOrigin::new());
let third = Arc::new(CountingOrigin::new(Arc::new(PayloadOrigin::new(payload))));
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(
vec![
Arc::clone(&first) as Arc<dyn Origin>,
Arc::clone(&second) as Arc<dyn Origin>,
Arc::clone(&third) as Arc<dyn Origin>,
],
Arc::clone(&metrics),
)
.await?;
let bytes = engine.get(hash).await?;
anyhow::ensure!(&bytes[..] == payload, "third origin must have served");
anyhow::ensure!(first.fetches() == 1 && second.fetches() == 1 && third.fetches() == 1);
anyhow::ensure!(
metrics.origin_fallback.get() == 2,
"expected exactly 2 chain-walk steps (0 -> 1 and 1 -> 2), got {}",
metrics.origin_fallback.get(),
);
anyhow::ensure!(
metrics.origin_fetches.get() == 1,
"origin_fetches must bump once per pull_through regardless of chain length, got {}",
metrics.origin_fetches.get(),
);
Ok(())
}
#[tokio::test]
async fn mixed_notfound_then_error_surfaces_error() -> anyhow::Result<()> {
let hash = Hash::new(b"never-served");
let first = Arc::new(NotFoundOrigin::new());
let second = Arc::new(PermanentlyFailingOrigin::new());
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(
vec![
Arc::clone(&first) as Arc<dyn Origin>,
Arc::clone(&second) as Arc<dyn Origin>,
],
Arc::clone(&metrics),
)
.await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"mixed NotFound-then-Error chain must surface OriginError, got: {err:?}",
);
Ok(())
}
#[tokio::test]
async fn single_origin_vec_preserves_pre_284_behaviour() -> anyhow::Result<()> {
let payload: &[u8] = b"single origin success";
let hash = Hash::new(payload);
let only = Arc::new(CountingOrigin::new(Arc::new(PayloadOrigin::new(payload))));
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(
vec![Arc::clone(&only) as Arc<dyn Origin>],
Arc::clone(&metrics),
)
.await?;
let bytes = engine.get(hash).await?;
anyhow::ensure!(&bytes[..] == payload);
anyhow::ensure!(only.fetches() == 1);
anyhow::ensure!(
metrics.origin_fallback.get() == 0,
"single-origin chain never advances; counter stays flat at zero",
);
Ok(())
}
#[tokio::test]
async fn empty_origin_vec_returns_no_origin() -> anyhow::Result<()> {
let hash = Hash::new(b"impossible");
let metrics = Arc::new(CacheMetrics::default());
let (engine, _tmp) = build_engine_with_origins(Vec::new(), Arc::clone(&metrics)).await?;
let err = err_of(engine.get(hash).await)?;
anyhow::ensure!(
matches!(err, CacheError::NoOrigin { .. }),
"empty chain must surface NoOrigin (cache-only mode), got: {err:?}",
);
anyhow::ensure!(
metrics.origin_fetches.get() == 0,
"no origin attempted => no origin_fetches bump",
);
Ok(())
}
use decdn_cache::OriginRangeRequest;
#[tokio::test]
async fn origin_range_request_len_and_empty() {
let r = OriginRangeRequest {
fetch_start: 16,
fetch_end: 48,
};
assert_eq!(r.len(), 32);
assert!(!r.is_empty());
let z = OriginRangeRequest {
fetch_start: 10,
fetch_end: 10,
};
assert!(z.is_empty());
assert_eq!(z.len(), 0);
}
#[tokio::test]
async fn http_size_returns_content_length_via_head() -> anyhow::Result<()> {
let payload: &[u8] = b"size me up";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200).insert_header("Content-Length", payload.len().to_string()),
)
.mount(&server)
.await;
let origin = HttpOrigin::parse(&server.uri())?;
anyhow::ensure!(
origin.size(hash).await? == Some(payload.len() as u64),
"HEAD Content-Length must be the canonical size",
);
Ok(())
}
#[tokio::test]
async fn http_size_missing_object_is_none() -> anyhow::Result<()> {
let hash = Hash::new(b"absent");
let server = MockServer::start().await;
let origin = HttpOrigin::parse(&server.uri())?;
anyhow::ensure!(
origin.size(hash).await?.is_none(),
"404 HEAD must degrade to None"
);
Ok(())
}
#[tokio::test]
async fn http_size_compressed_object_is_unknown() -> anyhow::Result<()> {
let payload: &[u8] = b"compressed body";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Length", payload.len().to_string())
.insert_header("Content-Encoding", "gzip"),
)
.mount(&server)
.await;
let origin = HttpOrigin::parse(&server.uri())?;
anyhow::ensure!(
origin.size(hash).await?.is_none(),
"a compressed HEAD must degrade to None"
);
Ok(())
}
#[derive(Debug)]
struct FaultingSizeOrigin;
impl Origin for FaultingSizeOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
Box::pin(async {
Err(OriginPullError::Transient(anyhow::anyhow!(
"unreachable in this test"
)))
})
}
fn size(
&self,
_hash: Hash,
) -> Pin<Box<dyn Future<Output = Result<Option<u64>, OriginPullError>> + Send + '_>> {
Box::pin(async {
Err(OriginPullError::Transient(anyhow::anyhow!(
"synthetic size-probe transport fault"
)))
})
}
}
#[tokio::test]
async fn origin_size_surfaces_a_transport_fault_when_no_origin_answers() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(FaultingSizeOrigin) as Arc<dyn Origin>],
16,
)
.await?;
let err = err_of(engine.origin_size(Hash::new(b"x")).await)?;
anyhow::ensure!(
matches!(err, CacheError::OriginError { .. }),
"expected the transport fault to surface, got {err:?}"
);
Ok(())
}
#[derive(Debug)]
struct FixedSizeOrigin(u64);
impl Origin for FixedSizeOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
Box::pin(async {
Err(OriginPullError::Transient(anyhow::anyhow!(
"unreachable in this test"
)))
})
}
fn size(
&self,
_hash: Hash,
) -> Pin<Box<dyn Future<Output = Result<Option<u64>, OriginPullError>> + Send + '_>> {
let size = self.0;
Box::pin(async move { Ok(Some(size)) })
}
}
#[tokio::test]
async fn origin_size_fault_then_answer_advances_the_chain() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(
tmp.path(),
vec![
Arc::new(FaultingSizeOrigin) as Arc<dyn Origin>,
Arc::new(FixedSizeOrigin(5)) as Arc<dyn Origin>,
],
16,
)
.await?;
let size = engine.origin_size(Hash::new(b"sized")).await?;
anyhow::ensure!(
size == Some(5),
"the healthy origin's answer wins, got {size:?}"
);
Ok(())
}
#[derive(Debug)]
struct OutboardOnlyOrigin(bytes::Bytes);
impl Origin for OutboardOnlyOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
Box::pin(async {
Err(OriginPullError::Transient(anyhow::anyhow!(
"unreachable in this test"
)))
})
}
fn fetch_outboard(
&self,
_hash: Hash,
_outboard_max_bytes: u64,
) -> Pin<
Box<dyn Future<Output = Result<decdn_cache::OutboardFetch, OriginPullError>> + Send + '_>,
> {
let ob = self.0.clone();
Box::pin(async move { Ok(decdn_cache::OutboardFetch::Found(ob)) })
}
}
#[tokio::test]
async fn outboard_probe_rejects_a_wrong_length_outboard_and_advances_the_chain()
-> anyhow::Result<()> {
use bao_tree::io::outboard::PreOrderMemOutboard;
let payload: Vec<u8> = (0..200 * 1024u32)
.map(|i| u8::try_from(i % 251).unwrap_or(0))
.collect();
let ob = PreOrderMemOutboard::create(&payload, decdn_cache::range_pull::IROH_BLOCK_SIZE);
let hash = Hash::from_bytes(*ob.root.as_bytes());
let total = u64::try_from(payload.len())?;
let correct = bytes::Bytes::from(ob.data);
anyhow::ensure!(correct.len() > 8, "test premise: non-trivial outboard");
let short = correct.slice(..correct.len() - 7);
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(
tmp.path(),
vec![
Arc::new(OutboardOnlyOrigin(short.clone())) as Arc<dyn Origin>,
Arc::new(OutboardOnlyOrigin(correct.clone())) as Arc<dyn Origin>,
],
16,
)
.await?;
let got = engine
.origin_fetch_outboard_bytes(hash, total)
.await?
.ok_or_else(|| anyhow::anyhow!("the healthy origin's outboard must win"))?;
anyhow::ensure!(got == correct, "the correct-length outboard wins the chain");
let tmp2 = tempfile::tempdir()?;
let engine2 = CacheEngine::open(
tmp2.path(),
vec![Arc::new(OutboardOnlyOrigin(short)) as Arc<dyn Origin>],
16,
)
.await?;
anyhow::ensure!(
engine2
.origin_fetch_outboard_bytes(hash, total)
.await?
.is_none(),
"a wrong-length outboard must decline, not serve"
);
Ok(())
}
#[tokio::test]
async fn origin_size_no_origin_configured_errors() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![], 16).await?;
let err = err_of(engine.origin_size(Hash::new(b"x")).await)?;
anyhow::ensure!(
matches!(err, CacheError::NoOrigin { .. }),
"expected NoOrigin, got {err:?}"
);
Ok(())
}
#[tokio::test]
async fn fs_size_returns_on_disk_length() -> anyhow::Result<()> {
let payload: &[u8] = b"on-disk canonical length";
let hash = Hash::new(payload);
let origin_dir = tempfile::tempdir()?;
seed_fs_blob(origin_dir.path(), hash, payload)?;
let origin = FilesystemOrigin::new(origin_dir.path()).await?;
anyhow::ensure!(
origin.size(hash).await? == Some(payload.len() as u64),
"fs size must be the file length",
);
anyhow::ensure!(
origin.size(Hash::new(b"never seeded")).await?.is_none(),
"absent fs object must be None",
);
Ok(())
}
#[tokio::test]
async fn export_bao_range_round_trips_and_verifies_against_root() -> anyhow::Result<()> {
use bytes::Bytes;
use decdn_cache::range_pull::align_range;
use iroh_blobs::store::mem::MemStore;
let mut payload = vec![0u8; 200 * 1024 + 1234];
let mut x: u32 = 0x1234_5678;
for b in &mut payload {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*b = x.to_le_bytes().first().copied().unwrap_or(0);
}
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
engine.get(hash).await?;
let blob_size = u64::try_from(payload.len())?;
let offset = 64 * 1024u64;
let len = 32 * 1024u64;
let wire = engine
.export_bao_range(hash, offset, len, blob_size)
.await?;
anyhow::ensure!(!wire.is_empty(), "bao export must be non-empty");
let aligned = align_range(offset, len, blob_size)?;
let mut combined = blob_size.to_le_bytes().to_vec();
combined.extend_from_slice(&wire);
let store = MemStore::new();
store
.blobs()
.import_bao_bytes(hash, aligned.chunk_ranges().clone(), Bytes::from(combined))
.await?;
let got = store
.blobs()
.export_ranges(hash, aligned.fetch_start()..aligned.fetch_end())
.concatenate()
.await?;
let start = usize::try_from(aligned.fetch_start())?;
let end = usize::try_from(aligned.fetch_end())?;
let want = payload
.get(start..end)
.ok_or_else(|| anyhow::anyhow!("aligned span out of bounds"))?;
anyhow::ensure!(got == want, "exported bao decodes to the wrong bytes");
Ok(())
}
#[tokio::test]
async fn export_bao_range_whole_blob_offset_zero() -> anyhow::Result<()> {
use bytes::Bytes;
use decdn_cache::range_pull::align_range;
use iroh_blobs::store::mem::MemStore;
let payload = b"the whole blob, served bao from offset zero with no raw path".to_vec();
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
engine.get(hash).await?;
let blob_size = u64::try_from(payload.len())?;
let wire = engine.export_bao_range(hash, 0, 0, blob_size).await?;
let aligned = align_range(0, 0, blob_size)?;
let mut combined = blob_size.to_le_bytes().to_vec();
combined.extend_from_slice(&wire);
let store = MemStore::new();
store
.blobs()
.import_bao_bytes(hash, aligned.chunk_ranges().clone(), Bytes::from(combined))
.await?;
let got = store.blobs().get_bytes(hash).await?;
anyhow::ensure!(
got.as_ref() == payload.as_slice(),
"whole-blob bao decodes to original"
);
Ok(())
}
#[tokio::test]
async fn export_bao_range_empty_blob_round_trips() -> anyhow::Result<()> {
use bytes::Bytes;
use decdn_cache::range_pull::align_range;
use iroh_blobs::store::mem::MemStore;
let payload: Vec<u8> = Vec::new();
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
engine.get(hash).await?;
let wire = engine.export_bao_range(hash, 0, 0, 0).await?;
anyhow::ensure!(wire.is_empty(), "0-byte blob has an empty bao wire form");
let aligned = align_range(0, 0, 0)?;
let mut combined = 0u64.to_le_bytes().to_vec();
combined.extend_from_slice(&wire);
let store = MemStore::new();
store
.blobs()
.import_bao_bytes(hash, aligned.chunk_ranges().clone(), Bytes::from(combined))
.await?;
let got = store.blobs().get_bytes(hash).await?;
anyhow::ensure!(got.as_ref().is_empty(), "empty blob decodes to empty bytes");
Ok(())
}
#[tokio::test]
async fn export_bao_range_empty_blob_evicted_errors() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = decdn_cache::CacheEngine::open(tmp.path(), Vec::new(), 16).await?;
let empty: Vec<u8> = Vec::new();
let hash = Hash::new(&empty);
anyhow::ensure!(engine.has(hash).await?, "empty blob is trivially present");
anyhow::ensure!(
engine.export_bao_range(hash, 0, 0, 0).await?.is_empty(),
"present empty blob serves empty"
);
engine.evict(hash).await?;
anyhow::ensure!(!engine.has(hash).await?, "evicted blob reads as absent");
let err = engine
.export_bao_range(hash, 0, 0, 0)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected an error for an evicted empty blob"))?;
anyhow::ensure!(
matches!(err, decdn_cache::CacheError::Store(_)),
"evicted empty blob must surface a Store error, got {err:?}"
);
Ok(())
}
#[tokio::test]
async fn export_bao_range_stream_matches_the_buffered_form_and_is_chunked() -> anyhow::Result<()> {
use futures_util::StreamExt;
let mut payload = vec![0u8; 200 * 1024 + 1234];
let mut x: u32 = 0x1234_5678;
for b in &mut payload {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*b = x.to_le_bytes().first().copied().unwrap_or(0);
}
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
engine.get(hash).await?;
let blob_size = u64::try_from(payload.len())?;
for (offset, len) in [(0u64, 0u64), (64 * 1024, 32 * 1024)] {
let buffered = engine
.export_bao_range(hash, offset, len, blob_size)
.await?;
let mut stream = engine
.export_bao_range_stream(hash, offset, len, blob_size)
.await?;
let mut streamed = Vec::new();
let mut items = 0usize;
let mut largest = 0usize;
while let Some(item) = stream.next().await {
let bytes = item?;
anyhow::ensure!(
!bytes.is_empty(),
"export stream must never yield an empty item at ({offset}, {len})"
);
largest = largest.max(bytes.len());
items += 1;
streamed.extend_from_slice(&bytes);
}
anyhow::ensure!(
streamed == buffered.as_ref(),
"streamed export differs from the buffered export at ({offset}, {len})"
);
anyhow::ensure!(
items > 1,
"export stream collapsed to {items} item(s) at ({offset}, {len}) — it must yield \
per-bao-item so the serve path holds O(chunk group), not O(blob)"
);
anyhow::ensure!(
largest <= 16 * 1024,
"export stream yielded a {largest}-byte item at ({offset}, {len}); items must stay \
bounded by one chunk group"
);
}
Ok(())
}
#[tokio::test]
async fn export_bao_range_stream_empty_blob_yields_no_items() -> anyhow::Result<()> {
use futures_util::StreamExt;
let payload: Vec<u8> = Vec::new();
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
engine.get(hash).await?;
let mut stream = engine.export_bao_range_stream(hash, 0, 0, 0).await?;
let mut items = 0usize;
while let Some(item) = stream.next().await {
item?;
items += 1;
}
anyhow::ensure!(items == 0, "empty blob must yield no items, got {items}");
Ok(())
}
#[tokio::test]
async fn populate_fills_via_the_streaming_commit_path() -> anyhow::Result<()> {
let mut payload = vec![0u8; 12 * 1024 * 1024];
let mut x: u32 = 0x9E37_79B9;
for b in &mut payload {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*b = x.to_le_bytes().first().copied().unwrap_or(0);
}
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
anyhow::ensure!(!engine.has(hash).await?, "blob must start absent");
engine.populate(hash).await?;
anyhow::ensure!(
engine.has(hash).await?,
"populate must commit the blob to the local store"
);
let got = engine.get(hash).await?;
anyhow::ensure!(
got.as_ref() == payload.as_slice(),
"populated blob does not match the origin payload"
);
Ok(())
}
#[tokio::test]
async fn populate_fills_via_the_buffered_commit_path() -> anyhow::Result<()> {
let mut payload = vec![0u8; 64 * 1024];
let mut x: u32 = 0x0BAD_F00D;
for b in &mut payload {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*b = x.to_le_bytes().first().copied().unwrap_or(0);
}
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
anyhow::ensure!(!engine.has(hash).await?, "blob must start absent");
engine.populate(hash).await?;
anyhow::ensure!(
engine.has(hash).await?,
"populate must commit the blob via the buffered arm too"
);
let got = engine.get(hash).await?;
anyhow::ensure!(
got.as_ref() == payload.as_slice(),
"buffered-arm populate stored the wrong bytes"
);
Ok(())
}
#[tokio::test]
async fn get_returns_bytes_for_a_small_origin_blob() -> anyhow::Result<()> {
let payload = b"small enough to be drained, not streamed".to_vec();
let hash = Hash::new(&payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{}", hash.to_hex())))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
.mount(&server)
.await;
let (engine, _tmp) = build_engine(&server.uri()).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
got.as_ref() == payload.as_slice(),
"get must return the drained payload, not an empty or missing blob"
);
Ok(())
}