use crate::{Error, HttpRequest, HttpResponse};
use bytes::Bytes;
use lru::LruCache;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::io::Write;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
pub const DEFAULT_CONTENT_CACHE_CAPACITY: usize = 128;
pub const DEFAULT_MAX_SERVE_SIZE: usize = 16 * 1024 * 1024;
type ContentCacheKey = (PathBuf, SystemTime, Option<CompressionAlgorithm>);
#[derive(Clone)]
struct CachedContent {
body: Bytes,
etag: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheStrategy {
NoCache,
Public(Duration),
Private(Duration),
Immutable,
MustRevalidate,
}
impl CacheStrategy {
pub fn to_header_value(&self) -> String {
match self {
CacheStrategy::NoCache => "no-cache, no-store, must-revalidate".to_string(),
CacheStrategy::Public(duration) => {
format!("public, max-age={}", duration.as_secs())
}
CacheStrategy::Private(duration) => {
format!("private, max-age={}", duration.as_secs())
}
CacheStrategy::Immutable => "public, max-age=31536000, immutable".to_string(),
CacheStrategy::MustRevalidate => "no-cache".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CompressionAlgorithm {
Gzip,
Brotli,
Zstd,
}
impl CompressionAlgorithm {
pub fn to_header_value(&self) -> &'static str {
match self {
CompressionAlgorithm::Gzip => "gzip",
CompressionAlgorithm::Brotli => "br",
CompressionAlgorithm::Zstd => "zstd",
}
}
pub fn file_extension(&self) -> &'static str {
match self {
CompressionAlgorithm::Gzip => ".gz",
CompressionAlgorithm::Brotli => ".br",
CompressionAlgorithm::Zstd => ".zst",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompressionLevel {
Fast,
Default,
Best,
Custom(u32),
}
impl CompressionLevel {
pub fn gzip_level(&self) -> flate2::Compression {
match self {
CompressionLevel::Fast => flate2::Compression::fast(),
CompressionLevel::Default => flate2::Compression::default(),
CompressionLevel::Best => flate2::Compression::best(),
CompressionLevel::Custom(level) => flate2::Compression::new((*level).min(9)),
}
}
pub fn brotli_level(&self) -> u32 {
match self {
CompressionLevel::Fast => 4,
CompressionLevel::Default => 6,
CompressionLevel::Best => 11,
CompressionLevel::Custom(level) => (*level).min(11),
}
}
pub fn zstd_level(&self) -> i32 {
match self {
CompressionLevel::Fast => 1,
CompressionLevel::Default => 3,
CompressionLevel::Best => 19,
CompressionLevel::Custom(level) => (*level).min(22) as i32,
}
}
}
#[derive(Debug, Clone)]
pub struct CompressionConfig {
pub enabled: bool,
pub level: CompressionLevel,
pub prefer_brotli: bool,
pub serve_precompressed: bool,
pub min_size: usize,
pub max_size: usize,
pub compress_types: Option<Vec<FileType>>,
}
impl CompressionConfig {
pub fn new() -> Self {
Self {
enabled: true,
level: CompressionLevel::Default,
prefer_brotli: true,
serve_precompressed: true,
min_size: 1024, max_size: 10485760, compress_types: None, }
}
pub fn disabled() -> Self {
Self {
enabled: false,
..Self::new()
}
}
pub fn with_level(mut self, level: CompressionLevel) -> Self {
self.level = level;
self
}
pub fn prefer_brotli(mut self, prefer: bool) -> Self {
self.prefer_brotli = prefer;
self
}
pub fn serve_precompressed(mut self, enable: bool) -> Self {
self.serve_precompressed = enable;
self
}
pub fn with_min_size(mut self, size: usize) -> Self {
self.min_size = size;
self
}
pub fn with_max_size(mut self, size: usize) -> Self {
self.max_size = size;
self
}
pub fn with_compress_types(mut self, types: Vec<FileType>) -> Self {
self.compress_types = Some(types);
self
}
pub fn should_compress(&self, file_type: FileType, size: usize) -> bool {
if !self.enabled {
return false;
}
if size < self.min_size || size > self.max_size {
return false;
}
let is_compressible = match file_type {
FileType::JavaScript | FileType::Stylesheet | FileType::Html | FileType::Json => true,
FileType::Image => false, FileType::Font => false, FileType::Video | FileType::Audio => false, FileType::Other => false,
};
if !is_compressible {
return false;
}
if let Some(ref types) = self.compress_types {
types.contains(&file_type)
} else {
true
}
}
}
impl Default for CompressionConfig {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FileType {
JavaScript,
Stylesheet,
Image,
Font,
Html,
Json,
Video,
Audio,
Other,
}
impl FileType {
pub fn from_path(path: &Path) -> Self {
match path.extension().and_then(|ext| ext.to_str()) {
Some("js") | Some("mjs") => FileType::JavaScript,
Some("css") => FileType::Stylesheet,
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("svg") | Some("webp")
| Some("avif") | Some("ico") => FileType::Image,
Some("woff") | Some("woff2") | Some("ttf") | Some("otf") | Some("eot") => {
FileType::Font
}
Some("html") | Some("htm") => FileType::Html,
Some("json") => FileType::Json,
Some("mp4") | Some("webm") | Some("ogv") => FileType::Video,
Some("mp3") | Some("wav") | Some("ogg") | Some("m4a") => FileType::Audio,
_ => FileType::Other,
}
}
pub fn mime_type(&self, path: &Path) -> String {
match self {
FileType::JavaScript => "application/javascript".to_string(),
FileType::Stylesheet => "text/css".to_string(),
FileType::Image => match path.extension().and_then(|ext| ext.to_str()) {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("svg") => "image/svg+xml",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("ico") => "image/x-icon",
_ => "image/*",
}
.to_string(),
FileType::Font => match path.extension().and_then(|ext| ext.to_str()) {
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("ttf") => "font/ttf",
Some("otf") => "font/otf",
Some("eot") => "application/vnd.ms-fontobject",
_ => "font/*",
}
.to_string(),
FileType::Html => "text/html".to_string(),
FileType::Json => "application/json".to_string(),
FileType::Video => "video/mp4".to_string(),
FileType::Audio => "audio/mpeg".to_string(),
FileType::Other => "application/octet-stream".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct StaticAssetsConfig {
pub root_dir: PathBuf,
pub default_strategy: CacheStrategy,
pub type_strategies: HashMap<FileType, CacheStrategy>,
pub enable_etag: bool,
pub enable_last_modified: bool,
pub enable_cors: bool,
pub cors_origin: Option<String>,
pub fallback: Option<String>,
pub index_files: Vec<String>,
pub compression: CompressionConfig,
pub cache_capacity: usize,
pub max_serve_size: usize,
}
impl StaticAssetsConfig {
pub fn new(root_dir: impl Into<PathBuf>) -> Self {
let mut type_strategies = HashMap::new();
type_strategies.insert(
FileType::JavaScript,
CacheStrategy::Public(Duration::from_secs(3600)),
);
type_strategies.insert(
FileType::Stylesheet,
CacheStrategy::Public(Duration::from_secs(3600)),
);
type_strategies.insert(
FileType::Image,
CacheStrategy::Public(Duration::from_secs(86400)),
);
type_strategies.insert(FileType::Font, CacheStrategy::Immutable);
type_strategies.insert(FileType::Html, CacheStrategy::NoCache);
type_strategies.insert(FileType::Json, CacheStrategy::NoCache);
type_strategies.insert(
FileType::Video,
CacheStrategy::Public(Duration::from_secs(86400)),
);
type_strategies.insert(
FileType::Audio,
CacheStrategy::Public(Duration::from_secs(86400)),
);
Self {
root_dir: root_dir.into(),
default_strategy: CacheStrategy::Public(Duration::from_secs(3600)),
type_strategies,
enable_etag: true,
enable_last_modified: true,
enable_cors: true,
cors_origin: None,
fallback: None,
index_files: vec!["index.html".to_string()],
compression: CompressionConfig::new(),
cache_capacity: DEFAULT_CONTENT_CACHE_CAPACITY,
max_serve_size: DEFAULT_MAX_SERVE_SIZE,
}
}
pub fn with_cache_capacity(mut self, capacity: usize) -> Self {
self.cache_capacity = capacity;
self
}
pub fn with_max_serve_size(mut self, size: usize) -> Self {
self.max_serve_size = size;
self
}
pub fn with_default_strategy(mut self, strategy: CacheStrategy) -> Self {
self.default_strategy = strategy;
self
}
pub fn with_type_strategy(mut self, file_type: FileType, strategy: CacheStrategy) -> Self {
self.type_strategies.insert(file_type, strategy);
self
}
pub fn with_etag(mut self, enable: bool) -> Self {
self.enable_etag = enable;
self
}
pub fn with_last_modified(mut self, enable: bool) -> Self {
self.enable_last_modified = enable;
self
}
pub fn with_cors(mut self, enable: bool) -> Self {
self.enable_cors = enable;
self
}
pub fn with_cors_origin(mut self, origin: impl Into<String>) -> Self {
self.cors_origin = Some(origin.into());
self
}
pub fn with_fallback(mut self, fallback: impl Into<String>) -> Self {
self.fallback = Some(fallback.into());
self
}
pub fn with_index_files(mut self, files: Vec<String>) -> Self {
self.index_files = files;
self
}
pub fn with_compression(mut self, compression: CompressionConfig) -> Self {
self.compression = compression;
self
}
pub fn with_compression_enabled(mut self, enable: bool) -> Self {
self.compression.enabled = enable;
self
}
pub fn with_compression_level(mut self, level: CompressionLevel) -> Self {
self.compression.level = level;
self
}
pub fn spa_mode(self) -> Self {
self.with_fallback("index.html")
.with_type_strategy(FileType::Html, CacheStrategy::NoCache)
.with_type_strategy(FileType::JavaScript, CacheStrategy::Immutable)
.with_type_strategy(FileType::Stylesheet, CacheStrategy::Immutable)
.with_compression_enabled(true)
}
pub fn max_performance(self) -> Self {
self.with_type_strategy(FileType::JavaScript, CacheStrategy::Immutable)
.with_type_strategy(FileType::Stylesheet, CacheStrategy::Immutable)
.with_type_strategy(FileType::Image, CacheStrategy::Immutable)
.with_type_strategy(FileType::Font, CacheStrategy::Immutable)
.with_compression(
CompressionConfig::new()
.with_level(CompressionLevel::Best)
.prefer_brotli(true)
.serve_precompressed(true),
)
}
pub fn development(self) -> Self {
self.with_default_strategy(CacheStrategy::NoCache)
.with_etag(false)
.with_last_modified(false)
.with_compression(CompressionConfig::disabled())
}
}
impl Default for StaticAssetsConfig {
fn default() -> Self {
Self::new("public")
}
}
#[derive(Clone)]
pub struct StaticAssetServer {
config: StaticAssetsConfig,
cache: Option<Arc<Mutex<LruCache<ContentCacheKey, CachedContent>>>>,
}
impl StaticAssetServer {
pub fn new(config: StaticAssetsConfig) -> Result<Self, Error> {
if !config.root_dir.exists() {
return Err(Error::Internal(format!(
"Static assets directory not found: {:?}",
config.root_dir
)));
}
let cache = NonZeroUsize::new(config.cache_capacity)
.map(|cap| Arc::new(Mutex::new(LruCache::new(cap))));
Ok(Self { config, cache })
}
fn cache_get(&self, key: &ContentCacheKey) -> Option<CachedContent> {
let cache = self.cache.as_ref()?;
cache.lock().get(key).cloned()
}
fn cache_put(&self, key: ContentCacheKey, value: CachedContent) {
if let Some(cache) = self.cache.as_ref() {
cache.lock().put(key, value);
}
}
async fn load_body(
&self,
path: &Path,
compression: Option<CompressionAlgorithm>,
) -> Result<Bytes, Error> {
let bytes = if let Some(algo) = compression {
if self.config.compression.serve_precompressed {
if let Some(content) = self.try_serve_precompressed(path, algo).await? {
content
} else {
let raw_content = tokio::fs::read(path)
.await
.map_err(|e| Error::Internal(format!("Failed to read file: {}", e)))?;
self.compress_content(&raw_content, algo)?
}
} else {
let raw_content = tokio::fs::read(path)
.await
.map_err(|e| Error::Internal(format!("Failed to read file: {}", e)))?;
self.compress_content(&raw_content, algo)?
}
} else {
tokio::fs::read(path)
.await
.map_err(|e| Error::Internal(format!("Failed to read file: {}", e)))?
};
Ok(Bytes::from(bytes))
}
pub async fn serve(&self, req: &HttpRequest) -> Result<HttpResponse, Error> {
let path = self.resolve_path(&req.path)?;
if !path.exists() {
if let Some(ref fallback) = self.config.fallback {
let fallback_path = self.config.root_dir.join(fallback);
if fallback_path.exists() {
return self.serve_file(&fallback_path, req).await;
}
}
return Err(Error::NotFound(format!("File not found: {}", req.path)));
}
if path.is_dir() {
for index_file in &self.config.index_files {
let index_path = path.join(index_file);
if index_path.exists() && index_path.is_file() {
return self.serve_file(&index_path, req).await;
}
}
return Err(Error::Forbidden("Directory listing disabled".to_string()));
}
self.serve_file(&path, req).await
}
async fn serve_file(&self, path: &Path, req: &HttpRequest) -> Result<HttpResponse, Error> {
let metadata = tokio::fs::metadata(path)
.await
.map_err(|e| Error::Internal(format!("Failed to read file metadata: {}", e)))?;
let modified = metadata.modified().ok();
let file_size = metadata.len() as usize;
let file_type = FileType::from_path(path);
let compression = self.select_compression(req, file_type, file_size);
let etag = if self.config.enable_etag {
Some(self.generate_etag_with_compression(path, &metadata, compression.as_ref()))
} else {
None
};
if let Some(ref etag_value) = etag
&& let Some(if_none_match) = req.headers.get("If-None-Match")
&& if_none_match == etag_value
{
return Ok(self.not_modified_response(etag_value));
}
if self.config.enable_last_modified
&& let Some(modified_time) = modified
&& let Some(if_modified_since) = req.headers.get("If-Modified-Since")
&& let Ok(since_time) = httpdate::parse_http_date(if_modified_since)
&& modified_time <= since_time
{
return Ok(self.not_modified_response(etag.as_deref().unwrap_or("")));
}
let used_compression = compression;
let cacheable = file_size <= self.config.max_serve_size && modified.is_some();
let content: Bytes = if cacheable {
let key: ContentCacheKey = (path.to_path_buf(), modified.unwrap(), compression);
if let Some(entry) = self.cache_get(&key) {
debug_assert_eq!(entry.etag, etag);
entry.body
} else {
let body = self.load_body(path, compression).await?;
self.cache_put(
key,
CachedContent {
body: body.clone(),
etag: etag.clone(),
},
);
body
}
} else {
self.load_body(path, compression).await?
};
let mut response = HttpResponse::ok().with_bytes_body(content);
let content_type = file_type.mime_type(path);
response
.headers
.insert("Content-Type".to_string(), content_type);
if let Some(algo) = used_compression {
response.headers.insert(
"Content-Encoding".to_string(),
algo.to_header_value().to_string(),
);
response
.headers
.insert("Vary".to_string(), "Accept-Encoding".to_string());
}
let cache_strategy = self
.config
.type_strategies
.get(&file_type)
.copied()
.unwrap_or(self.config.default_strategy);
response.headers.insert(
"Cache-Control".to_string(),
cache_strategy.to_header_value(),
);
if let Some(etag_value) = etag {
response.headers.insert("ETag".to_string(), etag_value);
}
if self.config.enable_last_modified
&& let Some(modified_time) = modified
{
let formatted = httpdate::fmt_http_date(modified_time);
response
.headers
.insert("Last-Modified".to_string(), formatted);
}
if self.config.enable_cors {
let origin = self.config.cors_origin.as_deref().unwrap_or("*");
response.headers.insert(
"Access-Control-Allow-Origin".to_string(),
origin.to_string(),
);
response.headers.insert(
"Access-Control-Allow-Methods".to_string(),
"GET, HEAD, OPTIONS".to_string(),
);
}
Ok(response)
}
fn select_compression(
&self,
req: &HttpRequest,
file_type: FileType,
file_size: usize,
) -> Option<CompressionAlgorithm> {
if !self
.config
.compression
.should_compress(file_type, file_size)
{
return None;
}
let accept_encoding = req
.headers
.get("Accept-Encoding")
.or_else(|| req.headers.get("accept-encoding"))?;
let encodings: Vec<&str> = accept_encoding
.split(',')
.map(|s| s.trim().split(';').next().unwrap_or(""))
.collect();
let supports_brotli = encodings.contains(&"br");
let supports_gzip = encodings.contains(&"gzip");
let supports_zstd = encodings.contains(&"zstd");
if self.config.compression.prefer_brotli && supports_brotli {
Some(CompressionAlgorithm::Brotli)
} else if supports_gzip {
Some(CompressionAlgorithm::Gzip)
} else if supports_brotli {
Some(CompressionAlgorithm::Brotli)
} else if supports_zstd {
Some(CompressionAlgorithm::Zstd)
} else {
None
}
}
async fn try_serve_precompressed(
&self,
path: &Path,
algo: CompressionAlgorithm,
) -> Result<Option<Vec<u8>>, Error> {
let compressed_path = path.with_extension(format!(
"{}{}",
path.extension().and_then(|e| e.to_str()).unwrap_or(""),
algo.file_extension()
));
if compressed_path.exists() {
let content = tokio::fs::read(&compressed_path).await.map_err(|e| {
Error::Internal(format!("Failed to read pre-compressed file: {}", e))
})?;
Ok(Some(content))
} else {
Ok(None)
}
}
fn compress_content(
&self,
content: &[u8],
algo: CompressionAlgorithm,
) -> Result<Vec<u8>, Error> {
match algo {
CompressionAlgorithm::Gzip => {
use flate2::write::GzEncoder;
let mut encoder =
GzEncoder::new(Vec::new(), self.config.compression.level.gzip_level());
encoder
.write_all(content)
.map_err(|e| Error::Internal(format!("Gzip compression failed: {}", e)))?;
encoder
.finish()
.map_err(|e| Error::Internal(format!("Gzip compression failed: {}", e)))
}
CompressionAlgorithm::Brotli => {
let mut output = Vec::new();
let params = brotli::enc::BrotliEncoderParams {
quality: self.config.compression.level.brotli_level() as i32,
..Default::default()
};
brotli::BrotliCompress(&mut std::io::Cursor::new(content), &mut output, ¶ms)
.map_err(|e| Error::Internal(format!("Brotli compression failed: {}", e)))?;
Ok(output)
}
CompressionAlgorithm::Zstd => {
let level = self.config.compression.level.zstd_level();
zstd::encode_all(std::io::Cursor::new(content), level)
.map_err(|e| Error::Internal(format!("Zstd compression failed: {}", e)))
}
}
}
fn resolve_path(&self, request_path: &str) -> Result<PathBuf, Error> {
let clean_path = request_path
.trim_start_matches('/')
.split('?')
.next()
.unwrap_or("");
let full_path = self.config.root_dir.join(clean_path);
let canonical_root =
self.config.root_dir.canonicalize().map_err(|_| {
Error::Internal("Failed to canonicalize root directory".to_string())
})?;
let canonical_path = match full_path.canonicalize() {
Ok(p) => p,
Err(_) => {
return Ok(full_path);
}
};
if !canonical_path.starts_with(&canonical_root) {
return Err(Error::Forbidden(
"Access denied: path traversal attempt".to_string(),
));
}
Ok(canonical_path)
}
fn generate_etag_with_compression(
&self,
path: &Path,
metadata: &std::fs::Metadata,
compression: Option<&CompressionAlgorithm>,
) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
path.to_string_lossy().hash(&mut hasher);
metadata.len().hash(&mut hasher);
if let Ok(modified) = metadata.modified()
&& let Ok(duration) = modified.duration_since(SystemTime::UNIX_EPOCH)
{
duration.as_secs().hash(&mut hasher);
}
if let Some(algo) = compression {
algo.to_header_value().hash(&mut hasher);
}
format!("\"{}\"", hasher.finish())
}
fn not_modified_response(&self, etag: &str) -> HttpResponse {
let mut response = HttpResponse::new(304);
if !etag.is_empty() {
response
.headers
.insert("ETag".to_string(), etag.to_string());
}
if self.config.enable_cors {
let origin = self.config.cors_origin.as_deref().unwrap_or("*");
response.headers.insert(
"Access-Control-Allow-Origin".to_string(),
origin.to_string(),
);
}
response
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_strategy_headers() {
assert_eq!(
CacheStrategy::NoCache.to_header_value(),
"no-cache, no-store, must-revalidate"
);
assert_eq!(
CacheStrategy::Public(Duration::from_secs(3600)).to_header_value(),
"public, max-age=3600"
);
assert_eq!(
CacheStrategy::Immutable.to_header_value(),
"public, max-age=31536000, immutable"
);
}
#[test]
fn test_file_type_detection() {
assert_eq!(
FileType::from_path(Path::new("script.js")),
FileType::JavaScript
);
assert_eq!(
FileType::from_path(Path::new("style.css")),
FileType::Stylesheet
);
assert_eq!(FileType::from_path(Path::new("image.png")), FileType::Image);
assert_eq!(FileType::from_path(Path::new("font.woff2")), FileType::Font);
}
#[test]
fn test_config_builder() {
let config = StaticAssetsConfig::new("public")
.with_default_strategy(CacheStrategy::NoCache)
.with_etag(true)
.with_cors_origin("https://example.com")
.with_compression_enabled(true);
assert_eq!(config.default_strategy, CacheStrategy::NoCache);
assert!(config.enable_etag);
assert_eq!(config.cors_origin, Some("https://example.com".to_string()));
assert!(config.compression.enabled);
}
#[test]
fn test_spa_mode() {
let config = StaticAssetsConfig::new("public").spa_mode();
assert_eq!(config.fallback, Some("index.html".to_string()));
assert_eq!(
config.type_strategies.get(&FileType::Html),
Some(&CacheStrategy::NoCache)
);
assert_eq!(
config.type_strategies.get(&FileType::JavaScript),
Some(&CacheStrategy::Immutable)
);
assert!(config.compression.enabled);
}
#[test]
fn test_compression_algorithm() {
assert_eq!(CompressionAlgorithm::Gzip.to_header_value(), "gzip");
assert_eq!(CompressionAlgorithm::Brotli.to_header_value(), "br");
assert_eq!(CompressionAlgorithm::Zstd.to_header_value(), "zstd");
assert_eq!(CompressionAlgorithm::Gzip.file_extension(), ".gz");
assert_eq!(CompressionAlgorithm::Brotli.file_extension(), ".br");
assert_eq!(CompressionAlgorithm::Zstd.file_extension(), ".zst");
}
#[test]
fn test_compression_level() {
assert_eq!(CompressionLevel::Fast.brotli_level(), 4);
assert_eq!(CompressionLevel::Default.brotli_level(), 6);
assert_eq!(CompressionLevel::Best.brotli_level(), 11);
assert_eq!(CompressionLevel::Custom(8).brotli_level(), 8);
assert_eq!(CompressionLevel::Custom(20).brotli_level(), 11);
assert_eq!(CompressionLevel::Fast.zstd_level(), 1);
assert_eq!(CompressionLevel::Default.zstd_level(), 3);
assert_eq!(CompressionLevel::Best.zstd_level(), 19);
assert_eq!(CompressionLevel::Custom(8).zstd_level(), 8);
assert_eq!(CompressionLevel::Custom(50).zstd_level(), 22); }
#[test]
fn test_compression_config_should_compress() {
let config = CompressionConfig::new();
assert!(config.should_compress(FileType::JavaScript, 5000));
assert!(config.should_compress(FileType::Stylesheet, 5000));
assert!(config.should_compress(FileType::Html, 5000));
assert!(!config.should_compress(FileType::Image, 5000));
assert!(!config.should_compress(FileType::Font, 5000));
assert!(!config.should_compress(FileType::Video, 5000));
assert!(!config.should_compress(FileType::JavaScript, 500));
assert!(!config.should_compress(FileType::JavaScript, 20_000_000)); }
#[test]
fn test_compression_disabled() {
let config = CompressionConfig::disabled();
assert!(!config.enabled);
assert!(!config.should_compress(FileType::JavaScript, 5000));
}
#[test]
fn test_development_mode_disables_compression() {
let config = StaticAssetsConfig::new("public").development();
assert!(!config.compression.enabled);
assert_eq!(config.default_strategy, CacheStrategy::NoCache);
}
#[test]
fn test_max_performance_enables_best_compression() {
let config = StaticAssetsConfig::new("public").max_performance();
assert!(config.compression.enabled);
assert_eq!(config.compression.level, CompressionLevel::Best);
assert!(config.compression.prefer_brotli);
}
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("armature_sa_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&path).unwrap();
Self { path }
}
fn write(&self, name: &str, contents: &[u8]) {
std::fs::write(self.path.join(name), contents).unwrap();
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn get_req(path: &str) -> HttpRequest {
HttpRequest::new("GET".to_string(), path.to_string())
}
#[tokio::test]
async fn test_second_request_served_from_cache_with_stable_etag() {
let dir = TempDir::new();
dir.write("data.js", &vec![b'a'; 2048]);
let config = StaticAssetsConfig::new(dir.path.clone());
let server = StaticAssetServer::new(config).unwrap();
let resp1 = server.serve(&get_req("/data.js")).await.unwrap();
assert_eq!(resp1.status, 200);
assert_eq!(resp1.body_ref().len(), 2048);
let etag1 = resp1.headers.get("ETag").cloned().expect("etag present");
let cache = server.cache.as_ref().unwrap();
assert_eq!(cache.lock().len(), 1);
let resp2 = server.serve(&get_req("/data.js")).await.unwrap();
assert_eq!(resp2.status, 200);
assert_eq!(resp2.body_ref(), resp1.body_ref());
let etag2 = resp2.headers.get("ETag").cloned().expect("etag present");
assert_eq!(etag1, etag2, "ETag must be stable across requests");
assert_eq!(cache.lock().len(), 1);
let mut cond = get_req("/data.js");
cond.headers
.insert("If-None-Match".to_string(), etag1.clone());
let resp304 = server.serve(&cond).await.unwrap();
assert_eq!(resp304.status, 304);
assert_eq!(resp304.headers.get("ETag"), Some(&etag1));
}
#[tokio::test]
async fn test_compressed_response_cached_by_encoding() {
let dir = TempDir::new();
dir.write("app.js", &vec![b'x'; 4096]);
let config = StaticAssetsConfig::new(dir.path.clone());
let server = StaticAssetServer::new(config).unwrap();
let mut req = get_req("/app.js");
req.headers
.insert("Accept-Encoding".to_string(), "gzip".to_string());
let resp1 = server.serve(&req).await.unwrap();
assert_eq!(resp1.status, 200);
assert_eq!(
resp1.headers.get("Content-Encoding"),
Some(&"gzip".to_string())
);
let etag1 = resp1.headers.get("ETag").cloned().unwrap();
let cache = server.cache.as_ref().unwrap();
assert_eq!(cache.lock().len(), 1);
let resp2 = server.serve(&req).await.unwrap();
assert_eq!(resp2.body_ref(), resp1.body_ref());
assert_eq!(resp2.headers.get("ETag"), Some(&etag1));
assert_eq!(cache.lock().len(), 1);
}
#[tokio::test]
async fn test_large_file_not_cached() {
let dir = TempDir::new();
dir.write("big.bin", &vec![0u8; 1024]);
let config = StaticAssetsConfig::new(dir.path.clone()).with_max_serve_size(10);
let server = StaticAssetServer::new(config).unwrap();
let resp = server.serve(&get_req("/big.bin")).await.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body_ref().len(), 1024);
let cache = server.cache.as_ref().unwrap();
assert_eq!(cache.lock().len(), 0);
}
#[test]
fn test_compress_content_round_trip_all_algorithms() {
let dir = TempDir::new();
let config = StaticAssetsConfig::new(dir.path.clone());
let server = StaticAssetServer::new(config).unwrap();
let data = b"Hello, World! This is a test string for compression.".repeat(20);
let gz = server
.compress_content(&data, CompressionAlgorithm::Gzip)
.unwrap();
assert_ne!(gz, data);
let mut gz_decoder = flate2::read::GzDecoder::new(&gz[..]);
let mut gz_decompressed = Vec::new();
std::io::Read::read_to_end(&mut gz_decoder, &mut gz_decompressed).unwrap();
assert_eq!(gz_decompressed, data);
let br = server
.compress_content(&data, CompressionAlgorithm::Brotli)
.unwrap();
assert_ne!(br, data);
let mut br_decompressed = Vec::new();
brotli::BrotliDecompress(&mut std::io::Cursor::new(&br), &mut br_decompressed).unwrap();
assert_eq!(br_decompressed, data);
let zst = server
.compress_content(&data, CompressionAlgorithm::Zstd)
.unwrap();
assert_ne!(zst, data);
let zst_decompressed = zstd::decode_all(std::io::Cursor::new(&zst)).unwrap();
assert_eq!(zst_decompressed, data);
}
#[tokio::test]
async fn test_zstd_compressed_response_cached_by_encoding() {
let dir = TempDir::new();
dir.write("app.js", &vec![b'x'; 4096]);
let config = StaticAssetsConfig::new(dir.path.clone());
let server = StaticAssetServer::new(config).unwrap();
let mut req = get_req("/app.js");
req.headers
.insert("Accept-Encoding".to_string(), "zstd".to_string());
let resp1 = server.serve(&req).await.unwrap();
assert_eq!(resp1.status, 200);
assert_eq!(
resp1.headers.get("Content-Encoding"),
Some(&"zstd".to_string())
);
let decompressed = zstd::decode_all(std::io::Cursor::new(resp1.body_ref())).unwrap();
assert_eq!(decompressed, vec![b'x'; 4096]);
let etag1 = resp1.headers.get("ETag").cloned().unwrap();
let cache = server.cache.as_ref().unwrap();
assert_eq!(cache.lock().len(), 1);
let resp2 = server.serve(&req).await.unwrap();
assert_eq!(resp2.body_ref(), resp1.body_ref());
assert_eq!(resp2.headers.get("ETag"), Some(&etag1));
assert_eq!(cache.lock().len(), 1);
}
#[tokio::test]
async fn test_cache_capacity_zero_disables_cache() {
let dir = TempDir::new();
dir.write("data.txt", b"hello world");
let config = StaticAssetsConfig::new(dir.path.clone()).with_cache_capacity(0);
let server = StaticAssetServer::new(config).unwrap();
let resp = server.serve(&get_req("/data.txt")).await.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body_ref(), b"hello world");
assert!(server.cache.is_none());
}
}