use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use yah_mesofact_bundle::assets::{
etag_for, sha256_hex, AssetEntry, AssetIndex, Encoding, ASSET_INDEX_FILE,
CACHE_CONTROL_REVALIDATE,
};
const CACHE_CAP: usize = 4096;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Stamp {
len: u64,
mtime: Option<SystemTime>,
}
impl Stamp {
fn of(meta: &std::fs::Metadata) -> Self {
Self { len: meta.len(), mtime: meta.modified().ok() }
}
}
#[derive(Default)]
pub(crate) struct AssetCache {
indexes: Mutex<HashMap<PathBuf, (Stamp, Option<Arc<AssetIndex>>)>>,
hashes: Mutex<HashMap<PathBuf, (Stamp, Arc<str>)>>,
}
impl AssetCache {
async fn entry_for(&self, root: &Path, target: &Path, len: u64) -> Option<AssetEntry> {
let start = target.parent()?;
for dir in start.ancestors().take_while(|d| d.starts_with(root)) {
let index_path = dir.join(ASSET_INDEX_FILE);
let Ok(meta) = tokio::fs::metadata(&index_path).await else {
continue;
};
let Some(index) = self.index_at(&index_path, Stamp::of(&meta)).await else {
continue;
};
let Ok(rel) = target.strip_prefix(dir) else { continue };
let rel = rel.to_string_lossy().replace('\\', "/");
if let Some(entry) = index.assets.get(&rel).filter(|e| e.size == len) {
return Some(entry.clone());
}
}
None
}
async fn index_at(&self, path: &Path, stamp: Stamp) -> Option<Arc<AssetIndex>> {
if let Some((s, index)) = self.indexes.lock().expect("asset cache poisoned").get(path) {
if *s == stamp {
return index.clone();
}
}
let parsed = tokio::fs::read(path)
.await
.ok()
.and_then(|raw| AssetIndex::parse(&raw))
.map(Arc::new);
let mut map = self.indexes.lock().expect("asset cache poisoned");
if map.len() >= CACHE_CAP {
map.clear();
}
map.insert(path.to_path_buf(), (stamp, parsed.clone()));
parsed
}
fn cached_hash(&self, path: &Path, stamp: Stamp) -> Option<Arc<str>> {
let map = self.hashes.lock().expect("asset cache poisoned");
map.get(path).filter(|(s, _)| *s == stamp).map(|(_, h)| h.clone())
}
fn remember_hash(&self, path: &Path, stamp: Stamp, sha: Arc<str>) {
let mut map = self.hashes.lock().expect("asset cache poisoned");
if map.len() >= CACHE_CAP {
map.clear();
}
map.insert(path.to_path_buf(), (stamp, sha));
}
}
pub(crate) async fn respond(
cache: &AssetCache,
root: &Path,
target: &Path,
content_type: &'static str,
req: &HeaderMap,
) -> Option<Response> {
if target.file_name().is_some_and(|n| n == ASSET_INDEX_FILE) {
return None;
}
let meta = tokio::fs::metadata(target).await.ok()?;
if !meta.is_file() {
return None;
}
let stamp = Stamp::of(&meta);
if let Some(entry) = cache.entry_for(root, target, stamp.len).await {
return Some(respond_indexed(&entry, target, content_type, req).await);
}
let (sha, body) = match cache.cached_hash(target, stamp) {
Some(sha) => (sha, None),
None => {
let bytes = tokio::fs::read(target).await.ok()?;
let (sha, bytes) = tokio::task::spawn_blocking(move || (sha256_hex(&bytes), bytes))
.await
.ok()?;
let sha: Arc<str> = sha.into();
cache.remember_hash(target, stamp, sha.clone());
(sha, Some(bytes))
}
};
let etag = etag_for(&sha, None);
let mut headers = validator_headers(&etag, CACHE_CONTROL_REVALIDATE, false);
if if_none_match(req, &etag) {
return Some((StatusCode::NOT_MODIFIED, headers).into_response());
}
let body = match body {
Some(b) => b,
None => tokio::fs::read(target).await.ok()?,
};
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
Some((headers, body).into_response())
}
async fn respond_indexed(
entry: &AssetEntry,
target: &Path,
content_type: &'static str,
req: &HeaderMap,
) -> Response {
let vary = !entry.encodings.is_empty();
let mut chosen = negotiate(req, &entry.encodings);
let mut body = None;
if let Some(enc) = chosen {
let mut variant = target.as_os_str().to_owned();
variant.push(enc.suffix());
match tokio::fs::read(PathBuf::from(variant)).await {
Ok(b) => body = Some(b),
Err(_) => chosen = None,
}
}
let etag = entry.etag(chosen);
let mut headers = validator_headers(&etag, entry.cache_control(), vary);
if if_none_match(req, &etag) {
return (StatusCode::NOT_MODIFIED, headers).into_response();
}
let body = match body {
Some(b) => b,
None => match tokio::fs::read(target).await {
Ok(b) => b,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
},
};
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
if let Some(enc) = chosen {
headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static(enc.token()));
}
(headers, body).into_response()
}
fn validator_headers(etag: &str, cache_control: &'static str, vary: bool) -> HeaderMap {
let mut h = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(etag) {
h.insert(header::ETAG, v);
}
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache_control));
if vary {
h.insert(header::VARY, HeaderValue::from_static("accept-encoding"));
}
h
}
pub(crate) fn negotiate(req: &HeaderMap, available: &[Encoding]) -> Option<Encoding> {
if available.is_empty() {
return None;
}
let mut q_by_token: HashMap<String, f32> = HashMap::new();
for value in req.get_all(header::ACCEPT_ENCODING) {
let Ok(value) = value.to_str() else { continue };
for item in value.split(',') {
let mut parts = item.split(';');
let token = parts.next().unwrap_or("").trim().to_ascii_lowercase();
if token.is_empty() {
continue;
}
let q = parts
.filter_map(|p| p.trim().strip_prefix("q=").or_else(|| p.trim().strip_prefix("Q=")))
.find_map(|v| v.trim().parse::<f32>().ok())
.unwrap_or(1.0);
q_by_token.insert(token, q);
}
}
let star = q_by_token.get("*").copied();
let mut best: Option<(Encoding, f32)> = None;
for &enc in available {
let q = q_by_token.get(enc.token()).copied().or(star).unwrap_or(0.0);
if q > 0.0 && best.is_none_or(|(_, bq)| q > bq) {
best = Some((enc, q));
}
}
best.map(|(e, _)| e)
}
pub(crate) fn if_none_match(req: &HeaderMap, etag: &str) -> bool {
req.get_all(header::IF_NONE_MATCH).iter().any(|value| {
value.to_str().is_ok_and(|v| {
v.split(',').map(str::trim).any(|tag| {
tag == "*" || tag.strip_prefix("W/").unwrap_or(tag) == etag
})
})
})
}
#[cfg(test)]
mod tests {
use super::*;
fn accept(v: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(header::ACCEPT_ENCODING, HeaderValue::from_str(v).unwrap());
h
}
const BOTH: &[Encoding] = &[Encoding::Br, Encoding::Gzip];
#[test]
fn negotiation_prefers_brotli_honours_q_and_defaults_to_identity() {
assert_eq!(negotiate(&HeaderMap::new(), BOTH), None, "no header ⇒ identity");
assert_eq!(negotiate(&accept("br, gzip"), BOTH), Some(Encoding::Br));
assert_eq!(negotiate(&accept("gzip, deflate"), BOTH), Some(Encoding::Gzip));
assert_eq!(negotiate(&accept("gzip;q=1.0, br;q=0.5"), BOTH), Some(Encoding::Gzip));
assert_eq!(negotiate(&accept("br;q=0, gzip"), BOTH), Some(Encoding::Gzip));
assert_eq!(negotiate(&accept("*"), BOTH), Some(Encoding::Br));
assert_eq!(negotiate(&accept("*;q=0"), BOTH), None);
assert_eq!(negotiate(&accept("identity"), BOTH), None);
assert_eq!(negotiate(&accept("br"), &[Encoding::Gzip]), None, "only what exists");
}
#[test]
fn if_none_match_uses_weak_comparison_and_lists() {
let mut h = HeaderMap::new();
h.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"x\", W/\"abc\""));
assert!(if_none_match(&h, "\"abc\""));
assert!(!if_none_match(&h, "\"abd\""));
h.insert(header::IF_NONE_MATCH, HeaderValue::from_static("*"));
assert!(if_none_match(&h, "\"anything\""));
assert!(!if_none_match(&HeaderMap::new(), "\"abc\""));
}
}