use super::TransformOptionsPayload;
#[cfg(feature = "azure")]
use super::azure;
#[cfg(feature = "gcs")]
use super::gcs;
pub(super) const DEFAULT_MAX_CONCURRENT_TRANSFORMS: u64 = 64;
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
use super::remote::STORAGE_DOWNLOAD_TIMEOUT_SECS;
#[cfg(feature = "s3")]
use super::s3;
use super::stderr_write;
use std::collections::HashMap;
use std::env;
use std::fmt;
use std::io;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use url::Url;
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LogLevel {
Error = 0,
Warn = 1,
Info = 2,
Debug = 3,
}
impl LogLevel {
pub(super) fn cycle(self) -> Self {
match self {
Self::Info => Self::Debug,
Self::Debug => Self::Error,
Self::Error => Self::Warn,
Self::Warn => Self::Info,
}
}
pub(super) fn from_u8(v: u8) -> Self {
match v {
0 => Self::Error,
1 => Self::Warn,
2 => Self::Info,
3 => Self::Debug,
_ => Self::Info,
}
}
pub(super) fn as_str(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warn => "warn",
Self::Info => "info",
Self::Debug => "debug",
}
}
}
impl fmt::Display for LogLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for LogLevel {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"error" => Ok(Self::Error),
"warn" => Ok(Self::Warn),
"info" => Ok(Self::Info),
"debug" => Ok(Self::Debug),
_ => Err(format!(
"invalid log level `{s}`: expected error, warn, info, or debug"
)),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustedProxy {
Addr(IpAddr),
Cidr(IpAddr, u8),
}
impl TrustedProxy {
pub fn parse(s: &str) -> Result<Self, String> {
if let Some((addr_str, prefix_str)) = s.split_once('/') {
let addr: IpAddr = addr_str
.trim()
.parse()
.map_err(|e| format!("invalid IP in CIDR `{s}`: {e}"))?;
let prefix: u8 = prefix_str
.trim()
.parse()
.map_err(|e| format!("invalid prefix length in CIDR `{s}`: {e}"))?;
let max_prefix = match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
if prefix > max_prefix {
return Err(format!(
"prefix length {prefix} exceeds maximum {max_prefix} for `{s}`"
));
}
Ok(Self::Cidr(addr, prefix))
} else {
let addr: IpAddr = s
.trim()
.parse()
.map_err(|e| format!("invalid trusted proxy IP `{s}`: {e}"))?;
Ok(Self::Addr(addr))
}
}
pub(super) fn contains(&self, ip: IpAddr) -> bool {
match self {
Self::Addr(a) => *a == ip,
Self::Cidr(network, prefix_len) => {
let prefix = *prefix_len;
match (network, ip) {
(IpAddr::V4(net), IpAddr::V4(addr)) => {
if prefix == 0 {
return true;
}
let mask = u32::MAX << (32 - prefix);
(u32::from(*net) & mask) == (u32::from(addr) & mask)
}
(IpAddr::V6(net), IpAddr::V6(addr)) => {
if prefix == 0 {
return true;
}
let mask = u128::MAX << (128 - prefix);
(u128::from(*net) & mask) == (u128::from(addr) & mask)
}
_ => false, }
}
}
}
}
pub(super) fn is_trusted_proxy(trusted: &[TrustedProxy], ip: IpAddr) -> bool {
trusted.iter().any(|t| t.contains(ip))
}
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub(super) enum StorageBackendLabel {
Filesystem,
S3,
Gcs,
Azure,
}
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageBackend {
Filesystem,
#[cfg(feature = "s3")]
S3,
#[cfg(feature = "gcs")]
Gcs,
#[cfg(feature = "azure")]
Azure,
}
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
impl StorageBackend {
pub fn parse(value: &str) -> Result<Self, String> {
match value.to_ascii_lowercase().as_str() {
"filesystem" | "fs" | "local" => Ok(Self::Filesystem),
#[cfg(feature = "s3")]
"s3" => Ok(Self::S3),
#[cfg(feature = "gcs")]
"gcs" => Ok(Self::Gcs),
#[cfg(feature = "azure")]
"azure" => Ok(Self::Azure),
_ => {
let mut expected = vec!["filesystem"];
#[cfg(feature = "s3")]
expected.push("s3");
#[cfg(feature = "gcs")]
expected.push("gcs");
#[cfg(feature = "azure")]
expected.push("azure");
#[allow(unused_mut)]
let mut hint = String::new();
#[cfg(not(feature = "s3"))]
if value.eq_ignore_ascii_case("s3") {
hint = " (hint: rebuild with --features s3)".to_string();
}
#[cfg(not(feature = "gcs"))]
if value.eq_ignore_ascii_case("gcs") {
hint = " (hint: rebuild with --features gcs)".to_string();
}
#[cfg(not(feature = "azure"))]
if value.eq_ignore_ascii_case("azure") {
hint = " (hint: rebuild with --features azure)".to_string();
}
Err(format!(
"unknown storage backend `{value}` (expected {}){hint}",
expected.join(" or ")
))
}
}
}
}
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
pub const DEFAULT_STORAGE_ROOT: &str = ".";
pub(super) const DEFAULT_PUBLIC_MAX_AGE_SECONDS: u32 = 3600;
pub(super) const DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS: u32 = 60;
pub(super) const DEFAULT_SHUTDOWN_DRAIN_SECS: u64 = 10;
pub(super) const DEFAULT_TRANSFORM_DEADLINE_SECS: u64 = 30;
pub(super) const DEFAULT_MAX_INPUT_PIXELS: u64 = 40_000_000;
pub(super) const DEFAULT_KEEP_ALIVE_MAX_REQUESTS: u64 = 100;
use super::http_parse::DEFAULT_MAX_UPLOAD_BODY_BYTES;
pub type LogHandler = Arc<dyn Fn(&str) + Send + Sync>;
pub struct ServerConfig {
pub storage_root: PathBuf,
pub bearer_token: Option<String>,
pub public_base_url: Option<String>,
pub signed_url_key_id: Option<String>,
pub signed_url_secret: Option<String>,
pub signing_keys: HashMap<String, String>,
pub allow_insecure_url_sources: bool,
pub cache_root: Option<PathBuf>,
pub cache_max_bytes: u64,
pub public_max_age_seconds: u32,
pub public_stale_while_revalidate_seconds: u32,
pub disable_accept_negotiation: bool,
pub format_preference: Vec<crate::MediaType>,
pub log_handler: Option<LogHandler>,
pub log_level: Arc<AtomicU8>,
pub max_concurrent_transforms: u64,
pub transform_deadline_secs: u64,
pub max_input_pixels: u64,
pub max_upload_bytes: usize,
pub keep_alive_max_requests: u64,
pub metrics_token: Option<String>,
pub disable_metrics: bool,
pub health_token: Option<String>,
pub health_cache_min_free_bytes: Option<u64>,
pub health_max_memory_bytes: Option<u64>,
pub(crate) health_cache: Arc<super::handler::HealthCache>,
pub shutdown_drain_secs: u64,
pub draining: Arc<AtomicBool>,
pub custom_response_headers: Vec<(String, String)>,
pub max_source_bytes: u64,
pub max_watermark_bytes: u64,
pub max_remote_redirects: usize,
pub enable_compression: bool,
pub compression_level: u32,
pub transforms_in_flight: Arc<AtomicU64>,
pub presets: Arc<std::sync::RwLock<HashMap<String, TransformOptionsPayload>>>,
pub presets_file_path: Option<PathBuf>,
pub rate_limiter: Option<Arc<super::rate_limit::RateLimiter>>,
pub trusted_proxies: Vec<TrustedProxy>,
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
pub storage_timeout_secs: u64,
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
pub storage_backend: StorageBackend,
#[cfg(feature = "s3")]
pub s3_context: Option<Arc<s3::S3Context>>,
#[cfg(feature = "gcs")]
pub gcs_context: Option<Arc<gcs::GcsContext>>,
#[cfg(feature = "azure")]
pub azure_context: Option<Arc<azure::AzureContext>>,
}
impl Clone for ServerConfig {
fn clone(&self) -> Self {
Self {
storage_root: self.storage_root.clone(),
bearer_token: self.bearer_token.clone(),
public_base_url: self.public_base_url.clone(),
signed_url_key_id: self.signed_url_key_id.clone(),
signed_url_secret: self.signed_url_secret.clone(),
signing_keys: self.signing_keys.clone(),
allow_insecure_url_sources: self.allow_insecure_url_sources,
cache_root: self.cache_root.clone(),
cache_max_bytes: self.cache_max_bytes,
public_max_age_seconds: self.public_max_age_seconds,
public_stale_while_revalidate_seconds: self.public_stale_while_revalidate_seconds,
disable_accept_negotiation: self.disable_accept_negotiation,
format_preference: self.format_preference.clone(),
log_handler: self.log_handler.clone(),
log_level: Arc::clone(&self.log_level),
max_concurrent_transforms: self.max_concurrent_transforms,
transform_deadline_secs: self.transform_deadline_secs,
max_input_pixels: self.max_input_pixels,
max_upload_bytes: self.max_upload_bytes,
keep_alive_max_requests: self.keep_alive_max_requests,
metrics_token: self.metrics_token.clone(),
disable_metrics: self.disable_metrics,
health_token: self.health_token.clone(),
health_cache_min_free_bytes: self.health_cache_min_free_bytes,
health_max_memory_bytes: self.health_max_memory_bytes,
health_cache: Arc::clone(&self.health_cache),
shutdown_drain_secs: self.shutdown_drain_secs,
draining: Arc::clone(&self.draining),
custom_response_headers: self.custom_response_headers.clone(),
max_source_bytes: self.max_source_bytes,
max_watermark_bytes: self.max_watermark_bytes,
max_remote_redirects: self.max_remote_redirects,
enable_compression: self.enable_compression,
compression_level: self.compression_level,
transforms_in_flight: Arc::clone(&self.transforms_in_flight),
presets: Arc::clone(&self.presets),
presets_file_path: self.presets_file_path.clone(),
rate_limiter: self.rate_limiter.as_ref().map(Arc::clone),
trusted_proxies: self.trusted_proxies.clone(),
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
storage_timeout_secs: self.storage_timeout_secs,
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
storage_backend: self.storage_backend,
#[cfg(feature = "s3")]
s3_context: self.s3_context.clone(),
#[cfg(feature = "gcs")]
gcs_context: self.gcs_context.clone(),
#[cfg(feature = "azure")]
azure_context: self.azure_context.clone(),
}
}
}
impl fmt::Debug for ServerConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut d = f.debug_struct("ServerConfig");
d.field("storage_root", &self.storage_root)
.field(
"bearer_token",
&self.bearer_token.as_ref().map(|_| "[REDACTED]"),
)
.field("public_base_url", &self.public_base_url)
.field("signed_url_key_id", &self.signed_url_key_id)
.field(
"signed_url_secret",
&self.signed_url_secret.as_ref().map(|_| "[REDACTED]"),
)
.field(
"signing_keys",
&self.signing_keys.keys().collect::<Vec<_>>(),
)
.field(
"allow_insecure_url_sources",
&self.allow_insecure_url_sources,
)
.field("cache_root", &self.cache_root)
.field("cache_max_bytes", &self.cache_max_bytes)
.field("public_max_age_seconds", &self.public_max_age_seconds)
.field(
"public_stale_while_revalidate_seconds",
&self.public_stale_while_revalidate_seconds,
)
.field(
"disable_accept_negotiation",
&self.disable_accept_negotiation,
)
.field("format_preference", &self.format_preference)
.field("log_handler", &self.log_handler.as_ref().map(|_| ".."))
.field("log_level", &self.current_log_level())
.field("max_concurrent_transforms", &self.max_concurrent_transforms)
.field("transform_deadline_secs", &self.transform_deadline_secs)
.field("max_input_pixels", &self.max_input_pixels)
.field("max_upload_bytes", &self.max_upload_bytes)
.field("keep_alive_max_requests", &self.keep_alive_max_requests)
.field(
"metrics_token",
&self.metrics_token.as_ref().map(|_| "[REDACTED]"),
)
.field("disable_metrics", &self.disable_metrics)
.field(
"health_token",
&self.health_token.as_ref().map(|_| "[REDACTED]"),
)
.field(
"health_cache_min_free_bytes",
&self.health_cache_min_free_bytes,
)
.field("health_max_memory_bytes", &self.health_max_memory_bytes)
.field("health_cache_ttl_nanos", &self.health_cache.ttl_nanos)
.field("shutdown_drain_secs", &self.shutdown_drain_secs)
.field(
"custom_response_headers",
&self.custom_response_headers.len(),
)
.field("enable_compression", &self.enable_compression)
.field("compression_level", &self.compression_level)
.field(
"presets",
&self
.presets
.read()
.map(|p| p.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default(),
)
.field("presets_file_path", &self.presets_file_path)
.field("rate_limiter", &self.rate_limiter.is_some())
.field("trusted_proxies", &self.trusted_proxies);
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
{
d.field("storage_backend", &self.storage_backend);
}
#[cfg(feature = "s3")]
{
d.field("s3_context", &self.s3_context.as_ref().map(|_| ".."));
}
#[cfg(feature = "gcs")]
{
d.field("gcs_context", &self.gcs_context.as_ref().map(|_| ".."));
}
#[cfg(feature = "azure")]
{
d.field("azure_context", &self.azure_context.as_ref().map(|_| ".."));
}
d.finish()
}
}
impl PartialEq for ServerConfig {
fn eq(&self, other: &Self) -> bool {
self.storage_root == other.storage_root
&& self.bearer_token == other.bearer_token
&& self.public_base_url == other.public_base_url
&& self.signed_url_key_id == other.signed_url_key_id
&& self.signed_url_secret == other.signed_url_secret
&& self.signing_keys == other.signing_keys
&& self.allow_insecure_url_sources == other.allow_insecure_url_sources
&& self.cache_root == other.cache_root
&& self.cache_max_bytes == other.cache_max_bytes
&& self.public_max_age_seconds == other.public_max_age_seconds
&& self.public_stale_while_revalidate_seconds
== other.public_stale_while_revalidate_seconds
&& self.disable_accept_negotiation == other.disable_accept_negotiation
&& self.format_preference == other.format_preference
&& self.max_concurrent_transforms == other.max_concurrent_transforms
&& self.transform_deadline_secs == other.transform_deadline_secs
&& self.max_input_pixels == other.max_input_pixels
&& self.max_upload_bytes == other.max_upload_bytes
&& self.keep_alive_max_requests == other.keep_alive_max_requests
&& self.metrics_token == other.metrics_token
&& self.disable_metrics == other.disable_metrics
&& self.health_token == other.health_token
&& self.health_cache_min_free_bytes == other.health_cache_min_free_bytes
&& self.health_max_memory_bytes == other.health_max_memory_bytes
&& self.health_cache.ttl_nanos == other.health_cache.ttl_nanos
&& self.health_cache.hysteresis_margin == other.health_cache.hysteresis_margin
&& self.shutdown_drain_secs == other.shutdown_drain_secs
&& self.custom_response_headers == other.custom_response_headers
&& self.max_source_bytes == other.max_source_bytes
&& self.max_watermark_bytes == other.max_watermark_bytes
&& self.max_remote_redirects == other.max_remote_redirects
&& self.enable_compression == other.enable_compression
&& self.compression_level == other.compression_level
&& *self.presets.read().unwrap() == *other.presets.read().unwrap()
&& self.presets_file_path == other.presets_file_path
&& self.rate_limiter.is_some() == other.rate_limiter.is_some()
&& self.trusted_proxies == other.trusted_proxies
&& cfg_storage_eq(self, other)
}
}
fn cfg_storage_eq(_this: &ServerConfig, _other: &ServerConfig) -> bool {
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
{
if _this.storage_backend != _other.storage_backend {
return false;
}
}
#[cfg(feature = "s3")]
{
if _this
.s3_context
.as_ref()
.map(|c| (&c.default_bucket, &c.endpoint_url))
!= _other
.s3_context
.as_ref()
.map(|c| (&c.default_bucket, &c.endpoint_url))
{
return false;
}
}
#[cfg(feature = "gcs")]
{
if _this
.gcs_context
.as_ref()
.map(|c| (&c.default_bucket, &c.endpoint_url))
!= _other
.gcs_context
.as_ref()
.map(|c| (&c.default_bucket, &c.endpoint_url))
{
return false;
}
}
#[cfg(feature = "azure")]
{
if _this
.azure_context
.as_ref()
.map(|c| (&c.default_container, &c.endpoint_url))
!= _other
.azure_context
.as_ref()
.map(|c| (&c.default_container, &c.endpoint_url))
{
return false;
}
}
true
}
impl Eq for ServerConfig {}
impl ServerConfig {
pub fn new(storage_root: PathBuf, bearer_token: Option<String>) -> Self {
Self {
storage_root,
bearer_token,
public_base_url: None,
signed_url_key_id: None,
signed_url_secret: None,
signing_keys: HashMap::new(),
allow_insecure_url_sources: false,
cache_root: None,
cache_max_bytes: 0,
public_max_age_seconds: DEFAULT_PUBLIC_MAX_AGE_SECONDS,
public_stale_while_revalidate_seconds: DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
disable_accept_negotiation: false,
format_preference: Vec::new(),
log_handler: None,
log_level: Arc::new(AtomicU8::new(LogLevel::Info as u8)),
max_concurrent_transforms: DEFAULT_MAX_CONCURRENT_TRANSFORMS,
transform_deadline_secs: DEFAULT_TRANSFORM_DEADLINE_SECS,
max_input_pixels: DEFAULT_MAX_INPUT_PIXELS,
max_upload_bytes: DEFAULT_MAX_UPLOAD_BODY_BYTES,
keep_alive_max_requests: DEFAULT_KEEP_ALIVE_MAX_REQUESTS,
metrics_token: None,
disable_metrics: false,
health_token: None,
health_cache_min_free_bytes: None,
health_max_memory_bytes: None,
health_cache: Arc::new(super::handler::HealthCache::new(
super::handler::DEFAULT_HEALTH_CACHE_TTL_SECS,
super::handler::DEFAULT_HYSTERESIS_MARGIN,
)),
shutdown_drain_secs: DEFAULT_SHUTDOWN_DRAIN_SECS,
draining: Arc::new(AtomicBool::new(false)),
custom_response_headers: Vec::new(),
max_source_bytes: super::remote::MAX_SOURCE_BYTES,
max_watermark_bytes: super::remote::MAX_WATERMARK_BYTES,
max_remote_redirects: super::remote::MAX_REMOTE_REDIRECTS,
enable_compression: true,
compression_level: 1,
transforms_in_flight: Arc::new(AtomicU64::new(0)),
presets: Arc::new(std::sync::RwLock::new(HashMap::new())),
presets_file_path: None,
rate_limiter: None,
trusted_proxies: Vec::new(),
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
storage_timeout_secs: STORAGE_DOWNLOAD_TIMEOUT_SECS,
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
storage_backend: StorageBackend::Filesystem,
#[cfg(feature = "s3")]
s3_context: None,
#[cfg(feature = "gcs")]
gcs_context: None,
#[cfg(feature = "azure")]
azure_context: None,
}
}
pub fn with_health_cache_ttl_secs(mut self, ttl_secs: u64) -> Self {
let margin = self.health_cache.hysteresis_margin;
self.health_cache = Arc::new(super::handler::HealthCache::new(ttl_secs, margin));
self
}
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
pub(super) fn storage_backend_label(&self) -> StorageBackendLabel {
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
{
match self.storage_backend {
StorageBackend::Filesystem => StorageBackendLabel::Filesystem,
#[cfg(feature = "s3")]
StorageBackend::S3 => StorageBackendLabel::S3,
#[cfg(feature = "gcs")]
StorageBackend::Gcs => StorageBackendLabel::Gcs,
#[cfg(feature = "azure")]
StorageBackend::Azure => StorageBackendLabel::Azure,
}
}
#[cfg(not(any(feature = "s3", feature = "gcs", feature = "azure")))]
{
StorageBackendLabel::Filesystem
}
}
pub(super) fn current_log_level(&self) -> LogLevel {
LogLevel::from_u8(self.log_level.load(Ordering::Relaxed))
}
pub(super) fn log_at(&self, level: LogLevel, msg: &str) {
if level > self.current_log_level() {
return;
}
if let Some(handler) = &self.log_handler {
handler(msg);
} else {
stderr_write(msg);
}
}
pub(super) fn log(&self, msg: &str) {
self.log_at(LogLevel::Info, msg);
}
pub(super) fn log_warn(&self, msg: &str) {
self.log_at(LogLevel::Warn, msg);
}
pub fn with_signed_url_credentials(
mut self,
key_id: impl Into<String>,
secret: impl Into<String>,
) -> Self {
let key_id = key_id.into();
let secret = secret.into();
self.signing_keys.insert(key_id.clone(), secret.clone());
self.signed_url_key_id = Some(key_id);
self.signed_url_secret = Some(secret);
self
}
pub fn with_signing_keys(mut self, keys: HashMap<String, String>) -> Self {
self.signing_keys.extend(keys);
self
}
pub fn with_insecure_url_sources(mut self, allow_insecure_url_sources: bool) -> Self {
self.allow_insecure_url_sources = allow_insecure_url_sources;
self
}
pub fn with_cache_root(mut self, cache_root: impl Into<PathBuf>) -> Self {
self.cache_root = Some(cache_root.into());
self
}
pub fn with_cache_max_bytes(mut self, max_bytes: u64) -> Self {
self.cache_max_bytes = max_bytes;
self
}
#[cfg(feature = "s3")]
pub fn with_s3_context(mut self, context: s3::S3Context) -> Self {
self.storage_backend = StorageBackend::S3;
self.s3_context = Some(Arc::new(context));
self
}
#[cfg(feature = "gcs")]
pub fn with_gcs_context(mut self, context: gcs::GcsContext) -> Self {
self.storage_backend = StorageBackend::Gcs;
self.gcs_context = Some(Arc::new(context));
self
}
#[cfg(feature = "azure")]
pub fn with_azure_context(mut self, context: azure::AzureContext) -> Self {
self.storage_backend = StorageBackend::Azure;
self.azure_context = Some(Arc::new(context));
self
}
pub fn with_presets(mut self, presets: HashMap<String, TransformOptionsPayload>) -> Self {
self.presets = Arc::new(std::sync::RwLock::new(presets));
self
}
pub fn from_env() -> io::Result<Self> {
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
let storage_backend = match env::var("TRUSS_STORAGE_BACKEND")
.ok()
.filter(|v| !v.is_empty())
{
Some(value) => StorageBackend::parse(&value)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?,
None => StorageBackend::Filesystem,
};
let storage_root =
env::var("TRUSS_STORAGE_ROOT").unwrap_or_else(|_| DEFAULT_STORAGE_ROOT.to_string());
let storage_root = PathBuf::from(storage_root).canonicalize()?;
let bearer_token = env::var("TRUSS_BEARER_TOKEN")
.ok()
.filter(|value| !value.is_empty());
let public_base_url = env::var("TRUSS_PUBLIC_BASE_URL")
.ok()
.filter(|value| !value.is_empty())
.map(validate_public_base_url)
.transpose()?;
let signed_url_key_id = env::var("TRUSS_SIGNED_URL_KEY_ID")
.ok()
.filter(|value| !value.is_empty());
let signed_url_secret = env::var("TRUSS_SIGNED_URL_SECRET")
.ok()
.filter(|value| !value.is_empty());
if signed_url_key_id.is_some() != signed_url_secret.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"TRUSS_SIGNED_URL_KEY_ID and TRUSS_SIGNED_URL_SECRET must be set together",
));
}
let mut signing_keys = HashMap::new();
if let (Some(kid), Some(sec)) = (&signed_url_key_id, &signed_url_secret) {
signing_keys.insert(kid.clone(), sec.clone());
}
if let Ok(json) = env::var("TRUSS_SIGNING_KEYS")
&& !json.is_empty()
{
let extra: HashMap<String, String> = serde_json::from_str(&json).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("TRUSS_SIGNING_KEYS must be valid JSON: {e}"),
)
})?;
for (kid, sec) in &extra {
if kid.is_empty() || sec.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"TRUSS_SIGNING_KEYS must not contain empty key IDs or secrets",
));
}
}
signing_keys.extend(extra);
}
if !signing_keys.is_empty() && public_base_url.is_none() {
eprintln!(
"truss: warning: signing keys are configured but TRUSS_PUBLIC_BASE_URL is not. \
Behind a reverse proxy or CDN the Host header may differ from the externally \
visible authority, causing signed URL verification to fail. Consider setting \
TRUSS_PUBLIC_BASE_URL to the canonical external origin."
);
}
let cache_root = env::var("TRUSS_CACHE_ROOT")
.ok()
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let cache_max_bytes =
parse_env_u64_ranged("TRUSS_CACHE_MAX_BYTES", 0, u64::MAX)?.unwrap_or(0);
let public_max_age_seconds = parse_optional_env_u32("TRUSS_PUBLIC_MAX_AGE")?
.unwrap_or(DEFAULT_PUBLIC_MAX_AGE_SECONDS);
let public_stale_while_revalidate_seconds =
parse_optional_env_u32("TRUSS_PUBLIC_STALE_WHILE_REVALIDATE")?
.unwrap_or(DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS);
let allow_insecure_url_sources = env_flag("TRUSS_ALLOW_INSECURE_URL_SOURCES");
let max_concurrent_transforms =
parse_env_u64_ranged("TRUSS_MAX_CONCURRENT_TRANSFORMS", 1, 1024)?
.unwrap_or(DEFAULT_MAX_CONCURRENT_TRANSFORMS);
let transform_deadline_secs =
parse_env_u64_ranged("TRUSS_TRANSFORM_DEADLINE_SECS", 1, 300)?
.unwrap_or(DEFAULT_TRANSFORM_DEADLINE_SECS);
let max_input_pixels =
parse_env_u64_ranged("TRUSS_MAX_INPUT_PIXELS", 1, crate::MAX_DECODED_PIXELS)?
.unwrap_or(DEFAULT_MAX_INPUT_PIXELS);
let max_upload_bytes =
parse_env_u64_ranged("TRUSS_MAX_UPLOAD_BYTES", 1, 10 * 1024 * 1024 * 1024)?
.unwrap_or(DEFAULT_MAX_UPLOAD_BODY_BYTES as u64) as usize;
let keep_alive_max_requests =
parse_env_u64_ranged("TRUSS_KEEP_ALIVE_MAX_REQUESTS", 1, 100_000)?
.unwrap_or(DEFAULT_KEEP_ALIVE_MAX_REQUESTS);
let max_source_bytes =
parse_env_u64_ranged("TRUSS_MAX_SOURCE_BYTES", 1, 10 * 1024 * 1024 * 1024)?
.unwrap_or(super::remote::MAX_SOURCE_BYTES);
let max_watermark_bytes =
parse_env_u64_ranged("TRUSS_MAX_WATERMARK_BYTES", 1, 1024 * 1024 * 1024)?
.unwrap_or(super::remote::MAX_WATERMARK_BYTES);
let max_remote_redirects = parse_env_u64_ranged("TRUSS_MAX_REMOTE_REDIRECTS", 0, 20)?
.unwrap_or(super::remote::MAX_REMOTE_REDIRECTS as u64)
as usize;
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
let storage_timeout_secs = parse_env_u64_ranged("TRUSS_STORAGE_TIMEOUT_SECS", 1, 300)?
.unwrap_or(STORAGE_DOWNLOAD_TIMEOUT_SECS);
#[cfg(feature = "s3")]
let s3_context = if storage_backend == StorageBackend::S3 {
let bucket = env::var("TRUSS_S3_BUCKET")
.ok()
.filter(|v| !v.is_empty())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"TRUSS_S3_BUCKET is required when TRUSS_STORAGE_BACKEND=s3",
)
})?;
Some(Arc::new(s3::build_s3_context(
bucket,
allow_insecure_url_sources,
)?))
} else {
None
};
#[cfg(feature = "gcs")]
let gcs_context = if storage_backend == StorageBackend::Gcs {
let bucket = env::var("TRUSS_GCS_BUCKET")
.ok()
.filter(|v| !v.is_empty())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"TRUSS_GCS_BUCKET is required when TRUSS_STORAGE_BACKEND=gcs",
)
})?;
Some(Arc::new(gcs::build_gcs_context(
bucket,
allow_insecure_url_sources,
)?))
} else {
if env::var("TRUSS_GCS_BUCKET")
.ok()
.filter(|v| !v.is_empty())
.is_some()
{
eprintln!(
"truss: warning: TRUSS_GCS_BUCKET is set but TRUSS_STORAGE_BACKEND is not \
`gcs`. The GCS bucket will be ignored. Set TRUSS_STORAGE_BACKEND=gcs to \
enable the GCS backend."
);
}
None
};
#[cfg(feature = "azure")]
let azure_context = if storage_backend == StorageBackend::Azure {
let container = env::var("TRUSS_AZURE_CONTAINER")
.ok()
.filter(|v| !v.is_empty())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"TRUSS_AZURE_CONTAINER is required when TRUSS_STORAGE_BACKEND=azure",
)
})?;
Some(Arc::new(azure::build_azure_context(
container,
allow_insecure_url_sources,
)?))
} else {
if env::var("TRUSS_AZURE_CONTAINER")
.ok()
.filter(|v| !v.is_empty())
.is_some()
{
eprintln!(
"truss: warning: TRUSS_AZURE_CONTAINER is set but TRUSS_STORAGE_BACKEND is not \
`azure`. The Azure container will be ignored. Set TRUSS_STORAGE_BACKEND=azure to \
enable the Azure backend."
);
}
None
};
let metrics_token = env::var("TRUSS_METRICS_TOKEN")
.ok()
.filter(|value| !value.trim().is_empty());
let disable_metrics = env_flag("TRUSS_DISABLE_METRICS");
let health_token = env::var("TRUSS_HEALTH_TOKEN")
.ok()
.filter(|value| !value.trim().is_empty());
if health_token.is_some() {
eprintln!(
"truss: /health endpoint requires Bearer authentication (TRUSS_HEALTH_TOKEN is set)"
);
}
let health_cache_min_free_bytes =
parse_env_u64_ranged("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", 1, u64::MAX)?;
let health_max_memory_bytes =
parse_env_u64_ranged("TRUSS_HEALTH_MAX_MEMORY_BYTES", 1, u64::MAX)?;
let health_cache_ttl_secs = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_TTL_SECS", 0, 300)?
.unwrap_or(super::handler::DEFAULT_HEALTH_CACHE_TTL_SECS);
let hysteresis_margin = parse_env_f64_ranged("TRUSS_HEALTH_HYSTERESIS_MARGIN", 0.01, 0.50)?
.unwrap_or(super::handler::DEFAULT_HYSTERESIS_MARGIN);
let health_cache = Arc::new(super::handler::HealthCache::new(
health_cache_ttl_secs,
hysteresis_margin,
));
let (presets, presets_file_path) = parse_presets_from_env()?;
let shutdown_drain_secs = parse_env_u64_ranged("TRUSS_SHUTDOWN_DRAIN_SECS", 0, 300)?
.unwrap_or(DEFAULT_SHUTDOWN_DRAIN_SECS);
let custom_response_headers = parse_response_headers_from_env()?;
let enable_compression = !env_flag("TRUSS_DISABLE_COMPRESSION");
let compression_level =
parse_env_u64_ranged("TRUSS_COMPRESSION_LEVEL", 0, 9)?.unwrap_or(1) as u32;
let log_level = match env::var("TRUSS_LOG_LEVEL").ok().filter(|v| !v.is_empty()) {
Some(val) => val
.parse::<LogLevel>()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?,
None => LogLevel::Info,
};
let format_preference = parse_format_preference_from_env()?;
let rate_limiter = {
let rps = parse_env_u64_ranged("TRUSS_RATE_LIMIT_RPS", 0, 100_000)?.unwrap_or(0);
if rps > 0 {
let burst =
parse_env_u64_ranged("TRUSS_RATE_LIMIT_BURST", 1, 100_000)?.unwrap_or(rps);
Some(Arc::new(super::rate_limit::RateLimiter::new(
rps as f64,
burst as f64,
)))
} else {
None
}
};
let trusted_proxies = match env::var("TRUSS_TRUSTED_PROXIES")
.ok()
.filter(|v| !v.is_empty())
{
Some(val) => val
.split(',')
.filter(|s| !s.trim().is_empty())
.map(TrustedProxy::parse)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?,
None => Vec::new(),
};
Ok(Self {
storage_root,
bearer_token,
public_base_url,
signed_url_key_id,
signed_url_secret,
signing_keys,
allow_insecure_url_sources,
cache_root,
cache_max_bytes,
public_max_age_seconds,
public_stale_while_revalidate_seconds,
disable_accept_negotiation: env_flag("TRUSS_DISABLE_ACCEPT_NEGOTIATION"),
format_preference,
log_handler: None,
log_level: Arc::new(AtomicU8::new(log_level as u8)),
max_concurrent_transforms,
transform_deadline_secs,
max_input_pixels,
max_upload_bytes,
keep_alive_max_requests,
metrics_token,
disable_metrics,
health_token,
health_cache_min_free_bytes,
health_max_memory_bytes,
health_cache,
shutdown_drain_secs,
draining: Arc::new(AtomicBool::new(false)),
custom_response_headers,
max_source_bytes,
max_watermark_bytes,
max_remote_redirects,
enable_compression,
compression_level,
transforms_in_flight: Arc::new(AtomicU64::new(0)),
presets: Arc::new(std::sync::RwLock::new(presets)),
presets_file_path,
rate_limiter,
trusted_proxies,
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
storage_timeout_secs,
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
storage_backend,
#[cfg(feature = "s3")]
s3_context,
#[cfg(feature = "gcs")]
gcs_context,
#[cfg(feature = "azure")]
azure_context,
})
}
}
pub(super) fn parse_env_u64_ranged(name: &str, min: u64, max: u64) -> io::Result<Option<u64>> {
match env::var(name).ok().filter(|v| !v.is_empty()) {
Some(value) => {
let n: u64 = value.parse().map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} must be a positive integer"),
)
})?;
if n < min || n > max {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} must be between {min} and {max}"),
));
}
Ok(Some(n))
}
None => Ok(None),
}
}
fn parse_env_f64_ranged(name: &str, min: f64, max: f64) -> io::Result<Option<f64>> {
match env::var(name).ok().filter(|v| !v.is_empty()) {
Some(value) => {
let n: f64 = value.parse().map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} must be a number"),
)
})?;
if n < min || n > max {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} must be between {min} and {max}"),
));
}
Ok(Some(n))
}
None => Ok(None),
}
}
pub(super) fn parse_format_preference_from_env() -> io::Result<Vec<crate::MediaType>> {
let value = match env::var("TRUSS_FORMAT_PREFERENCE")
.ok()
.filter(|v| !v.is_empty())
{
Some(v) => v,
None => return Ok(Vec::new()),
};
let mut formats = Vec::new();
for segment in value.split(',') {
let name = segment.trim();
if name.is_empty() {
continue;
}
let media_type: crate::MediaType = name.parse().map_err(|e: String| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("TRUSS_FORMAT_PREFERENCE: {e}"),
)
})?;
if formats.contains(&media_type) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("TRUSS_FORMAT_PREFERENCE: duplicate format `{name}`"),
));
}
formats.push(media_type);
}
Ok(formats)
}
pub(super) fn env_flag(name: &str) -> bool {
env::var(name)
.map(|value| {
matches!(
value.as_str(),
"1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON"
)
})
.unwrap_or(false)
}
pub(super) fn parse_optional_env_u32(name: &str) -> io::Result<Option<u32>> {
match env::var(name) {
Ok(value) if !value.is_empty() => value.parse::<u32>().map(Some).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} must be a non-negative integer"),
)
}),
_ => Ok(None),
}
}
pub(super) fn parse_presets_from_env()
-> io::Result<(HashMap<String, TransformOptionsPayload>, Option<PathBuf>)> {
let (json_str, source, file_path) = match env::var("TRUSS_PRESETS_FILE")
.ok()
.filter(|v| !v.is_empty())
{
Some(path) => {
let content = std::fs::read_to_string(&path).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("failed to read TRUSS_PRESETS_FILE `{path}`: {e}"),
)
})?;
let pb = PathBuf::from(&path);
(content, format!("TRUSS_PRESETS_FILE `{path}`"), Some(pb))
}
None => match env::var("TRUSS_PRESETS").ok().filter(|v| !v.is_empty()) {
Some(value) => (value, "TRUSS_PRESETS".to_string(), None),
None => return Ok((HashMap::new(), None)),
},
};
let presets = serde_json::from_str::<HashMap<String, TransformOptionsPayload>>(&json_str)
.map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{source} must be valid JSON: {e}"),
)
})?;
Ok((presets, file_path))
}
pub(super) fn parse_presets_file(
path: &std::path::Path,
) -> io::Result<HashMap<String, TransformOptionsPayload>> {
let content = std::fs::read_to_string(path)?;
serde_json::from_str::<HashMap<String, TransformOptionsPayload>>(&content).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid preset JSON in `{}`: {e}", path.display()),
)
})
}
fn parse_response_headers_from_env() -> io::Result<Vec<(String, String)>> {
let raw = match env::var("TRUSS_RESPONSE_HEADERS")
.ok()
.filter(|v| !v.is_empty())
{
Some(value) => value,
None => return Ok(Vec::new()),
};
let map: HashMap<String, String> = serde_json::from_str(&raw).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("TRUSS_RESPONSE_HEADERS must be a JSON object: {e}"),
)
})?;
let mut headers = Vec::with_capacity(map.len());
for (name, value) in map {
validate_header_name(&name)?;
reject_denied_header(&name)?;
validate_header_value(&name, &value)?;
headers.push((name, value));
}
headers.sort_by(|a, b| a.0.cmp(&b.0));
Ok(headers)
}
fn validate_header_name(name: &str) -> io::Result<()> {
if name.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"TRUSS_RESPONSE_HEADERS: header name must not be empty",
));
}
for byte in name.bytes() {
let valid = byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
);
if !valid {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("TRUSS_RESPONSE_HEADERS: invalid character in header name `{name}`"),
));
}
}
Ok(())
}
fn validate_header_value(name: &str, value: &str) -> io::Result<()> {
for byte in value.bytes() {
let valid = byte == b'\t' || (0x20..=0x7E).contains(&byte);
if !valid {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("TRUSS_RESPONSE_HEADERS: invalid character in value for header `{name}`"),
));
}
}
Ok(())
}
fn reject_denied_header(name: &str) -> io::Result<()> {
const DENIED: &[&str] = &[
"content-length",
"transfer-encoding",
"content-encoding",
"content-type",
"connection",
"host",
"upgrade",
"proxy-connection",
"keep-alive",
"te",
"trailer",
];
let lower = name.to_ascii_lowercase();
if DENIED.contains(&lower.as_str()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"TRUSS_RESPONSE_HEADERS: header `{name}` is not allowed (framing/hop-by-hop header)"
),
));
}
Ok(())
}
pub(super) fn validate_public_base_url(value: String) -> io::Result<String> {
let parsed = Url::parse(&value).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("TRUSS_PUBLIC_BASE_URL must be a valid URL: {error}"),
)
})?;
match parsed.scheme() {
"http" | "https" => Ok(parsed.to_string()),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"TRUSS_PUBLIC_BASE_URL must use http or https",
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
struct ScopedEnv {
key: &'static str,
}
impl ScopedEnv {
fn set(key: &'static str, value: &str) -> Self {
unsafe { env::set_var(key, value) };
Self { key }
}
fn remove(key: &'static str) -> Self {
unsafe { env::remove_var(key) };
Self { key }
}
}
impl Drop for ScopedEnv {
fn drop(&mut self) {
unsafe { env::remove_var(self.key) };
}
}
#[test]
fn keep_alive_default() {
let config = ServerConfig::new(PathBuf::from("."), None);
assert_eq!(config.keep_alive_max_requests, 100);
}
#[test]
#[serial]
fn parse_keep_alive_env_valid() {
let _env = ScopedEnv::set("TRUSS_KEEP_ALIVE_MAX_REQUESTS", "500");
let result = parse_env_u64_ranged("TRUSS_KEEP_ALIVE_MAX_REQUESTS", 1, 100_000);
assert_eq!(result.unwrap(), Some(500));
}
#[test]
#[serial]
fn parse_keep_alive_env_zero_rejected() {
let _env = ScopedEnv::set("TRUSS_KEEP_ALIVE_MAX_REQUESTS", "0");
let result = parse_env_u64_ranged("TRUSS_KEEP_ALIVE_MAX_REQUESTS", 1, 100_000);
assert!(result.is_err());
}
#[test]
#[serial]
fn parse_keep_alive_env_over_max_rejected() {
let _env = ScopedEnv::set("TRUSS_KEEP_ALIVE_MAX_REQUESTS", "100001");
let result = parse_env_u64_ranged("TRUSS_KEEP_ALIVE_MAX_REQUESTS", 1, 100_000);
assert!(result.is_err());
}
#[test]
fn health_thresholds_default_none() {
let config = ServerConfig::new(PathBuf::from("."), None);
assert!(config.health_cache_min_free_bytes.is_none());
assert!(config.health_max_memory_bytes.is_none());
}
#[test]
#[serial]
fn parse_health_cache_min_free_bytes_valid() {
let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", "1073741824");
let result = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", 1, u64::MAX);
assert_eq!(result.unwrap(), Some(1_073_741_824));
}
#[test]
#[serial]
fn parse_health_max_memory_bytes_valid() {
let _env = ScopedEnv::set("TRUSS_HEALTH_MAX_MEMORY_BYTES", "536870912");
let result = parse_env_u64_ranged("TRUSS_HEALTH_MAX_MEMORY_BYTES", 1, u64::MAX);
assert_eq!(result.unwrap(), Some(536_870_912));
}
#[test]
#[serial]
fn parse_health_threshold_zero_rejected() {
let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", "0");
let result = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", 1, u64::MAX);
assert!(result.is_err());
}
#[test]
fn shutdown_drain_secs_default() {
let config = ServerConfig::new(PathBuf::from("."), None);
assert_eq!(config.shutdown_drain_secs, DEFAULT_SHUTDOWN_DRAIN_SECS);
}
#[test]
fn draining_default_false() {
let config = ServerConfig::new(PathBuf::from("."), None);
assert!(!config.draining.load(std::sync::atomic::Ordering::Relaxed));
}
#[test]
#[serial]
fn parse_shutdown_drain_secs_valid() {
let _env = ScopedEnv::set("TRUSS_SHUTDOWN_DRAIN_SECS", "30");
let result = parse_env_u64_ranged("TRUSS_SHUTDOWN_DRAIN_SECS", 0, 300);
assert_eq!(result.unwrap(), Some(30));
}
#[test]
#[serial]
fn parse_shutdown_drain_secs_over_max_rejected() {
let _env = ScopedEnv::set("TRUSS_SHUTDOWN_DRAIN_SECS", "301");
let result = parse_env_u64_ranged("TRUSS_SHUTDOWN_DRAIN_SECS", 0, 300);
assert!(result.is_err());
}
#[test]
fn presets_default_empty() {
let config = ServerConfig::new(PathBuf::from("."), None);
assert!(config.presets.read().unwrap().is_empty());
assert!(config.presets_file_path.is_none());
}
#[test]
fn parse_presets_file_valid() {
let dir = std::env::temp_dir().join(format!(
"truss_test_presets_{}",
std::time::SystemTime::UNIX_EPOCH
.elapsed()
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("presets.json");
std::fs::write(
&path,
r#"{"thumb":{"width":100,"height":100},"banner":{"width":1200}}"#,
)
.unwrap();
let presets = super::parse_presets_file(&path).unwrap();
assert_eq!(presets.len(), 2);
assert_eq!(presets["thumb"].width, Some(100));
assert_eq!(presets["thumb"].height, Some(100));
assert_eq!(presets["banner"].width, Some(1200));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn parse_presets_file_invalid_json() {
let dir = std::env::temp_dir().join(format!(
"truss_test_presets_invalid_{}",
std::time::SystemTime::UNIX_EPOCH
.elapsed()
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bad.json");
std::fs::write(&path, "not valid json {{{").unwrap();
let result = super::parse_presets_file(&path);
assert!(result.is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn parse_presets_file_nonexistent() {
let result =
super::parse_presets_file(std::path::Path::new("/tmp/nonexistent_truss_test.json"));
assert!(result.is_err());
}
#[test]
#[serial]
fn parse_presets_from_env_returns_file_path() {
let dir = std::env::temp_dir().join(format!(
"truss_test_presets_path_{}",
std::time::SystemTime::UNIX_EPOCH
.elapsed()
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("presets.json");
std::fs::write(&path, r#"{"thumb":{"width":100}}"#).unwrap();
let _env = ScopedEnv::set("TRUSS_PRESETS_FILE", path.to_str().unwrap());
let _env2 = ScopedEnv::remove("TRUSS_PRESETS");
let (presets, file_path) = super::parse_presets_from_env().unwrap();
assert_eq!(presets.len(), 1);
assert_eq!(file_path, Some(path));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn with_presets_sets_presets() {
let mut map = HashMap::new();
map.insert(
"test".to_string(),
super::super::TransformOptionsPayload {
width: Some(200),
height: None,
fit: None,
position: None,
format: None,
quality: None,
optimize: None,
target_quality: None,
background: None,
rotate: None,
auto_orient: None,
strip_metadata: None,
preserve_exif: None,
crop: None,
blur: None,
sharpen: None,
},
);
let config = ServerConfig::new(PathBuf::from("."), None).with_presets(map);
let presets = config.presets.read().unwrap();
assert_eq!(presets.len(), 1);
assert_eq!(presets["test"].width, Some(200));
}
#[test]
fn custom_response_headers_default_empty() {
let config = ServerConfig::new(PathBuf::from("."), None);
assert!(config.custom_response_headers.is_empty());
}
#[test]
#[serial]
fn parse_response_headers_valid_json() {
let _env = ScopedEnv::set(
"TRUSS_RESPONSE_HEADERS",
r#"{"CDN-Cache-Control":"max-age=3600","X-Custom":"value"}"#,
);
let result = parse_response_headers_from_env();
let headers = result.unwrap();
assert_eq!(headers.len(), 2);
assert_eq!(headers[0].0, "CDN-Cache-Control");
assert_eq!(headers[0].1, "max-age=3600");
assert_eq!(headers[1].0, "X-Custom");
assert_eq!(headers[1].1, "value");
}
#[test]
#[serial]
fn parse_response_headers_invalid_json() {
let _env = ScopedEnv::set("TRUSS_RESPONSE_HEADERS", "not json");
let result = parse_response_headers_from_env();
assert!(result.is_err());
}
#[test]
#[serial]
fn parse_response_headers_empty_name_rejected() {
let _env = ScopedEnv::set("TRUSS_RESPONSE_HEADERS", r#"{"":"value"}"#);
let result = parse_response_headers_from_env();
assert!(result.is_err());
}
#[test]
#[serial]
fn parse_response_headers_invalid_name_character() {
let _env = ScopedEnv::set("TRUSS_RESPONSE_HEADERS", r#"{"Bad Header":"value"}"#);
let result = parse_response_headers_from_env();
assert!(result.is_err());
}
#[test]
#[serial]
fn parse_response_headers_invalid_value_character() {
let _env = ScopedEnv::set("TRUSS_RESPONSE_HEADERS", r#"{"X-Bad":"val\u0000ue"}"#);
let result = parse_response_headers_from_env();
assert!(result.is_err());
}
#[test]
fn validate_header_name_valid() {
assert!(super::validate_header_name("Cache-Control").is_ok());
assert!(super::validate_header_name("X-Custom-Header").is_ok());
assert!(super::validate_header_name("CDN-Cache-Control").is_ok());
}
#[test]
fn validate_header_name_rejects_space() {
assert!(super::validate_header_name("Bad Header").is_err());
}
#[test]
fn validate_header_name_rejects_empty() {
assert!(super::validate_header_name("").is_err());
}
#[test]
fn validate_header_value_valid() {
assert!(super::validate_header_value("X", "normal value").is_ok());
assert!(super::validate_header_value("X", "max-age=3600, public").is_ok());
}
#[test]
fn validate_header_value_rejects_null() {
assert!(super::validate_header_value("X", "bad\x00value").is_err());
}
#[test]
fn compression_enabled_by_default() {
let config = ServerConfig::new(PathBuf::from("."), None);
assert!(config.enable_compression);
}
#[test]
fn log_level_default_info() {
let config = ServerConfig::new(PathBuf::from("."), None);
assert_eq!(config.current_log_level(), LogLevel::Info);
}
#[test]
fn log_level_cycle() {
assert_eq!(LogLevel::Info.cycle(), LogLevel::Debug);
assert_eq!(LogLevel::Debug.cycle(), LogLevel::Error);
assert_eq!(LogLevel::Error.cycle(), LogLevel::Warn);
assert_eq!(LogLevel::Warn.cycle(), LogLevel::Info);
}
#[test]
fn log_level_from_str() {
assert_eq!("error".parse::<LogLevel>().unwrap(), LogLevel::Error);
assert_eq!("WARN".parse::<LogLevel>().unwrap(), LogLevel::Warn);
assert_eq!("Info".parse::<LogLevel>().unwrap(), LogLevel::Info);
assert_eq!("DEBUG".parse::<LogLevel>().unwrap(), LogLevel::Debug);
assert!("invalid".parse::<LogLevel>().is_err());
}
#[test]
fn log_level_display() {
assert_eq!(LogLevel::Error.to_string(), "error");
assert_eq!(LogLevel::Warn.to_string(), "warn");
assert_eq!(LogLevel::Info.to_string(), "info");
assert_eq!(LogLevel::Debug.to_string(), "debug");
}
#[test]
fn log_level_from_u8_roundtrip() {
for level in [
LogLevel::Error,
LogLevel::Warn,
LogLevel::Info,
LogLevel::Debug,
] {
assert_eq!(LogLevel::from_u8(level as u8), level);
}
assert_eq!(LogLevel::from_u8(42), LogLevel::Info);
}
#[test]
#[serial]
fn parse_log_level_from_env() {
let _env = ScopedEnv::set("TRUSS_LOG_LEVEL", "debug");
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.current_log_level(), LogLevel::Debug);
}
#[test]
#[serial]
fn parse_log_level_invalid_rejected() {
let _env = ScopedEnv::set("TRUSS_LOG_LEVEL", "verbose");
let result = ServerConfig::from_env();
assert!(result.is_err());
}
#[test]
fn log_at_filters_by_level() {
use std::sync::Mutex;
let messages: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let msgs = Arc::clone(&messages);
let handler: LogHandler = Arc::new(move |msg: &str| {
msgs.lock().unwrap().push(msg.to_string());
});
let mut config = ServerConfig::new(PathBuf::from("."), None);
config.log_handler = Some(handler);
config
.log_level
.store(LogLevel::Warn as u8, std::sync::atomic::Ordering::Relaxed);
config.log_at(LogLevel::Error, "err");
config.log_at(LogLevel::Warn, "wrn");
config.log_at(LogLevel::Info, "inf");
config.log_at(LogLevel::Debug, "dbg");
let logged = messages.lock().unwrap();
assert_eq!(*logged, vec!["err", "wrn"]);
}
#[test]
#[serial]
fn parse_format_preference_unset_returns_empty() {
let _env = ScopedEnv::remove("TRUSS_FORMAT_PREFERENCE");
let result = parse_format_preference_from_env().unwrap();
assert!(result.is_empty());
}
#[test]
#[serial]
fn parse_format_preference_empty_returns_empty() {
let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "");
let result = parse_format_preference_from_env().unwrap();
assert!(result.is_empty());
}
#[test]
#[serial]
fn parse_format_preference_single_format() {
let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "webp");
let result = parse_format_preference_from_env().unwrap();
assert_eq!(result, vec![crate::MediaType::Webp]);
}
#[test]
#[serial]
fn parse_format_preference_multiple_formats() {
let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "avif,webp,png,jpeg");
let result = parse_format_preference_from_env().unwrap();
assert_eq!(
result,
vec![
crate::MediaType::Avif,
crate::MediaType::Webp,
crate::MediaType::Png,
crate::MediaType::Jpeg,
]
);
}
#[test]
#[serial]
fn parse_format_preference_with_spaces() {
let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", " webp , jpeg , png ");
let result = parse_format_preference_from_env().unwrap();
assert_eq!(
result,
vec![
crate::MediaType::Webp,
crate::MediaType::Jpeg,
crate::MediaType::Png,
]
);
}
#[test]
#[serial]
fn parse_format_preference_invalid_format_rejected() {
let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "webp,gif");
let result = parse_format_preference_from_env();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("TRUSS_FORMAT_PREFERENCE"));
}
#[test]
#[serial]
fn parse_format_preference_duplicate_rejected() {
let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "webp,jpeg,webp");
let result = parse_format_preference_from_env();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("duplicate"));
}
#[test]
#[serial]
fn parse_format_preference_trailing_comma_ok() {
let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "avif,webp,");
let result = parse_format_preference_from_env().unwrap();
assert_eq!(result, vec![crate::MediaType::Avif, crate::MediaType::Webp]);
}
#[test]
fn trusted_proxy_parse_single_ipv4() {
let tp = TrustedProxy::parse("10.0.0.1").unwrap();
assert_eq!(tp, TrustedProxy::Addr("10.0.0.1".parse().unwrap()));
}
#[test]
fn trusted_proxy_parse_single_ipv6() {
let tp = TrustedProxy::parse("::1").unwrap();
assert_eq!(tp, TrustedProxy::Addr("::1".parse().unwrap()));
}
#[test]
fn trusted_proxy_parse_cidr_v4() {
let tp = TrustedProxy::parse("10.0.0.0/8").unwrap();
assert_eq!(tp, TrustedProxy::Cidr("10.0.0.0".parse().unwrap(), 8));
}
#[test]
fn trusted_proxy_parse_cidr_v6() {
let tp = TrustedProxy::parse("fd00::/8").unwrap();
assert_eq!(tp, TrustedProxy::Cidr("fd00::".parse().unwrap(), 8));
}
#[test]
fn trusted_proxy_parse_with_whitespace() {
let tp = TrustedProxy::parse(" 10.0.0.1 ").unwrap();
assert_eq!(tp, TrustedProxy::Addr("10.0.0.1".parse().unwrap()));
}
#[test]
fn trusted_proxy_parse_cidr_with_whitespace() {
let tp = TrustedProxy::parse(" 10.0.0.0 / 8 ").unwrap();
assert_eq!(tp, TrustedProxy::Cidr("10.0.0.0".parse().unwrap(), 8));
}
#[test]
fn trusted_proxy_parse_invalid_ip() {
assert!(TrustedProxy::parse("not-an-ip").is_err());
}
#[test]
fn trusted_proxy_parse_prefix_too_large_v4() {
assert!(TrustedProxy::parse("10.0.0.0/33").is_err());
}
#[test]
fn trusted_proxy_parse_prefix_too_large_v6() {
assert!(TrustedProxy::parse("::1/129").is_err());
}
#[test]
fn trusted_proxy_contains_exact_match() {
let tp = TrustedProxy::Addr("10.0.0.1".parse().unwrap());
assert!(tp.contains("10.0.0.1".parse().unwrap()));
assert!(!tp.contains("10.0.0.2".parse().unwrap()));
}
#[test]
fn trusted_proxy_contains_cidr_v4() {
let tp = TrustedProxy::Cidr("10.0.0.0".parse().unwrap(), 8);
assert!(tp.contains("10.1.2.3".parse().unwrap()));
assert!(tp.contains("10.255.255.255".parse().unwrap()));
assert!(!tp.contains("11.0.0.1".parse().unwrap()));
}
#[test]
fn trusted_proxy_contains_cidr_v6() {
let tp = TrustedProxy::Cidr("fd00::".parse().unwrap(), 8);
assert!(tp.contains("fd12::1".parse().unwrap()));
assert!(!tp.contains("fe80::1".parse().unwrap()));
}
#[test]
fn trusted_proxy_cidr_v4_does_not_match_v6() {
let tp = TrustedProxy::Cidr("10.0.0.0".parse().unwrap(), 8);
assert!(!tp.contains("::1".parse().unwrap()));
}
#[test]
fn trusted_proxy_cidr_zero_prefix_matches_all() {
let tp = TrustedProxy::Cidr("0.0.0.0".parse().unwrap(), 0);
assert!(tp.contains("1.2.3.4".parse().unwrap()));
assert!(tp.contains("255.255.255.255".parse().unwrap()));
}
#[test]
fn trusted_proxy_cidr_32_matches_exact() {
let tp = TrustedProxy::Cidr("10.0.0.1".parse().unwrap(), 32);
assert!(tp.contains("10.0.0.1".parse().unwrap()));
assert!(!tp.contains("10.0.0.2".parse().unwrap()));
}
#[test]
fn is_trusted_proxy_checks_all_entries() {
let proxies = vec![
TrustedProxy::Addr("10.0.0.1".parse().unwrap()),
TrustedProxy::Cidr("172.16.0.0".parse().unwrap(), 12),
];
assert!(is_trusted_proxy(&proxies, "10.0.0.1".parse().unwrap()));
assert!(is_trusted_proxy(&proxies, "172.20.1.1".parse().unwrap()));
assert!(!is_trusted_proxy(&proxies, "192.168.1.1".parse().unwrap()));
}
#[test]
fn is_trusted_proxy_empty_list() {
assert!(!is_trusted_proxy(&[], "10.0.0.1".parse().unwrap()));
}
#[test]
#[serial]
fn from_env_trusted_proxies_parsed() {
let _env_proxies = ScopedEnv::set("TRUSS_TRUSTED_PROXIES", "10.0.0.1,172.16.0.0/12");
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.trusted_proxies.len(), 2);
assert_eq!(
config.trusted_proxies[0],
TrustedProxy::Addr("10.0.0.1".parse().unwrap())
);
assert_eq!(
config.trusted_proxies[1],
TrustedProxy::Cidr("172.16.0.0".parse().unwrap(), 12)
);
}
#[test]
#[serial]
fn from_env_trusted_proxies_empty_when_unset() {
let _env = ScopedEnv::remove("TRUSS_TRUSTED_PROXIES");
let config = ServerConfig::from_env().unwrap();
assert!(config.trusted_proxies.is_empty());
}
#[test]
#[serial]
fn from_env_trusted_proxies_invalid_rejects() {
let _env = ScopedEnv::set("TRUSS_TRUSTED_PROXIES", "not-an-ip");
assert!(ServerConfig::from_env().is_err());
}
#[test]
#[serial]
fn parse_health_cache_ttl_secs_valid() {
let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_TTL_SECS", "10");
let result = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_TTL_SECS", 0, 300);
assert_eq!(result.unwrap(), Some(10));
}
#[test]
#[serial]
fn parse_health_cache_ttl_secs_zero_disables_caching() {
let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_TTL_SECS", "0");
let result = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_TTL_SECS", 0, 300);
assert_eq!(result.unwrap(), Some(0));
}
#[test]
#[serial]
fn from_env_wires_health_cache_ttl_secs() {
let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_TTL_SECS", "10");
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.health_cache.ttl_nanos, 10 * 1_000_000_000);
}
#[test]
fn with_health_cache_ttl_secs_overrides_default() {
let config = ServerConfig::new(PathBuf::from("."), None).with_health_cache_ttl_secs(20);
assert_eq!(config.health_cache.ttl_nanos, 20 * 1_000_000_000);
}
}