use std::io::Write;
use std::sync::Arc;
use decdn_cache::{
CacheEngine, Hash, Origin, OriginPullError, S3Credentials, S3Origin, S3OriginConfig,
parse_origin_url,
};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const BUCKET: &str = "decdn-blobs";
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)?)
}
fn key_for(hash: Hash) -> String {
let hex = hash.to_hex();
let shard = hex.get(..2).unwrap_or("");
format!("{shard}/{}", hex.as_str())
}
async fn serve_s3_object(
hash: Hash,
encoding: Option<&str>,
body: Vec<u8>,
) -> anyhow::Result<MockServer> {
let server = MockServer::start().await;
let mut tmpl = ResponseTemplate::new(200);
if let Some(enc) = encoding {
tmpl = tmpl.insert_header("Content-Encoding", enc);
}
let tmpl = tmpl.set_body_bytes(body);
Mock::given(method("GET"))
.and(path(format!("/{BUCKET}/{}", key_for(hash))))
.respond_with(tmpl)
.mount(&server)
.await;
Ok(server)
}
async fn s3_origin_at(endpoint: &str) -> anyhow::Result<S3Origin> {
let cfg = S3OriginConfig {
bucket: BUCKET.to_string(),
region: "us-east-1".to_string(),
endpoint_url: Some(parse_origin_url(endpoint)?),
path_style: true,
prefix: String::new(),
credentials: Some(S3Credentials::Static {
access_key_id: "AKIA-test-fake".to_string(),
secret_access_key: "secret-fake".to_string(),
session_token: None,
}),
};
S3Origin::new(&cfg).await
}
#[tokio::test]
async fn s3_origin_decompresses_gzip_over_http() -> anyhow::Result<()> {
let payload: &[u8] = b"hello, gzipped s3 over the wire! repeat repeat repeat repeat";
let hash = Hash::new(payload);
let server = serve_s3_object(hash, Some("gzip"), gzip(payload)?).await?;
let origin = s3_origin_at(&server.uri()).await?;
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 over the HTTP wire path"
);
Ok(())
}
#[tokio::test]
async fn s3_origin_decompresses_zstd_over_http() -> anyhow::Result<()> {
let payload: &[u8] = b"hello, zstd s3 over the wire! and a longer body to compress xxxxx";
let hash = Hash::new(payload);
let server = serve_s3_object(hash, Some("zstd"), zstd_compress(payload)?).await?;
let origin = s3_origin_at(&server.uri()).await?;
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 canonical payload over the HTTP wire path"
);
Ok(())
}
#[tokio::test]
async fn s3_origin_rejects_brotli_encoding_over_http() -> anyhow::Result<()> {
let hash = Hash::new(b"would-be-brotli-body");
let server = serve_s3_object(hash, Some("br"), b"\x00\x01\x02brotli-ish".to_vec()).await?;
let origin = s3_origin_at(&server.uri()).await?;
let err = origin
.fetch(hash, 16 * 1024 * 1024)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("unknown `br` 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 `br` encoding: {msg}"
);
Ok(())
}
#[tokio::test]
async fn cache_engine_pulls_gzip_from_s3_over_http_and_caches() -> anyhow::Result<()> {
let payload: &[u8] = b"engine pull of a gzip blob through the s3 wire path, then cached";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{BUCKET}/{}", key_for(hash))))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "gzip")
.set_body_bytes(gzip(payload)?),
)
.expect(1)
.mount(&server)
.await;
let origin: Arc<dyn Origin> = Arc::new(s3_origin_at(&server.uri()).await?);
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![origin], 16).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
&got[..] == payload,
"first get must return canonical (decompressed) bytes"
);
anyhow::ensure!(
engine.has(hash).await?,
"blob must be cached after the gzip miss"
);
let got2 = engine.get(hash).await?;
anyhow::ensure!(
&got2[..] == payload,
"second get must be served from the local cache"
);
Ok(())
}
#[tokio::test]
async fn cache_engine_pulls_zstd_from_s3_over_http_and_caches() -> anyhow::Result<()> {
let payload: &[u8] = b"engine pull of a zstd blob through the s3 wire path xxxxxxxxxxxx";
let hash = Hash::new(payload);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{BUCKET}/{}", key_for(hash))))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Encoding", "zstd")
.set_body_bytes(zstd_compress(payload)?),
)
.expect(1)
.mount(&server)
.await;
let origin: Arc<dyn Origin> = Arc::new(s3_origin_at(&server.uri()).await?);
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![origin], 16).await?;
let got = engine.get(hash).await?;
anyhow::ensure!(
&got[..] == payload,
"engine must return canonical bytes for a zstd-encoded S3 object"
);
anyhow::ensure!(
engine.has(hash).await?,
"blob must be cached after the zstd miss"
);
let got2 = engine.get(hash).await?;
anyhow::ensure!(
&got2[..] == payload,
"second get must be served from the local cache"
);
Ok(())
}
#[tokio::test]
async fn s3_origin_passes_through_uncompressed_over_http() -> anyhow::Result<()> {
let payload: &[u8] = b"plain uncompressed s3 bytes over the wire";
let hash = Hash::new(payload);
let server = serve_s3_object(hash, None, payload.to_vec()).await?;
let origin = s3_origin_at(&server.uri()).await?;
let bytes = origin
.fetch(hash, 16 * 1024 * 1024)
.await?
.collect_to_bytes()
.await?
.ok_or_else(|| anyhow::anyhow!("expected Found for an uncompressed object"))?;
anyhow::ensure!(&bytes[..] == payload, "uncompressed body must pass through");
Ok(())
}