use std::io::Write;
use std::sync::Arc;
use std::time::Duration;
use aws_sdk_s3::Client;
use aws_sdk_s3::operation::get_object::{GetObjectError, GetObjectOutput};
use aws_sdk_s3::types::error::NoSuchKey;
use aws_smithy_mocks::{Rule, RuleMode, mock, mock_client};
use aws_smithy_types::byte_stream::ByteStream;
use aws_smithy_types::retry::RetryConfig;
use decdn_cache::{
CacheEngine, CacheError, DecompressMode, Hash, Origin, OriginError, OriginFetch,
OriginPullError, RetryPolicy, S3Origin, SupportedEncoding,
};
const BUCKET: &str = "decdn-blobs";
fn mock_s3_client(rules: &[&Rule]) -> Client {
mock_client!(aws_sdk_s3, RuleMode::Sequential, rules, |conf| {
conf.retry_config(RetryConfig::disabled())
})
}
fn mock_s3_client_match_any(rules: &[&Rule]) -> Client {
mock_client!(aws_sdk_s3, RuleMode::MatchAny, rules, |conf| {
conf.retry_config(RetryConfig::disabled())
})
}
fn s3_origin(client: Client, prefix: &str) -> anyhow::Result<S3Origin> {
S3Origin::from_parts(client, BUCKET, prefix)
}
fn expected_key(prefix: &str, hash: Hash) -> String {
let hex = hash.to_hex();
let shard = hex.get(..2).unwrap_or("");
format!("{prefix}{shard}/{}", hex.as_str())
}
#[tokio::test]
async fn fetch_returns_origin_bytes_on_success() -> anyhow::Result<()> {
let payload: &[u8] = b"hello, decdn from s3";
let hash = Hash::new(payload);
let key = expected_key("", hash);
let key_for_match = key.clone();
let rule = mock!(Client::get_object)
.match_requests(move |req| {
req.bucket() == Some(BUCKET) && req.key() == Some(&key_for_match)
})
.then_output(|| {
GetObjectOutput::builder()
.body(ByteStream::from_static(b"hello, decdn from s3"))
.build()
});
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
let bytes = origin
.fetch(hash, 16 * 1024 * 1024)
.await?
.collect_to_bytes()
.await?
.ok_or_else(|| anyhow::anyhow!("expected Found, got NotFound"))?;
anyhow::ensure!(&bytes[..] == payload, "got: {bytes:?}");
anyhow::ensure!(rule.num_calls() == 1, "single fetch must hit the rule once");
Ok(())
}
#[tokio::test]
async fn fetch_no_such_key_maps_to_not_found() -> anyhow::Result<()> {
let hash = Hash::new(b"absent");
let rule = mock!(Client::get_object)
.then_error(|| GetObjectError::NoSuchKey(NoSuchKey::builder().build()));
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
match origin.fetch(hash, 16 * 1024 * 1024).await? {
OriginFetch::NotFound => Ok(()),
OriginFetch::Found { .. } => anyhow::bail!("expected NotFound on NoSuchKey"),
OriginFetch::AlreadyAdmitted => {
anyhow::bail!("S3Origin never admits directly; expected NotFound on NoSuchKey")
}
}
}
#[tokio::test]
async fn fetch_bare_http_404_maps_to_not_found() -> anyhow::Result<()> {
let hash = Hash::new(b"absent");
let rule = mock!(Client::get_object)
.sequence()
.http_status(404, None)
.build();
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
match origin.fetch(hash, 16 * 1024 * 1024).await? {
OriginFetch::NotFound => Ok(()),
OriginFetch::Found { .. } => anyhow::bail!("expected NotFound on HTTP 404"),
OriginFetch::AlreadyAdmitted => {
anyhow::bail!("S3Origin never admits directly; expected NotFound on HTTP 404")
}
}
}
#[tokio::test]
async fn fetch_404_with_no_such_bucket_is_permanent_not_not_found() -> anyhow::Result<()> {
let hash = Hash::new(b"any");
let body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
<Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist</Message>\
<BucketName>typo-bucket</BucketName></Error>"
.to_string();
let rule = mock!(Client::get_object)
.sequence()
.http_status(404, Some(body))
.build();
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
match origin.fetch(hash, 16 * 1024 * 1024).await {
Ok(OriginFetch::NotFound) => anyhow::bail!(
"NoSuchBucket masked as NotFound — operator would see 'missing blob' \
instead of the real config error. fix in classify_get_object_error."
),
Ok(OriginFetch::Found { .. }) => anyhow::bail!("expected error, got Found"),
Ok(OriginFetch::AlreadyAdmitted) => {
anyhow::bail!("S3Origin never admits directly; expected error, got AlreadyAdmitted")
}
Err(err) => {
anyhow::ensure!(
matches!(err, OriginPullError::Permanent(_)),
"NoSuchBucket must be Permanent, got: {err:?}"
);
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains("NoSuchBucket"),
"error context lost the AWS error code; would make ops debugging harder: {msg}"
);
Ok(())
}
}
}
#[tokio::test]
async fn fetch_404_with_access_denied_is_permanent_not_not_found() -> anyhow::Result<()> {
let hash = Hash::new(b"forbidden");
let body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>"
.to_string();
let rule = mock!(Client::get_object)
.sequence()
.http_status(404, Some(body))
.build();
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
let err = origin
.fetch(hash, 16 * 1024 * 1024)
.await
.err()
.ok_or_else(|| {
anyhow::anyhow!(
"404 + AccessDenied masked as NotFound — IAM misconfig would silently \
report 'missing blob'. fix in classify_get_object_error."
)
})?;
anyhow::ensure!(
matches!(err, OriginPullError::Permanent(_)),
"AccessDenied must be Permanent, got: {err:?}"
);
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains("AccessDenied"),
"error context lost the AWS error code: {msg}"
);
Ok(())
}
#[tokio::test]
async fn fetch_5xx_classifies_as_transient_with_no_internal_retry() -> anyhow::Result<()> {
let hash = Hash::new(b"transient");
let rule = mock!(Client::get_object)
.sequence()
.http_status(503, None)
.build();
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
let err = origin
.fetch(hash, 16 * 1024 * 1024)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("503 should produce a Transient error"))?;
anyhow::ensure!(
matches!(err, OriginPullError::Transient(_)),
"expected Transient for 5xx, got: {err:?}"
);
anyhow::ensure!(
rule.num_calls() == 1,
"SDK internal retry must be disabled; expected 1 dispatch, got {}",
rule.num_calls()
);
Ok(())
}
#[tokio::test]
async fn cache_engine_retries_transient_via_origin_retry_policy() -> anyhow::Result<()> {
let payload: &[u8] = b"recovered after engine retry";
let hash = Hash::new(payload);
let rule = mock!(Client::get_object)
.sequence()
.http_status(503, None)
.times(2)
.output(|| {
GetObjectOutput::builder()
.body(ByteStream::from_static(b"recovered after engine retry"))
.build()
})
.build();
let client = mock_s3_client(&[&rule]);
let origin: Arc<dyn Origin> = Arc::new(s3_origin(client, "")?);
let tmp = tempfile::tempdir()?;
let policy = RetryPolicy {
max_retries: 3,
initial_backoff_ms: 1,
max_backoff_ms: 4,
jitter_ratio: 0.0,
buffered_max_bytes: 0,
};
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
decdn_cache::PinnedHashes::empty(),
policy,
decdn_cache::CircuitBreakerPolicy::default(),
None,
Duration::ZERO,
)
.await?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload, "got: {got:?}");
anyhow::ensure!(
rule.num_calls() == 3,
"expected 3 outer-policy retries (2 transient + success); SDK internal retry may have leaked back on. got {}",
rule.num_calls()
);
Ok(())
}
#[tokio::test]
async fn fetch_4xx_classifies_as_permanent_and_does_not_retry() -> anyhow::Result<()> {
let hash = Hash::new(b"forbidden");
let rule = mock!(Client::get_object)
.sequence()
.http_status(403, None)
.build();
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
let err = origin
.fetch(hash, 16 * 1024 * 1024)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("4xx should produce an error"))?;
anyhow::ensure!(
matches!(err, OriginPullError::Permanent(_)),
"expected Permanent for 4xx, got: {err:?}"
);
anyhow::ensure!(
rule.num_calls() == 1,
"permanent 4xx must not be retried by the SDK; got {} calls",
rule.num_calls()
);
Ok(())
}
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 fetch_with_content_encoding_gzip_is_decompressed() -> anyhow::Result<()> {
let payload: &[u8] = b"hello, gzipped s3 world! repeat repeat repeat repeat";
let hash = Hash::new(payload);
let compressed = gzip(payload)?;
let rule = mock!(Client::get_object).then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(compressed.clone()))
.content_encoding("gzip")
.build()
});
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
let bytes = origin
.fetch(hash, 16 * 1024 * 1024)
.await?
.collect_to_bytes()
.await?
.ok_or_else(|| anyhow::anyhow!("expected Found, got NotFound"))?;
anyhow::ensure!(
&bytes[..] == payload,
"gzip body should decode to canonical payload"
);
Ok(())
}
#[tokio::test]
async fn fetch_with_content_encoding_zstd_is_decompressed() -> anyhow::Result<()> {
let payload: &[u8] = b"hello, zstd s3! and a longer body to compress meaningfully xxxxx";
let hash = Hash::new(payload);
let compressed = zstd_compress(payload)?;
let rule = mock!(Client::get_object).then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(compressed.clone()))
.content_encoding("zstd")
.build()
});
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
let bytes = origin
.fetch(hash, 16 * 1024 * 1024)
.await?
.collect_to_bytes()
.await?
.ok_or_else(|| anyhow::anyhow!("expected Found, got NotFound"))?;
anyhow::ensure!(&bytes[..] == payload, "zstd body should decode to payload");
Ok(())
}
#[tokio::test]
async fn fetch_with_strict_mode_rejects_gzip_encoding() -> anyhow::Result<()> {
let hash = Hash::new(b"gzipped-strict");
let rule = mock!(Client::get_object).then_output(|| {
GetObjectOutput::builder()
.body(ByteStream::from_static(
b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03",
))
.content_encoding("gzip")
.build()
});
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?.with_decompress_mode(DecompressMode::Strict);
let err = origin
.fetch(hash, 16 * 1024 * 1024)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("strict mode must reject a compressed body"))?;
let msg = match &err {
OriginPullError::Permanent(e) => format!("{e:#}"),
OriginPullError::Transient(e) => {
anyhow::bail!("Content-Encoding rejection must be Permanent, was Transient: {e:#}");
}
};
anyhow::ensure!(
msg.contains("Content-Encoding") && msg.contains("canonical bytes"),
"strict rejection lost its actionable runbook hint: {msg}"
);
Ok(())
}
#[tokio::test]
async fn fetch_with_unknown_encoding_is_permanent() -> anyhow::Result<()> {
let hash = Hash::new(b"brotli-body");
let rule = mock!(Client::get_object).then_output(|| {
GetObjectOutput::builder()
.body(ByteStream::from_static(b"\x00\x01\x02brotli-ish"))
.content_encoding("br")
.build()
});
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
let err = origin
.fetch(hash, 16 * 1024 * 1024)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("unknown encoding must be rejected"))?;
let msg = match &err {
OriginPullError::Permanent(e) => format!("{e:#}"),
OriginPullError::Transient(e) => {
anyhow::bail!("unknown-encoding rejection must be Permanent, was Transient: {e:#}");
}
};
anyhow::ensure!(
msg.contains("Content-Encoding") && msg.contains("br"),
"error should name the unsupported encoding: {msg}"
);
Ok(())
}
#[tokio::test]
async fn fetch_with_content_encoding_identity_is_accepted() -> anyhow::Result<()> {
let payload: &[u8] = b"plain bytes";
let hash = Hash::new(payload);
let rule = mock!(Client::get_object).then_output(|| {
GetObjectOutput::builder()
.body(ByteStream::from_static(b"plain bytes"))
.content_encoding("identity")
.build()
});
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
let bytes = origin
.fetch(hash, 16 * 1024 * 1024)
.await?
.collect_to_bytes()
.await?
.ok_or_else(|| anyhow::anyhow!("expected Found"))?;
anyhow::ensure!(&bytes[..] == payload);
Ok(())
}
#[tokio::test]
async fn fetch_oversize_body_streams_through_for_engine_cap() -> anyhow::Result<()> {
let payload = vec![0xABu8; 256 * 1024];
let hash = Hash::new(&payload);
let body = payload.clone();
let rule = mock!(Client::get_object).then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(body.clone()))
.build()
});
let client = mock_s3_client(&[&rule]);
let origin = s3_origin(client, "")?;
let bytes = origin
.fetch(hash, 64 * 1024)
.await?
.collect_to_bytes()
.await?
.ok_or_else(|| anyhow::anyhow!("expected Found"))?;
anyhow::ensure!(
bytes.len() == payload.len(),
"stream truncated: {} bytes vs {} in payload",
bytes.len(),
payload.len()
);
Ok(())
}
#[tokio::test]
async fn fetch_applies_prefix_with_sharded_key_layout() -> anyhow::Result<()> {
let payload: &[u8] = b"prefixed bytes";
let hash = Hash::new(payload);
let prefix = "blobs/";
let key = expected_key(prefix, hash);
let key_for_match = key.clone();
let rule = mock!(Client::get_object)
.match_requests(move |req| {
req.bucket() == Some(BUCKET) && req.key() == Some(&key_for_match)
})
.then_output(|| {
GetObjectOutput::builder()
.body(ByteStream::from_static(b"prefixed bytes"))
.build()
});
let client = mock_s3_client_match_any(&[&rule]);
let origin = s3_origin(client, prefix)?;
let bytes = origin
.fetch(hash, 16 * 1024 * 1024)
.await?
.collect_to_bytes()
.await?
.ok_or_else(|| anyhow::anyhow!("expected Found"))?;
anyhow::ensure!(&bytes[..] == payload);
anyhow::ensure!(rule.num_calls() == 1, "rule must match the prefixed key");
Ok(())
}
#[tokio::test]
async fn cache_engine_miss_pulls_from_s3_and_caches() -> anyhow::Result<()> {
let payload: &[u8] = b"engine-pull through s3";
let hash = Hash::new(payload);
let rule = mock!(Client::get_object).then_output(|| {
GetObjectOutput::builder()
.body(ByteStream::from_static(b"engine-pull through s3"))
.build()
});
let client = mock_s3_client(&[&rule]);
let origin: Arc<dyn Origin> = Arc::new(s3_origin(client, "")?);
let tmp = tempfile::tempdir()?;
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?;
let got = engine.get(hash).await?;
anyhow::ensure!(&got[..] == payload, "first get must return origin bytes");
anyhow::ensure!(engine.has(hash).await?, "blob must be cached after miss");
let got2 = engine.get(hash).await?;
anyhow::ensure!(
&got2[..] == payload,
"second get must be served from the local cache"
);
anyhow::ensure!(
rule.num_calls() == 1,
"second get must not re-hit the origin; got {} dispatches",
rule.num_calls()
);
Ok(())
}
#[tokio::test]
async fn cache_engine_rejects_s3_body_larger_than_max_blob_bytes() -> anyhow::Result<()> {
let payload = vec![0xCDu8; 4 * 1024 * 1024];
let hash = Hash::new(&payload);
let body = payload.clone();
let rule = mock!(Client::get_object).then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(body.clone()))
.build()
});
let client = mock_s3_client(&[&rule]);
let origin: Arc<dyn Origin> = Arc::new(s3_origin(client, "")?);
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
1, decdn_cache::PinnedHashes::empty(),
RetryPolicy::disabled(),
decdn_cache::CircuitBreakerPolicy::default(),
None,
Duration::ZERO,
)
.await?;
let err = engine
.get(hash)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("oversize body must be rejected at engine cap"))?;
anyhow::ensure!(
matches!(err, CacheError::BlobTooLarge { .. }),
"expected BlobTooLarge from engine cap, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn cache_engine_s3_truncated_gzip_is_permanent_decompression_failed() -> anyhow::Result<()> {
let payload: &[u8] = b"truncate me, please, but only the gzip wrapper xxxxxxxxxxxx";
let hash = Hash::new(payload);
let mut compressed = gzip(payload)?;
let drop = compressed.len().saturating_sub(20);
compressed.truncate(drop);
let rule = mock!(Client::get_object).then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(compressed.clone()))
.content_encoding("gzip")
.build()
});
let client = mock_s3_client(&[&rule]);
let origin: Arc<dyn Origin> = Arc::new(s3_origin(client, "")?);
let tmp = tempfile::tempdir()?;
let policy = RetryPolicy {
max_retries: 3,
initial_backoff_ms: 1,
max_backoff_ms: 4,
jitter_ratio: 0.0,
buffered_max_bytes: 4 << 20,
};
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin as Arc<dyn Origin>],
16,
decdn_cache::PinnedHashes::empty(),
policy,
decdn_cache::CircuitBreakerPolicy::default(),
None,
Duration::ZERO,
)
.await?;
let err = engine
.get(hash)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("truncated gzip must be rejected"))?;
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:?}"
);
anyhow::ensure!(
rule.num_calls() == 1,
"decoder failure is Permanent; retry must not re-dispatch, got {}",
rule.num_calls()
);
Ok(())
}
#[tokio::test]
async fn cache_engine_s3_rejects_decompression_bomb() -> anyhow::Result<()> {
let payload = vec![0u8; 4 * 1024 * 1024];
let hash = 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 content_length = i64::try_from(compressed.len())?;
let rule = mock!(Client::get_object).then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(compressed.clone()))
.content_encoding("gzip")
.content_length(content_length)
.build()
});
let client = mock_s3_client(&[&rule]);
let origin: Arc<dyn Origin> = Arc::new(s3_origin(client, "")?);
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(
tmp.path(),
vec![origin as Arc<dyn Origin>],
1, )
.await?;
let err =
engine.get(hash).await.err().ok_or_else(|| {
anyhow::anyhow!("decompression bomb must be rejected at the decoded cap")
})?;
anyhow::ensure!(
matches!(err, CacheError::BlobTooLarge { .. }),
"expected BlobTooLarge from bomb cap, got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn cache_engine_s3_blake3_verify_runs_over_decompressed_bytes() -> anyhow::Result<()> {
let payload: &[u8] = b"verify-after-decompress on the s3 path, 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 rule = mock!(Client::get_object).then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(compressed.clone()))
.content_encoding("gzip")
.build()
});
let client = mock_s3_client(&[&rule]);
let origin: Arc<dyn Origin> = Arc::new(s3_origin(client, "")?);
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let err =
engine.get(raw_hash).await.err().ok_or_else(|| {
anyhow::anyhow!("compressed-hash request must mismatch the decoded body")
})?;
anyhow::ensure!(
matches!(err, CacheError::HashMismatch { .. }),
"expected HashMismatch (verify ran over decompressed form), got: {err:?}"
);
Ok(())
}
#[tokio::test]
async fn cache_engine_s3_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 content_length = i64::try_from(compressed.len())?;
let rule = mock!(Client::get_object).then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(compressed.clone()))
.content_encoding("gzip")
.content_length(content_length)
.build()
});
let client = mock_s3_client(&[&rule]);
let origin: Arc<dyn Origin> = Arc::new(s3_origin(client, "")?);
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.len() == payload.len() && got[..] == payload[..],
"in-bounds compressed blob must decode and cache, got {} bytes",
got.len()
);
Ok(())
}
#[tokio::test]
async fn cache_engine_surfaces_not_found_for_no_such_key() -> anyhow::Result<()> {
let hash = Hash::new(b"missing");
let rule = mock!(Client::get_object)
.then_error(|| GetObjectError::NoSuchKey(NoSuchKey::builder().build()));
let client = mock_s3_client(&[&rule]);
let origin: Arc<dyn Origin> = Arc::new(s3_origin(client, "")?);
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 16).await?;
let err = engine
.get(hash)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected NotFound"))?;
anyhow::ensure!(
matches!(err, CacheError::NotFound { .. }),
"expected CacheError::NotFound, got: {err:?}"
);
Ok(())
}
use decdn_cache::{OriginRangeFetch, OriginRangeRequest};
fn make_blob(len: usize) -> Vec<u8> {
let mut v = vec![0u8; len];
let mut x: u32 = 0x9e37_79b9;
for b in &mut v {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*b = x.to_le_bytes().first().copied().unwrap_or(0);
}
v
}
#[tokio::test]
async fn fetch_range_data_returns_span() -> anyhow::Result<()> {
let blob = make_blob(200 * 1024);
let hash = Hash::new(&blob);
let data_key = expected_key("", hash);
let (start, end) = (16 * 1024usize, 48 * 1024usize);
let span = blob.get(start..end).unwrap_or_default().to_vec();
let data_match = data_key.clone();
let data_rule = mock!(Client::get_object)
.match_requests(move |req| req.key() == Some(&data_match) && req.range().is_some())
.then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(span.clone()))
.build()
});
let client = mock_s3_client_match_any(&[&data_rule]);
let origin = s3_origin(client, "")?;
let req = OriginRangeRequest {
fetch_start: start as u64,
fetch_end: end as u64,
};
match origin.fetch_range_data(hash, req).await? {
OriginRangeFetch::Ranged { data } => {
anyhow::ensure!(
data.as_ref() == blob.get(start..end).unwrap_or_default(),
"span mismatch",
);
}
other => anyhow::bail!("expected Ranged, got {other:?}"),
}
anyhow::ensure!(data_rule.num_calls() == 1, "one ranged data GET");
Ok(())
}
#[tokio::test]
async fn fetch_range_data_missing_object_is_unsupported() -> anyhow::Result<()> {
let blob = make_blob(64 * 1024);
let hash = Hash::new(&blob);
let data_key = expected_key("", hash);
let data_match = data_key.clone();
let data_rule = mock!(Client::get_object)
.match_requests(move |req| req.key() == Some(&data_match))
.then_error(|| GetObjectError::NoSuchKey(NoSuchKey::builder().build()));
let client = mock_s3_client_match_any(&[&data_rule]);
let origin = s3_origin(client, "")?;
let req = OriginRangeRequest {
fetch_start: 0,
fetch_end: 16 * 1024,
};
anyhow::ensure!(
matches!(
origin.fetch_range_data(hash, req).await?,
OriginRangeFetch::Unsupported
),
"missing object must degrade to Unsupported",
);
Ok(())
}
#[tokio::test]
async fn fetch_range_data_wrong_length_span_degrades() -> anyhow::Result<()> {
let blob = make_blob(200 * 1024);
let hash = Hash::new(&blob);
let data_key = expected_key("", hash);
let data_match = data_key.clone();
let data_rule = mock!(Client::get_object)
.match_requests(move |req| req.key() == Some(&data_match) && req.range().is_some())
.then_output(|| {
GetObjectOutput::builder()
.body(ByteStream::from_static(b"short!!!"))
.build()
});
let client = mock_s3_client_match_any(&[&data_rule]);
let origin = s3_origin(client, "")?;
let req = OriginRangeRequest {
fetch_start: 16 * 1024,
fetch_end: 48 * 1024,
};
anyhow::ensure!(
matches!(
origin.fetch_range_data(hash, req).await?,
OriginRangeFetch::Unsupported
),
"wrong-length span must degrade",
);
Ok(())
}
use aws_sdk_s3::operation::head_object::{HeadObjectError, HeadObjectOutput};
use aws_sdk_s3::types::error::NotFound;
#[tokio::test]
async fn size_returns_head_object_content_length() -> anyhow::Result<()> {
let hash = Hash::new(b"size-probe");
let key = expected_key("", hash);
let rule = mock!(Client::head_object)
.match_requests(move |req| req.key() == Some(&key))
.then_output(|| HeadObjectOutput::builder().content_length(4096).build());
let client = mock_s3_client_match_any(&[&rule]);
let origin = s3_origin(client, "")?;
anyhow::ensure!(
origin.size(hash).await? == Some(4096),
"size must be the HeadObject Content-Length"
);
anyhow::ensure!(rule.num_calls() == 1, "exactly one HeadObject");
Ok(())
}
#[tokio::test]
async fn size_compressed_object_is_unknown() -> anyhow::Result<()> {
let hash = Hash::new(b"compressed");
let rule = mock!(Client::head_object).then_output(|| {
HeadObjectOutput::builder()
.content_length(1024)
.content_encoding("gzip")
.build()
});
let client = mock_s3_client_match_any(&[&rule]);
let origin = s3_origin(client, "")?;
anyhow::ensure!(
origin.size(hash).await?.is_none(),
"compressed object size must be unknown"
);
Ok(())
}
#[tokio::test]
async fn size_missing_object_is_none() -> anyhow::Result<()> {
let hash = Hash::new(b"absent");
let rule = mock!(Client::head_object)
.then_error(|| HeadObjectError::NotFound(NotFound::builder().build()));
let client = mock_s3_client_match_any(&[&rule]);
let origin = s3_origin(client, "")?;
anyhow::ensure!(
origin.size(hash).await?.is_none(),
"missing object size must be None"
);
Ok(())
}
use decdn_cache::OutboardFetch;
#[tokio::test]
async fn fetch_outboard_returns_sibling_obao4() -> anyhow::Result<()> {
let hash = Hash::new(b"s3-outboard-marker");
let obao4_key = format!("{}.obao4", expected_key("", hash));
let outboard_bytes = vec![0xCDu8; 4096];
let obao4_match = obao4_key.clone();
let body = outboard_bytes.clone();
let obao4_rule = mock!(Client::get_object)
.match_requests(move |req| req.key() == Some(&obao4_match))
.then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(body.clone()))
.build()
});
let client = mock_s3_client_match_any(&[&obao4_rule]);
let origin = s3_origin(client, "")?;
match origin.fetch_outboard(hash, 1 << 20).await? {
OutboardFetch::Found(bytes) => {
anyhow::ensure!(
bytes.as_ref() == outboard_bytes.as_slice(),
"outboard bytes mismatch"
);
}
other => anyhow::bail!("expected Found, got {other:?}"),
}
anyhow::ensure!(obao4_rule.num_calls() == 1, "exactly one outboard GET");
Ok(())
}
#[tokio::test]
async fn fetch_outboard_missing_sibling_is_not_found() -> anyhow::Result<()> {
let hash = Hash::new(b"s3-outboard-missing-marker");
let obao4_key = format!("{}.obao4", expected_key("", hash));
let obao4_match = obao4_key.clone();
let obao4_rule = mock!(Client::get_object)
.match_requests(move |req| req.key() == Some(&obao4_match))
.then_error(|| GetObjectError::NoSuchKey(NoSuchKey::builder().build()));
let client = mock_s3_client_match_any(&[&obao4_rule]);
let origin = s3_origin(client, "")?;
anyhow::ensure!(
matches!(
origin.fetch_outboard(hash, 1 << 20).await?,
OutboardFetch::NotFound
),
"missing sibling must be NotFound",
);
Ok(())
}
#[tokio::test]
async fn fetch_outboard_oversize_degrades_without_buffering() -> anyhow::Result<()> {
let hash = Hash::new(b"s3-outboard-oversize-marker");
let obao4_key = format!("{}.obao4", expected_key("", hash));
let huge_outboard = vec![0x5Au8; 8 * 1024 * 1024];
let obao4_match = obao4_key.clone();
let obao4_rule = mock!(Client::get_object)
.match_requests(move |req| req.key() == Some(&obao4_match))
.then_output(move || {
GetObjectOutput::builder()
.body(ByteStream::from(huge_outboard.clone()))
.build()
});
let client = mock_s3_client_match_any(&[&obao4_rule]);
let origin = s3_origin(client, "")?;
anyhow::ensure!(
matches!(
origin.fetch_outboard(hash, 4 * 1024).await?,
OutboardFetch::Unsupported
),
"oversize outboard must degrade to Unsupported",
);
anyhow::ensure!(
obao4_rule.num_calls() == 1,
"outboard GET issued exactly once"
);
Ok(())
}