use http::header::{self, HeaderMap, HeaderValue};
#[cfg(feature = "runtime-compression")]
use std::collections::HashSet;
use std::fs::File;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
trait PathBufExt {
fn append_extension(&self, extension: impl AsRef<std::ffi::OsStr>) -> PathBuf;
}
impl PathBufExt for Path {
fn append_extension(&self, extension: impl AsRef<std::ffi::OsStr>) -> PathBuf {
match self.file_name() {
Some(file_name) => {
let mut new_file_name = file_name.to_os_string();
new_file_name.push(".");
new_file_name.push(extension.as_ref());
self.with_file_name(new_file_name)
}
None => self.to_path_buf(),
}
}
}
#[cfg(feature = "runtime-compression")]
use crate::brotli_cache::BrotliCache;
use crate::SerdirError;
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
pub struct StaticCompression {
gzip: bool,
br: bool,
zstd: bool,
}
impl StaticCompression {
pub fn none() -> Self {
Self::default()
}
pub fn all() -> Self {
Self::default().gzip(true).brotli(true).zstd(true)
}
pub fn brotli(mut self, enabled: bool) -> Self {
self.br = enabled;
self
}
pub fn gzip(mut self, enabled: bool) -> Self {
self.gzip = enabled;
self
}
pub fn zstd(mut self, enabled: bool) -> Self {
self.zstd = enabled;
self
}
}
#[cfg(feature = "runtime-compression")]
const DEFAULT_CACHE_SIZE: u16 = 128;
#[cfg(feature = "runtime-compression")]
const DEFAULT_COMPRESSION_LEVEL: BrotliLevel = BrotliLevel::L5;
#[cfg(feature = "runtime-compression")]
const DEFAULT_MAX_FILE_SIZE: u64 = 1024 * 1024;
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum BrotliLevel {
L0 = 0,
L1 = 1,
L2 = 2,
L3 = 3,
L4 = 4,
#[default]
L5 = 5,
L6 = 6,
L7 = 7,
L8 = 8,
L9 = 9,
L10 = 10,
L11 = 11,
}
impl From<BrotliLevel> for i32 {
fn from(level: BrotliLevel) -> Self {
level as i32
}
}
impl TryFrom<u8> for BrotliLevel {
type Error = SerdirError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::L0),
1 => Ok(Self::L1),
2 => Ok(Self::L2),
3 => Ok(Self::L3),
4 => Ok(Self::L4),
5 => Ok(Self::L5),
6 => Ok(Self::L6),
7 => Ok(Self::L7),
8 => Ok(Self::L8),
9 => Ok(Self::L9),
10 => Ok(Self::L10),
11 => Ok(Self::L11),
_ => Err(SerdirError::ConfigError(format!(
"invalid Brotli level: {value}, must be between 0 and 11"
))),
}
}
}
impl TryFrom<u32> for BrotliLevel {
type Error = SerdirError;
fn try_from(value: u32) -> Result<Self, Self::Error> {
u8::try_from(value)
.map_err(|_| {
SerdirError::ConfigError(format!(
"invalid Brotli level: {value}, must be between 0 and 11"
))
})?
.try_into()
}
}
#[cfg(feature = "runtime-compression")]
#[derive(Debug, Clone)]
pub struct CachedCompression {
pub(crate) cache_size: u16,
pub(crate) compression_level: BrotliLevel,
pub(crate) supported_extensions: Option<HashSet<&'static str>>,
pub(crate) max_file_size: u64,
}
#[cfg(feature = "runtime-compression")]
impl CachedCompression {
pub fn new() -> Self {
Self::default()
}
pub fn max_size(mut self, size: u16) -> Self {
assert!(size >= 4, "cache_size must be at least 4");
assert!(size.is_power_of_two(), "cache_size must be a power of two");
self.cache_size = size;
self
}
pub fn compression_level(mut self, level: BrotliLevel) -> Self {
self.compression_level = level;
self
}
pub fn supported_extensions(mut self, extensions: Option<HashSet<&'static str>>) -> Self {
self.supported_extensions = extensions;
self
}
pub fn max_file_size(mut self, size: u64) -> Self {
self.max_file_size = size;
self
}
}
#[cfg(feature = "runtime-compression")]
impl Default for CachedCompression {
fn default() -> Self {
Self {
cache_size: DEFAULT_CACHE_SIZE,
compression_level: DEFAULT_COMPRESSION_LEVEL,
supported_extensions: None,
max_file_size: DEFAULT_MAX_FILE_SIZE,
}
}
}
pub(crate) fn parse_qvalue(s: &str) -> Result<u16, ()> {
match s {
"1" | "1." | "1.0" | "1.00" | "1.000" => return Ok(1000),
"0" | "0." => return Ok(0),
s if !s.starts_with("0.") => return Err(()),
_ => {}
};
let v = &s[2..];
let factor = match v.len() {
1 => 100,
2 => 10,
3 => 1,
_ => return Err(()),
};
let v = u16::from_str(v).map_err(|_| ())?;
let q = v * factor;
Ok(q)
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
pub(crate) struct CompressionSupport {
gzip: bool,
br: bool,
zstd: bool,
}
impl CompressionSupport {
pub fn new(br: bool, gzip: bool, zstd: bool) -> Self {
Self { gzip, br, zstd }
}
pub fn brotli(&self) -> bool {
self.br
}
pub fn gzip(&self) -> bool {
self.gzip
}
pub fn zstd(&self) -> bool {
self.zstd
}
pub fn detect(headers: &HeaderMap) -> CompressionSupport {
let v = match headers.get(header::ACCEPT_ENCODING) {
None => return CompressionSupport::default(),
Some(v) if v.is_empty() => return CompressionSupport::default(),
Some(v) => v,
};
let (mut gzip_q, mut br_q, mut zstd_q, mut identity_q, mut star_q) =
(None, None, None, None, None);
let parts = match v.to_str() {
Ok(s) => s.split(','),
Err(_) => return CompressionSupport::default(),
};
for qi in parts {
let coding;
let quality;
match qi.split_once(';') {
None => {
coding = qi.trim();
quality = 1000;
}
Some((c, q)) => {
coding = c.trim();
let Some(q) = q
.trim()
.strip_prefix("q=")
.and_then(|q| parse_qvalue(q).ok())
else {
return CompressionSupport::default(); };
quality = q;
}
};
if coding == "gzip" {
gzip_q = Some(quality);
} else if coding == "br" {
br_q = Some(quality);
} else if coding == "zstd" {
zstd_q = Some(quality);
} else if coding == "identity" {
identity_q = Some(quality);
} else if coding == "*" {
star_q = Some(quality);
}
}
let gzip_q = gzip_q.or(star_q).unwrap_or(0);
let br_q = br_q.or(star_q).unwrap_or(0);
let zstd_q = zstd_q.or(star_q).unwrap_or(0);
let identity_q = identity_q.or(star_q).unwrap_or(0);
let use_gzip = gzip_q > 0 && gzip_q >= identity_q;
let use_br = br_q > 0 && br_q >= identity_q;
let use_zstd = zstd_q > 0 && zstd_q >= identity_q;
CompressionSupport {
gzip: use_gzip,
br: use_br,
zstd: use_zstd,
}
}
}
#[derive(Debug, Clone)]
pub enum CompressionStrategy {
Static(StaticCompression),
#[cfg(feature = "runtime-compression")]
Cached(CachedCompression),
None,
}
impl CompressionStrategy {
pub fn none() -> Self {
Self::None
}
pub fn static_compression() -> Self {
Self::Static(StaticCompression::none())
}
#[cfg(feature = "runtime-compression")]
pub fn cached_compression() -> Self {
Self::Cached(CachedCompression::new())
}
pub(crate) fn into_inner(self) -> CompressionStrategyInner {
match self {
Self::Static(value) => CompressionStrategyInner::Static(CompressionSupport::new(
value.br, value.gzip, value.zstd,
)),
#[cfg(feature = "runtime-compression")]
Self::Cached(value) => {
let cache = BrotliCache::from(value);
CompressionStrategyInner::Cached(Arc::new(cache))
}
Self::None => CompressionStrategyInner::None,
}
}
}
impl Default for CompressionStrategy {
fn default() -> Self {
Self::none()
}
}
impl From<StaticCompression> for CompressionStrategy {
fn from(value: StaticCompression) -> Self {
Self::Static(value)
}
}
#[cfg(feature = "runtime-compression")]
impl From<CachedCompression> for CompressionStrategy {
fn from(value: CachedCompression) -> Self {
Self::Cached(value)
}
}
#[derive(Debug, Clone)]
pub(crate) enum CompressionStrategyInner {
Static(CompressionSupport),
#[cfg(feature = "runtime-compression")]
Cached(Arc<BrotliCache>),
None,
}
impl CompressionStrategyInner {
pub(crate) fn is_none(&self) -> bool {
matches!(self, CompressionStrategyInner::None)
}
pub(crate) async fn find_file(
&self,
path: &Path,
supported: crate::compression::CompressionSupport,
) -> Result<MatchedFile, SerdirError> {
match self {
CompressionStrategyInner::Static(server_support) => {
if supported.brotli() && server_support.brotli() {
let br_path = path.append_extension("br");
match Self::try_path(&br_path, ContentEncoding::Brotli) {
Ok(f) => return Ok(f),
Err(SerdirError::NotFound(_)) | Err(SerdirError::IsDirectory(_)) => {}
Err(e) => return Err(e),
}
}
if supported.zstd() && server_support.zstd() {
let zstd_path = path.append_extension("zstd");
match Self::try_path(&zstd_path, ContentEncoding::Zstd) {
Ok(f) => return Ok(f),
Err(SerdirError::NotFound(_)) | Err(SerdirError::IsDirectory(_)) => {}
Err(e) => return Err(e),
}
}
if supported.gzip() && server_support.gzip() {
let gz_path = path.append_extension("gz");
match Self::try_path(&gz_path, ContentEncoding::Gzip) {
Ok(f) => return Ok(f),
Err(SerdirError::NotFound(_)) | Err(SerdirError::IsDirectory(_)) => {}
Err(e) => return Err(e),
}
}
}
#[cfg(feature = "runtime-compression")]
CompressionStrategyInner::Cached(cache) => {
if supported.brotli() {
let matched = cache.get(path).await?;
return Ok(matched);
}
}
CompressionStrategyInner::None => {}
}
Self::try_path(path, ContentEncoding::Identity)
}
fn try_path(p: &Path, encoding: ContentEncoding) -> Result<MatchedFile, SerdirError> {
match crate::platform::open_file(p) {
Ok(file) => {
let file_info = crate::FileInfo::open_file(p, &file)?;
let extension = p
.extension()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
Ok(MatchedFile {
file: Arc::new(file),
file_info,
content_encoding: encoding,
extension,
})
}
Err(e) if e.kind() == ErrorKind::NotFound => Err(SerdirError::NotFound(None)),
Err(e) => Err(SerdirError::IOError(e)),
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ContentEncoding {
Gzip,
Brotli,
Zstd,
Identity,
}
impl ContentEncoding {
pub(crate) fn get_header_value(&self) -> Option<HeaderValue> {
match self {
ContentEncoding::Gzip => Some(HeaderValue::from_static("gzip")),
ContentEncoding::Brotli => Some(HeaderValue::from_static("br")),
ContentEncoding::Zstd => Some(HeaderValue::from_static("zstd")),
ContentEncoding::Identity => None,
}
}
}
#[derive(Clone)]
pub(crate) struct MatchedFile {
pub(crate) file_info: crate::FileInfo,
pub(crate) file: Arc<File>,
pub(crate) content_encoding: ContentEncoding,
pub(crate) extension: String,
}
#[cfg(test)]
mod tests {
use super::*;
use http::header::HeaderValue;
use http::{self, header};
fn ae_hdrs(value: &'static str) -> http::HeaderMap {
let mut h = http::HeaderMap::new();
h.insert(header::ACCEPT_ENCODING, HeaderValue::from_static(value));
h
}
#[test]
fn test_brotli_level_conversions() {
assert_eq!(i32::from(BrotliLevel::L0), 0);
assert_eq!(i32::from(BrotliLevel::L11), 11);
assert_eq!(BrotliLevel::try_from(0u8).unwrap(), BrotliLevel::L0);
assert_eq!(BrotliLevel::try_from(5u8).unwrap(), BrotliLevel::L5);
assert_eq!(BrotliLevel::try_from(11u8).unwrap(), BrotliLevel::L11);
assert!(BrotliLevel::try_from(12u8).is_err());
assert_eq!(BrotliLevel::try_from(0u32).unwrap(), BrotliLevel::L0);
assert_eq!(BrotliLevel::try_from(11u32).unwrap(), BrotliLevel::L11);
assert!(BrotliLevel::try_from(12u32).is_err());
}
#[test]
fn test_parse_qvalue() {
assert_eq!(parse_qvalue("0"), Ok(0));
assert_eq!(parse_qvalue("0."), Ok(0));
assert_eq!(parse_qvalue("0.0"), Ok(0));
assert_eq!(parse_qvalue("0.00"), Ok(0));
assert_eq!(parse_qvalue("0.000"), Ok(0));
assert_eq!(parse_qvalue("0.0000"), Err(()));
assert_eq!(parse_qvalue("0.2"), Ok(200));
assert_eq!(parse_qvalue("0.23"), Ok(230));
assert_eq!(parse_qvalue("0.234"), Ok(234));
assert_eq!(parse_qvalue("1"), Ok(1000));
assert_eq!(parse_qvalue("1."), Ok(1000));
assert_eq!(parse_qvalue("1.0"), Ok(1000));
assert_eq!(parse_qvalue("1.1"), Err(()));
assert_eq!(parse_qvalue("1.00"), Ok(1000));
assert_eq!(parse_qvalue("1.000"), Ok(1000));
assert_eq!(parse_qvalue("1.001"), Err(()));
assert_eq!(parse_qvalue("1.0000"), Err(()));
assert_eq!(parse_qvalue("2"), Err(()));
}
#[test]
fn test_detect_compression_support() {
let detect = CompressionSupport::detect(&header::HeaderMap::new());
assert!(!detect.gzip());
assert!(!detect.brotli());
assert!(!detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("gzip"));
assert!(detect.gzip());
assert!(!detect.brotli());
assert!(!detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("gzip;q=0.001"));
assert!(detect.gzip());
assert!(!detect.brotli());
assert!(!detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("br;q=0.001"));
assert!(!detect.gzip());
assert!(detect.brotli());
assert!(!detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("zstd;q=0.001"));
assert!(!detect.gzip());
assert!(!detect.brotli());
assert!(detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("br, gzip, zstd"));
assert!(detect.brotli());
assert!(detect.gzip());
assert!(detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("gzip;q=0"));
assert!(!detect.gzip());
assert!(!detect.brotli());
assert!(!detect.gzip());
let detect = CompressionSupport::detect(&ae_hdrs(""));
assert!(!detect.gzip());
assert!(!detect.brotli());
assert!(!detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("*"));
assert!(detect.gzip());
assert!(detect.brotli());
assert!(detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("gzip;q=0, *"));
assert!(!detect.gzip());
assert!(detect.brotli());
assert!(detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("identity;q=0, *"));
assert!(detect.gzip());
assert!(detect.brotli());
assert!(detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("identity;q=0.5, gzip;q=1.0"));
assert!(detect.gzip());
let detect = CompressionSupport::detect(&ae_hdrs("identity;q=1.0, gzip;q=0.5"));
assert!(!detect.gzip());
let detect = CompressionSupport::detect(&ae_hdrs("br;q=1.0, gzip;q=0.5, identity;q=0.1"));
assert!(detect.brotli());
assert!(detect.gzip());
assert!(!detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("zstd;q=1.0, gzip;q=0.5, identity;q=0.1"));
assert!(!detect.brotli());
assert!(detect.gzip());
assert!(detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("br;q=0.5, gzip;q=1.0, identity;q=0.1"));
assert!(detect.brotli());
assert!(detect.gzip());
assert!(!detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("zstd;q=1.0, gzip;q=0.5, identity;q=0.1"));
assert!(detect.zstd());
assert!(detect.gzip());
assert!(!detect.brotli());
let detect = CompressionSupport::detect(&ae_hdrs("*;q=0"));
assert!(!detect.gzip());
assert!(!detect.brotli());
assert!(!detect.zstd());
let detect = CompressionSupport::detect(&ae_hdrs("gzip;q=0.002")); assert!(detect.gzip());
}
#[test]
fn test_static_compression_into_strategy() {
let strategy: CompressionStrategy = StaticCompression::none()
.brotli(true)
.gzip(false)
.zstd(true)
.into();
let inner = strategy.into_inner();
match inner {
CompressionStrategyInner::Static(support) => {
assert!(support.brotli());
assert!(!support.gzip());
assert!(support.zstd());
}
_ => panic!("expected static compression strategy"),
}
}
#[test]
fn test_static_compression_constructor_disables_all_encodings() {
let strategy = CompressionStrategy::static_compression();
let inner = strategy.into_inner();
match inner {
CompressionStrategyInner::Static(support) => {
assert!(!support.brotli());
assert!(!support.gzip());
assert!(!support.zstd());
}
_ => panic!("expected static compression strategy"),
}
}
#[test]
#[cfg(feature = "runtime-compression")]
fn test_cached_compression_into_strategy() {
let strategy: CompressionStrategy = CachedCompression::new()
.max_size(16)
.compression_level(BrotliLevel::L5)
.into();
let inner = strategy.into_inner();
assert!(matches!(inner, CompressionStrategyInner::Cached(_)));
}
#[test]
fn test_compression_strategy_none() {
let strategy = CompressionStrategy::none();
assert!(matches!(strategy, CompressionStrategy::None));
let inner = strategy.into_inner();
assert!(inner.is_none());
}
#[test]
fn test_compression_strategy_default() {
let strategy = CompressionStrategy::default();
assert!(matches!(strategy, CompressionStrategy::None));
}
#[test]
fn test_compression_strategy_static() {
let strategy = CompressionStrategy::static_compression();
if let CompressionStrategy::Static(static_comp) = strategy {
assert!(!static_comp.br);
assert!(!static_comp.gzip);
assert!(!static_comp.zstd);
} else {
panic!("expected static compression strategy");
}
}
#[test]
#[cfg(feature = "runtime-compression")]
fn test_compression_strategy_cached() {
let strategy = CompressionStrategy::cached_compression();
assert!(matches!(strategy, CompressionStrategy::Cached(_)));
if let CompressionStrategy::Cached(cached) = strategy {
assert_eq!(cached.cache_size, 128);
assert_eq!(cached.compression_level, BrotliLevel::L5);
assert_eq!(cached.max_file_size, 1024 * 1024);
assert!(cached.supported_extensions.is_none());
}
}
}