use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use super::{Cache, CacheEntry};
use crate::s3::S3Client;
pub struct R2Cache {
client: Arc<S3Client>,
}
impl R2Cache {
pub fn new(client: Arc<S3Client>) -> Self {
Self { client }
}
}
impl Cache for R2Cache {
async fn get(&self, key: &str) -> Option<CacheEntry> {
let s3_key = sanitize_key(key)?;
let resp = match self.client.get_object(&s3_key).await {
Ok(Some(resp)) => resp,
Ok(None) => return None,
Err(e) => {
tracing::warn!(error = %e, key = %s3_key, "R2 cache get failed");
return None;
}
};
let content_type = if resp.content_type == "application/octet-stream" {
detect_content_type(&resp.data).to_string()
} else {
resp.content_type
};
Some(CacheEntry {
data: resp.data,
content_type,
created_at: now_unix(),
})
}
async fn put(&self, key: &str, entry: CacheEntry) {
let Some(s3_key) = sanitize_key(key) else {
return;
};
if let Err(e) = self
.client
.put_object(&s3_key, entry.data, &entry.content_type)
.await
{
tracing::warn!(error = %e, key = %s3_key, "R2 cache put failed");
}
}
async fn delete(&self, key: &str) -> bool {
let Some(s3_key) = sanitize_key(key) else {
return false;
};
match self.client.delete_object(&s3_key).await {
Ok(deleted) => deleted,
Err(e) => {
tracing::warn!(error = %e, key = %s3_key, "R2 cache delete failed");
false
}
}
}
async fn clear(&self) {
}
async fn size(&self) -> usize {
0
}
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn detect_content_type(data: &[u8]) -> &'static str {
if data.len() < 2 {
return "application/octet-stream";
}
if data.len() >= 12 {
if &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
return "image/webp";
}
if &data[4..8] == b"ftyp" {
let brand = &data[8..12];
if brand == b"avif" || brand == b"avis" {
return "image/avif";
}
if brand == b"heic" || brand == b"heix" || brand == b"mif1" {
return "image/avif";
}
}
}
if data.len() >= 4 {
if data[0] == 0x89 && &data[1..4] == b"PNG" {
return "image/png";
}
if &data[0..4] == b"GIF8" {
return "image/gif";
}
}
if data[0] == 0xFF && data[1] == 0xD8 {
return "image/jpeg";
}
"application/octet-stream"
}
fn sanitize_key(key: &str) -> Option<String> {
let mut out = String::with_capacity(key.len());
let mut prev_slash = false;
for raw in key.chars() {
let ch = if raw == '|' { '/' } else { raw };
if ch == '/' && prev_slash {
continue;
}
out.push(ch);
prev_slash = ch == '/';
}
if out.contains("..") { None } else { Some(out) }
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_client() -> Arc<S3Client> {
Arc::new(
S3Client::new(
"http://localhost:1234",
"test-bucket",
"auto",
"test",
"test",
)
.unwrap(),
)
}
async fn mock_r2cache(server: &MockServer) -> R2Cache {
R2Cache::new(Arc::new(
S3Client::new(&server.uri(), "test-bucket", "auto", "test", "test").unwrap(),
))
}
#[tokio::test]
async fn get_returns_none_when_s3_is_unreachable() {
let rc = R2Cache::new(test_client());
assert!(rc.get("some-key").await.is_none());
}
#[tokio::test]
async fn get_returns_entry_on_real_200() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test-bucket/photo.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(b"pngdata".to_vec())
.insert_header("content-type", "image/png"),
)
.mount(&server)
.await;
let rc = mock_r2cache(&server).await;
let entry = rc.get("photo.png").await.unwrap();
assert_eq!(entry.data, b"pngdata");
assert_eq!(entry.content_type, "image/png");
}
#[tokio::test]
async fn get_returns_none_on_real_404_without_logging_as_an_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test-bucket/missing.png"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let rc = mock_r2cache(&server).await;
assert!(rc.get("missing.png").await.is_none());
}
#[tokio::test]
async fn get_returns_none_on_real_server_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test-bucket/broken.png"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let rc = mock_r2cache(&server).await;
assert!(rc.get("broken.png").await.is_none());
}
#[tokio::test]
async fn put_succeeds_on_real_200() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/test-bucket/photo.png"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let rc = mock_r2cache(&server).await;
rc.put(
"photo.png",
CacheEntry {
data: b"data".to_vec(),
content_type: "image/png".to_string(),
created_at: 0,
},
)
.await;
}
#[tokio::test]
async fn delete_returns_true_on_real_204() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/test-bucket/photo.png"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let rc = mock_r2cache(&server).await;
assert!(rc.delete("photo.png").await);
}
#[tokio::test]
async fn delete_returns_false_on_real_error() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/test-bucket/photo.png"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let rc = mock_r2cache(&server).await;
assert!(!rc.delete("photo.png").await);
}
#[test]
fn sanitize_key_replaces_pipes_with_slashes() {
assert_eq!(
sanitize_key("path|transforms|format").as_deref(),
Some("path/transforms/format")
);
}
#[test]
fn sanitize_key_collapses_double_pipes_empty_segment() {
assert_eq!(
sanitize_key("test.png||auto").as_deref(),
Some("test.png/auto")
);
}
#[test]
fn sanitize_key_leaves_key_unchanged_when_no_pipes() {
assert_eq!(sanitize_key("simple-key").as_deref(), Some("simple-key"));
}
#[test]
fn sanitize_key_rejects_traversal_sequences() {
assert_eq!(sanitize_key("photos/../etc/passwd|w=400|webp"), None);
}
#[tokio::test]
async fn get_treats_traversal_key_as_a_miss_not_a_request() {
let rc = R2Cache::new(test_client());
assert!(rc.get("photos/../etc/passwd|w=400|webp").await.is_none());
}
#[tokio::test]
async fn size_returns_0() {
let rc = R2Cache::new(test_client());
assert_eq!(rc.size().await, 0);
}
#[tokio::test]
async fn clear_does_not_panic() {
let rc = R2Cache::new(test_client());
rc.clear().await;
}
#[test]
fn detect_content_type_identifies_png() {
let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x00";
assert_eq!(detect_content_type(png), "image/png");
}
#[test]
fn detect_content_type_identifies_jpeg() {
let jpeg = b"\xFF\xD8\xFF\xE0\x00\x00\x00\x00\x00\x00\x00\x00";
assert_eq!(detect_content_type(jpeg), "image/jpeg");
}
#[test]
fn detect_content_type_identifies_webp() {
assert_eq!(
detect_content_type(b"RIFF\x00\x00\x00\x00WEBP"),
"image/webp"
);
}
#[test]
fn detect_content_type_identifies_avif() {
assert_eq!(
detect_content_type(b"\x00\x00\x00\x1cftypavif"),
"image/avif"
);
}
#[test]
fn detect_content_type_identifies_gif() {
assert_eq!(
detect_content_type(b"GIF89a\x00\x00\x00\x00\x00\x00"),
"image/gif"
);
assert_eq!(
detect_content_type(b"GIF87a\x00\x00\x00\x00\x00\x00"),
"image/gif"
);
}
#[test]
fn detect_content_type_returns_octet_stream_for_unknown() {
assert_eq!(detect_content_type(b"unknown"), "application/octet-stream");
}
#[test]
fn detect_content_type_returns_octet_stream_for_short_data() {
assert_eq!(detect_content_type(b"x"), "application/octet-stream");
assert_eq!(detect_content_type(b""), "application/octet-stream");
}
}