use super::LogHandler;
use crate::MediaType;
use sha2::{Digest, Sha256};
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::ServerConfig;
use super::http_parse::HttpRequest;
use super::metrics::CACHE_HITS_TOTAL;
use super::negotiate::{
CacheHitStatus, ImageResponsePolicy, build_image_etag, build_image_response_headers,
if_none_match_matches,
};
use super::response::HttpResponse;
use crate::core::default_lossy_target_quality;
use crate::{Fit, Position, TransformOptions};
pub(super) const DEFAULT_CACHE_TTL_SECONDS: u64 = 3600;
pub(super) static CACHE_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
const EVICTION_INTERVAL_SECS: u64 = 60;
pub(super) struct TransformCache {
pub(super) root: PathBuf,
pub(super) ttl: Duration,
pub(super) log_handler: Option<LogHandler>,
pub(super) max_bytes: u64,
last_eviction_secs: AtomicU64,
}
#[derive(Debug)]
pub(super) enum CacheLookup {
Hit {
media_type: MediaType,
body: Vec<u8>,
age: Duration,
},
Miss,
}
impl TransformCache {
pub(super) fn new(root: PathBuf) -> Self {
Self {
root,
ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECONDS),
log_handler: None,
max_bytes: 0,
last_eviction_secs: AtomicU64::new(0),
}
}
pub(super) fn with_log_handler(mut self, handler: Option<LogHandler>) -> Self {
self.log_handler = handler;
self
}
pub(super) fn with_max_bytes(mut self, max_bytes: u64) -> Self {
self.max_bytes = max_bytes;
self
}
pub(super) fn log(&self, msg: &str) {
if let Some(handler) = &self.log_handler {
handler(msg);
} else {
eprintln!("{msg}");
}
}
pub(super) fn entry_path(&self, key: &str) -> PathBuf {
debug_assert!(
key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()),
"cache key must be a 64-character hex string"
);
let a = &key[0..2];
let b = &key[2..4];
let c = &key[4..6];
self.root.join(a).join(b).join(c).join(key)
}
pub(super) fn get(&self, key: &str) -> CacheLookup {
let path = self.entry_path(key);
let file = match fs::File::open(&path) {
Ok(f) => f,
Err(_) => return CacheLookup::Miss,
};
let age = match file
.metadata()
.and_then(|m| m.modified())
.and_then(|mtime| mtime.elapsed().map_err(io::Error::other))
{
Ok(age) => age,
Err(_) => return CacheLookup::Miss,
};
if age > self.ttl {
return CacheLookup::Miss;
}
let mut data = Vec::new();
if io::Read::read_to_end(&mut &file, &mut data).is_err() {
self.remove_corrupted(&path, "read failed");
return CacheLookup::Miss;
}
let newline_pos = match data.iter().position(|&b| b == b'\n') {
Some(pos) => pos,
None => {
self.remove_corrupted(&path, "missing header newline");
return CacheLookup::Miss;
}
};
let media_type_str = match std::str::from_utf8(&data[..newline_pos]) {
Ok(s) => s,
Err(_) => {
self.remove_corrupted(&path, "invalid UTF-8 in header");
return CacheLookup::Miss;
}
};
let media_type = match MediaType::from_str(media_type_str) {
Ok(mt) => mt,
Err(_) => {
self.remove_corrupted(&path, "unrecognized media type");
return CacheLookup::Miss;
}
};
data.drain(..=newline_pos);
CacheLookup::Hit {
media_type,
body: data,
age,
}
}
fn remove_corrupted(&self, path: &Path, reason: &str) {
self.log(&format!(
"truss: removing corrupted cache entry ({reason}): {}",
path.display()
));
let _ = fs::remove_file(path);
}
pub(super) fn put(&self, key: &str, media_type: MediaType, body: &[u8]) {
let path = self.entry_path(key);
if let Some(parent) = path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
self.log(&format!("truss: cache mkdir failed: {err}"));
return;
}
let tmp_path = path.with_extension(unique_tmp_suffix());
let mut header = media_type.as_name().as_bytes().to_vec();
header.push(b'\n');
let result = (|| -> io::Result<()> {
let mut file = fs::File::create(&tmp_path)?;
file.write_all(&header)?;
file.write_all(body)?;
drop(file);
fs::rename(&tmp_path, &path)?;
Ok(())
})();
if let Err(err) = result {
self.log(&format!("truss: cache write failed: {err}"));
let _ = fs::remove_file(&tmp_path);
} else {
self.maybe_evict();
}
}
fn maybe_evict(&self) {
if self.max_bytes == 0 {
return;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let last = self.last_eviction_secs.load(Ordering::Relaxed);
if now.saturating_sub(last) < EVICTION_INTERVAL_SECS {
return;
}
if self
.last_eviction_secs
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
.is_err()
{
return; }
if let Err(err) = self.evict_to_limit() {
self.log(&format!("truss: cache eviction scan failed: {err}"));
}
}
fn evict_to_limit(&self) -> io::Result<()> {
let mut entries = collect_cache_entries(&self.root)?;
let total_size: u64 = entries.iter().map(|e| e.size).sum();
if total_size <= self.max_bytes {
return Ok(());
}
entries.sort_by_key(|e| e.mtime);
let mut current_size = total_size;
for entry in &entries {
if current_size <= self.max_bytes {
break;
}
if fs::remove_file(&entry.path).is_ok() {
self.log(&format!(
"truss: cache eviction: removed {} ({} bytes)",
entry.path.display(),
entry.size
));
current_size = current_size.saturating_sub(entry.size);
}
}
Ok(())
}
}
struct CacheEntry {
path: PathBuf,
size: u64,
mtime: Duration,
}
fn collect_cache_entries(root: &Path) -> io::Result<Vec<CacheEntry>> {
let mut entries = Vec::new();
collect_entries_recursive(root, &mut entries);
Ok(entries)
}
fn collect_entries_recursive(dir: &Path, entries: &mut Vec<CacheEntry>) {
let read_dir = match fs::read_dir(dir) {
Ok(rd) => rd,
Err(_) => return,
};
for entry in read_dir.flatten() {
let path = entry.path();
if path.is_dir() {
collect_entries_recursive(&path, entries);
} else if path.is_file() {
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& name.contains(".tmp.")
{
continue;
}
if let Ok(meta) = fs::metadata(&path) {
let size = meta.len();
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.unwrap_or(Duration::ZERO);
entries.push(CacheEntry { path, size, mtime });
}
}
}
}
pub(super) struct OriginCache {
root: PathBuf,
pub(super) ttl: Duration,
log_handler: Option<LogHandler>,
}
impl OriginCache {
pub(super) fn new(cache_root: &Path) -> Self {
Self {
root: cache_root.join("origin"),
ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECONDS),
log_handler: None,
}
}
pub(super) fn with_log_handler(mut self, handler: Option<LogHandler>) -> Self {
self.log_handler = handler;
self
}
fn log(&self, msg: &str) {
if let Some(handler) = &self.log_handler {
handler(msg);
} else {
eprintln!("{msg}");
}
}
fn entry_path(&self, namespace: &str, url: &str) -> PathBuf {
let mut hasher = Sha256::new();
hasher.update(namespace.as_bytes());
hasher.update(b":");
hasher.update(url.as_bytes());
let key = hex::encode(hasher.finalize());
let a = &key[0..2];
let b = &key[2..4];
let c = &key[4..6];
self.root.join(a).join(b).join(c).join(&key)
}
pub(super) fn get(&self, namespace: &str, url: &str) -> Option<Vec<u8>> {
let path = self.entry_path(namespace, url);
let file = fs::File::open(&path).ok()?;
let age = file
.metadata()
.and_then(|m| m.modified())
.and_then(|mtime| mtime.elapsed().map_err(io::Error::other))
.ok()?;
if age > self.ttl {
return None;
}
let mut data = Vec::new();
io::Read::read_to_end(&mut &file, &mut data).ok()?;
Some(data)
}
pub(super) fn put(&self, namespace: &str, url: &str, body: &[u8]) {
let path = self.entry_path(namespace, url);
if let Some(parent) = path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
self.log(&format!("truss: origin cache mkdir failed: {err}"));
return;
}
let tmp_path = path.with_extension(unique_tmp_suffix());
let result = (|| -> io::Result<()> {
let mut file = fs::File::create(&tmp_path)?;
file.write_all(body)?;
drop(file);
fs::rename(&tmp_path, &path)?;
Ok(())
})();
if let Err(err) = result {
self.log(&format!("truss: origin cache write failed: {err}"));
let _ = fs::remove_file(&tmp_path);
}
}
}
pub(super) fn unique_tmp_suffix() -> String {
let seq = CACHE_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("tmp.{}.{seq}", std::process::id())
}
pub(super) fn compute_cache_key(
source_identifier: &str,
options: &TransformOptions,
negotiated_accept: Option<&str>,
watermark_identity: Option<&str>,
) -> String {
use std::fmt::Write;
let mut canonical = String::new();
canonical.push_str(source_identifier);
canonical.push('\n');
let has_bounded_resize = options.width.is_some() && options.height.is_some();
let mut first = true;
let mut push_param = |canonical: &mut String, k: &str, v: &str| {
if !first {
canonical.push('&');
}
first = false;
canonical.push_str(k);
canonical.push('=');
canonical.push_str(v);
};
if options.auto_orient {
push_param(&mut canonical, "autoOrient", "true");
}
if let Some(bg) = &options.background {
let mut buf = String::new();
let _ = write!(buf, "{:02x}{:02x}{:02x}{:02x}", bg.r, bg.g, bg.b, bg.a);
push_param(&mut canonical, "background", &buf);
}
if let Some(blur) = options.blur {
let mut buf = String::new();
let _ = write!(buf, "{blur}");
push_param(&mut canonical, "blur", &buf);
}
if let Some(crop) = options.crop {
let buf = crop.to_string();
push_param(&mut canonical, "crop", &buf);
}
if has_bounded_resize {
let fit = options.fit.unwrap_or(Fit::Contain);
push_param(&mut canonical, "fit", fit.as_name());
}
if let Some(format) = options.format {
push_param(&mut canonical, "format", format.as_name());
}
if let Some(h) = options.height {
let buf = h.to_string();
push_param(&mut canonical, "height", &buf);
}
if options.optimize != crate::OptimizeMode::None {
push_param(&mut canonical, "optimize", options.optimize.as_name());
}
if has_bounded_resize {
let pos = options.position.unwrap_or(Position::Center);
push_param(&mut canonical, "position", pos.as_name());
}
if options.preserve_exif {
push_param(&mut canonical, "preserveExif", "true");
}
if options.grayscale {
push_param(&mut canonical, "grayscale", "true");
}
if options.without_enlargement {
push_param(&mut canonical, "withoutEnlargement", "true");
}
if let Some(q) = options.quality {
let buf = q.to_string();
push_param(&mut canonical, "quality", &buf);
}
if let Some(target_quality) = options.target_quality {
let buf = target_quality.to_string();
push_param(&mut canonical, "targetQuality", &buf);
} else if matches!(
options.optimize,
crate::OptimizeMode::Auto | crate::OptimizeMode::Lossy
) && options.quality.is_none()
&& let Some(format) = options.format
&& let Some(target_quality) = default_lossy_target_quality(format)
{
let buf = target_quality.to_string();
push_param(&mut canonical, "targetQuality", &buf);
}
if !options.rotate.is_identity() {
let buf = options.rotate.to_string();
push_param(&mut canonical, "rotate", &buf);
}
if let Some(sharpen) = options.sharpen {
let mut buf = String::new();
let _ = write!(buf, "{sharpen}");
push_param(&mut canonical, "sharpen", &buf);
}
if options.strip_metadata {
push_param(&mut canonical, "stripMetadata", "true");
}
if let Some(w) = options.width {
let buf = w.to_string();
push_param(&mut canonical, "width", &buf);
}
canonical.push('\n');
if let Some(accept) = negotiated_accept {
canonical.push_str(accept);
}
canonical.push('\n');
if let Some(wm) = watermark_identity {
canonical.push_str(wm);
}
let digest = Sha256::digest(canonical.as_bytes());
hex::encode(digest)
}
pub(super) fn compute_watermark_identity(
url: &str,
position: &str,
opacity: u8,
margin: u32,
) -> String {
let mut hasher = Sha256::new();
hasher.update(b"watermark\n");
hasher.update(url.as_bytes());
hasher.update(b"\n");
hasher.update(position.as_bytes());
hasher.update(b"\n");
hasher.update(opacity.to_string().as_bytes());
hasher.update(b"\n");
hasher.update(margin.to_string().as_bytes());
hex::encode(hasher.finalize())
}
pub(super) fn compute_watermark_content_identity(
content_hash: &str,
position: &str,
opacity: u8,
margin: u32,
) -> String {
let mut hasher = Sha256::new();
hasher.update(b"watermark-content\n");
hasher.update(content_hash.as_bytes());
hasher.update(b"\n");
hasher.update(position.as_bytes());
hasher.update(b"\n");
hasher.update(opacity.to_string().as_bytes());
hasher.update(b"\n");
hasher.update(margin.to_string().as_bytes());
hex::encode(hasher.finalize())
}
pub(super) fn try_versioned_cache_lookup(
versioned_hash: Option<&str>,
options: &TransformOptions,
request: &HttpRequest,
response_policy: ImageResponsePolicy,
config: &ServerConfig,
watermark_identity: Option<&str>,
) -> Option<HttpResponse> {
let source_hash = versioned_hash?;
let cache_root = config.cache_root.as_ref()?;
options.format?;
let cache =
TransformCache::new(cache_root.clone()).with_log_handler(config.log_handler.clone());
let cache_key = compute_cache_key(source_hash, options, None, watermark_identity);
if let CacheLookup::Hit {
media_type,
body,
age,
} = cache.get(&cache_key)
{
CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
let etag = build_image_etag(&body);
let mut headers = build_image_response_headers(
media_type,
&etag,
response_policy,
false,
CacheHitStatus::Hit,
config.public_max_age_seconds,
config.public_stale_while_revalidate_seconds,
&config.custom_response_headers,
);
headers.push(("Age".to_string(), age.as_secs().to_string()));
if matches!(response_policy, ImageResponsePolicy::PublicGet)
&& if_none_match_matches(request.header("if-none-match"), &etag)
{
return Some(HttpResponse::empty("304 Not Modified", headers));
}
return Some(HttpResponse::binary_with_headers(
"200 OK",
media_type.as_mime(),
headers,
body,
));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_blur_full_precision() {
let opts_a = TransformOptions {
blur: Some(0.11),
..TransformOptions::default()
};
let opts_b = TransformOptions {
blur: Some(0.14),
..TransformOptions::default()
};
let key_a = compute_cache_key("img.png", &opts_a, None, None);
let key_b = compute_cache_key("img.png", &opts_b, None, None);
assert_ne!(
key_a, key_b,
"blur=0.11 and blur=0.14 must produce different cache keys"
);
}
#[test]
fn cache_key_differs_by_without_enlargement() {
let base = TransformOptions {
width: Some(200),
height: Some(200),
..TransformOptions::default()
};
let clamped = TransformOptions {
without_enlargement: true,
..base.clone()
};
assert_ne!(
compute_cache_key("img.png", &base, None, None),
compute_cache_key("img.png", &clamped, None, None)
);
}
#[test]
fn cache_key_differs_by_grayscale() {
let base = TransformOptions::default();
let gray = TransformOptions {
grayscale: true,
..TransformOptions::default()
};
assert_ne!(
compute_cache_key("img.png", &base, None, None),
compute_cache_key("img.png", &gray, None, None),
"a grayscale request must not reuse the color variant's cache entry"
);
}
#[test]
fn cache_key_differs_by_optimize_mode() {
let base = TransformOptions::default();
let optimized = TransformOptions {
optimize: crate::OptimizeMode::Auto,
..TransformOptions::default()
};
assert_ne!(
compute_cache_key("img.png", &base, None, None),
compute_cache_key("img.png", &optimized, None, None)
);
}
#[test]
fn cache_key_differs_by_target_quality() {
let a = TransformOptions {
format: Some(MediaType::Jpeg),
optimize: crate::OptimizeMode::Lossy,
target_quality: Some(crate::TargetQuality {
metric: crate::QualityMetric::Ssim,
value: 0.98,
}),
..TransformOptions::default()
};
let b = TransformOptions {
format: Some(MediaType::Jpeg),
optimize: crate::OptimizeMode::Lossy,
target_quality: Some(crate::TargetQuality {
metric: crate::QualityMetric::Ssim,
value: 0.99,
}),
..TransformOptions::default()
};
assert_ne!(
compute_cache_key("img.png", &a, None, None),
compute_cache_key("img.png", &b, None, None)
);
}
#[test]
fn cache_key_matches_explicit_default_target_quality() {
let implicit = TransformOptions {
format: Some(MediaType::Jpeg),
optimize: crate::OptimizeMode::Lossy,
..TransformOptions::default()
};
let explicit = TransformOptions {
format: Some(MediaType::Jpeg),
optimize: crate::OptimizeMode::Lossy,
target_quality: default_lossy_target_quality(MediaType::Jpeg),
..TransformOptions::default()
};
assert_eq!(
compute_cache_key("img.png", &implicit, None, None),
compute_cache_key("img.png", &explicit, None, None)
);
}
fn test_key(index: u8) -> String {
let digest = Sha256::digest([index]);
hex::encode(digest)
}
#[test]
fn eviction_removes_oldest_entries_when_over_limit() {
let dir = tempfile::tempdir().unwrap();
let cache = TransformCache::new(dir.path().to_path_buf()).with_max_bytes(200);
let body = vec![0u8; 50];
cache.put(&test_key(0), MediaType::Jpeg, &body);
std::thread::sleep(std::time::Duration::from_millis(50));
cache.put(&test_key(1), MediaType::Jpeg, &body);
std::thread::sleep(std::time::Duration::from_millis(50));
cache.put(&test_key(2), MediaType::Jpeg, &body);
std::thread::sleep(std::time::Duration::from_millis(50));
cache.put(&test_key(3), MediaType::Jpeg, &body);
let _ = cache.evict_to_limit();
let remaining: Vec<_> = collect_cache_entries(dir.path())
.unwrap()
.into_iter()
.map(|e| e.path)
.collect();
let total_size: u64 = collect_cache_entries(dir.path())
.unwrap()
.iter()
.map(|e| e.size)
.sum();
assert!(
total_size <= 200,
"cache size {total_size} should be <= 200 after eviction"
);
assert!(
remaining.contains(&cache.entry_path(&test_key(3))),
"newest entry should survive eviction"
);
}
#[test]
fn no_eviction_when_max_bytes_is_zero() {
let dir = tempfile::tempdir().unwrap();
let cache = TransformCache::new(dir.path().to_path_buf()).with_max_bytes(0);
let body = vec![0u8; 100];
for i in 0..5 {
cache.put(&test_key(i), MediaType::Jpeg, &body);
}
let entries = collect_cache_entries(dir.path()).unwrap();
assert_eq!(
entries.len(),
5,
"all entries should survive when max_bytes is 0"
);
}
#[test]
fn no_eviction_when_under_limit() {
let dir = tempfile::tempdir().unwrap();
let cache = TransformCache::new(dir.path().to_path_buf()).with_max_bytes(1_000_000);
let body = vec![0u8; 50];
for i in 0..3 {
cache.put(&test_key(i), MediaType::Jpeg, &body);
}
let entries = collect_cache_entries(dir.path()).unwrap();
assert_eq!(
entries.len(),
3,
"all entries should survive when under limit"
);
}
#[test]
fn collect_cache_entries_skips_temp_files() {
let dir = tempfile::tempdir().unwrap();
let cache = TransformCache::new(dir.path().to_path_buf());
cache.put(&test_key(0), MediaType::Jpeg, b"data");
let key = test_key(1);
let path = cache.entry_path(&key);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
let tmp_path = path.with_extension("tmp.12345.0");
fs::write(&tmp_path, b"partial").unwrap();
let entries = collect_cache_entries(dir.path()).unwrap();
assert_eq!(entries.len(), 1, "temp files should be excluded from scan");
}
}