1use super::TransformOptionsPayload;
2#[cfg(feature = "azure")]
3use super::azure;
4#[cfg(feature = "gcs")]
5use super::gcs;
6pub(super) const MIN_CONCURRENT_TRANSFORMS: u64 = 1;
12pub(super) const MAX_CONCURRENT_TRANSFORMS: u64 = 1024;
13
14pub(super) const FALLBACK_MAX_CONCURRENT_TRANSFORMS: u64 = 4;
17
18pub(super) fn default_max_concurrent_transforms() -> u64 {
33 let cores = std::thread::available_parallelism()
34 .map(|count| count.get() as u64)
35 .unwrap_or(FALLBACK_MAX_CONCURRENT_TRANSFORMS);
36 cores.clamp(MIN_CONCURRENT_TRANSFORMS, MAX_CONCURRENT_TRANSFORMS)
37}
38#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
39use super::remote::STORAGE_DOWNLOAD_TIMEOUT_SECS;
40#[cfg(feature = "s3")]
41use super::s3;
42use super::stderr_write;
43
44use std::collections::HashMap;
45use std::env;
46use std::fmt;
47use std::io;
48use std::net::IpAddr;
49use std::path::PathBuf;
50use std::sync::Arc;
51use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
52use url::Url;
53
54#[repr(u8)]
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
65#[non_exhaustive]
66pub enum LogLevel {
67 Error = 0,
69 Warn = 1,
71 Info = 2,
73 Debug = 3,
75}
76
77impl LogLevel {
78 #[cfg(any(unix, test))]
83 pub(super) fn cycle(self) -> Self {
84 match self {
85 Self::Info => Self::Debug,
86 Self::Debug => Self::Error,
87 Self::Error => Self::Warn,
88 Self::Warn => Self::Info,
89 }
90 }
91
92 pub(super) fn from_u8(v: u8) -> Self {
94 match v {
95 0 => Self::Error,
96 1 => Self::Warn,
97 2 => Self::Info,
98 3 => Self::Debug,
99 _ => Self::Info,
100 }
101 }
102
103 pub(super) fn as_str(self) -> &'static str {
105 match self {
106 Self::Error => "error",
107 Self::Warn => "warn",
108 Self::Info => "info",
109 Self::Debug => "debug",
110 }
111 }
112}
113
114impl fmt::Display for LogLevel {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(self.as_str())
117 }
118}
119
120impl std::str::FromStr for LogLevel {
121 type Err = String;
122
123 fn from_str(s: &str) -> Result<Self, Self::Err> {
124 match s.to_ascii_lowercase().as_str() {
125 "error" => Ok(Self::Error),
126 "warn" => Ok(Self::Warn),
127 "info" => Ok(Self::Info),
128 "debug" => Ok(Self::Debug),
129 _ => Err(format!(
130 "invalid log level `{s}`: expected error, warn, info, or debug"
131 )),
132 }
133 }
134}
135
136#[non_exhaustive]
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum TrustedProxy {
144 Addr(IpAddr),
146 Cidr(IpAddr, u8),
149}
150
151impl TrustedProxy {
152 pub fn parse(s: &str) -> Result<Self, String> {
154 if let Some((addr_str, prefix_str)) = s.split_once('/') {
155 let addr: IpAddr = addr_str
156 .trim()
157 .parse()
158 .map_err(|e| format!("invalid IP in CIDR `{s}`: {e}"))?;
159 let prefix: u8 = prefix_str
160 .trim()
161 .parse()
162 .map_err(|e| format!("invalid prefix length in CIDR `{s}`: {e}"))?;
163 let max_prefix = match addr {
164 IpAddr::V4(_) => 32,
165 IpAddr::V6(_) => 128,
166 };
167 if prefix > max_prefix {
168 return Err(format!(
169 "prefix length {prefix} exceeds maximum {max_prefix} for `{s}`"
170 ));
171 }
172 Ok(Self::Cidr(addr, prefix))
173 } else {
174 let addr: IpAddr = s
175 .trim()
176 .parse()
177 .map_err(|e| format!("invalid trusted proxy IP `{s}`: {e}"))?;
178 Ok(Self::Addr(addr))
179 }
180 }
181
182 pub(super) fn contains(&self, ip: IpAddr) -> bool {
184 match self {
185 Self::Addr(a) => *a == ip,
186 Self::Cidr(network, prefix_len) => {
187 let prefix = *prefix_len;
188 match (network, ip) {
189 (IpAddr::V4(net), IpAddr::V4(addr)) => {
190 if prefix == 0 {
191 return true;
192 }
193 let mask = u32::MAX << (32 - prefix);
194 (u32::from(*net) & mask) == (u32::from(addr) & mask)
195 }
196 (IpAddr::V6(net), IpAddr::V6(addr)) => {
197 if prefix == 0 {
198 return true;
199 }
200 let mask = u128::MAX << (128 - prefix);
201 (u128::from(*net) & mask) == (u128::from(addr) & mask)
202 }
203 _ => false, }
205 }
206 }
207 }
208}
209
210pub(super) fn is_trusted_proxy(trusted: &[TrustedProxy], ip: IpAddr) -> bool {
212 trusted.iter().any(|t| t.contains(ip))
213}
214
215#[derive(Debug, Clone, Copy)]
220#[allow(dead_code)]
221pub(super) enum StorageBackendLabel {
222 Filesystem,
223 S3,
224 Gcs,
225 Azure,
226}
227
228#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232#[non_exhaustive]
233pub enum StorageBackend {
234 Filesystem,
236 #[cfg(feature = "s3")]
238 S3,
239 #[cfg(feature = "gcs")]
241 Gcs,
242 #[cfg(feature = "azure")]
244 Azure,
245}
246
247#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
248impl StorageBackend {
249 pub fn parse(value: &str) -> Result<Self, String> {
251 match value.to_ascii_lowercase().as_str() {
252 "filesystem" | "fs" | "local" => Ok(Self::Filesystem),
253 #[cfg(feature = "s3")]
254 "s3" => Ok(Self::S3),
255 #[cfg(feature = "gcs")]
256 "gcs" => Ok(Self::Gcs),
257 #[cfg(feature = "azure")]
258 "azure" => Ok(Self::Azure),
259 _ => {
260 let mut expected = vec!["filesystem"];
261 #[cfg(feature = "s3")]
262 expected.push("s3");
263 #[cfg(feature = "gcs")]
264 expected.push("gcs");
265 #[cfg(feature = "azure")]
266 expected.push("azure");
267
268 #[allow(unused_mut)]
269 let mut hint = String::new();
270 #[cfg(not(feature = "s3"))]
271 if value.eq_ignore_ascii_case("s3") {
272 hint = " (hint: rebuild with --features s3)".to_string();
273 }
274 #[cfg(not(feature = "gcs"))]
275 if value.eq_ignore_ascii_case("gcs") {
276 hint = " (hint: rebuild with --features gcs)".to_string();
277 }
278 #[cfg(not(feature = "azure"))]
279 if value.eq_ignore_ascii_case("azure") {
280 hint = " (hint: rebuild with --features azure)".to_string();
281 }
282
283 Err(format!(
284 "unknown storage backend `{value}` (expected {}){hint}",
285 expected.join(" or ")
286 ))
287 }
288 }
289 }
290}
291
292pub(crate) const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
294
295pub(crate) const DEFAULT_STORAGE_ROOT: &str = ".";
297
298pub(super) const DEFAULT_PUBLIC_MAX_AGE_SECONDS: u32 = 3600;
299pub(super) const DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS: u32 = 60;
300
301pub(super) const DEFAULT_SHUTDOWN_DRAIN_SECS: u64 = 10;
304
305pub(super) const DEFAULT_TRANSFORM_DEADLINE_SECS: u64 = 30;
308
309pub(super) const DEFAULT_MAX_INPUT_PIXELS: u64 = 40_000_000;
312
313pub(super) const DEFAULT_KEEP_ALIVE_MAX_REQUESTS: u64 = 100;
317
318use super::http_parse::DEFAULT_MAX_UPLOAD_BODY_BYTES;
319
320pub type LogHandler = Arc<dyn Fn(&str) + Send + Sync>;
331
332#[non_exhaustive]
333pub struct ServerConfig {
334 pub storage_root: PathBuf,
336 pub bearer_token: Option<String>,
338 pub public_base_url: Option<String>,
345 pub signed_url_key_id: Option<String>,
351 pub signed_url_secret: Option<String>,
355 pub signing_keys: HashMap<String, String>,
365 pub allow_insecure_url_sources: bool,
371 pub cache_root: Option<PathBuf>,
378 pub cache_max_bytes: u64,
387 pub(crate) cache_eviction_secs: Arc<AtomicU64>,
392 pub public_max_age_seconds: u32,
397 pub public_stale_while_revalidate_seconds: u32,
402 pub disable_accept_negotiation: bool,
410 pub format_preference: Vec<crate::MediaType>,
419 pub log_handler: Option<LogHandler>,
425 pub log_level: Arc<AtomicU8>,
430 pub max_concurrent_transforms: u64,
436 pub transform_deadline_secs: u64,
443 pub max_input_pixels: u64,
448 pub max_upload_bytes: usize,
453 pub keep_alive_max_requests: u64,
457 pub metrics_token: Option<String>,
463 pub disable_metrics: bool,
467 pub health_token: Option<String>,
473 pub health_cache_min_free_bytes: Option<u64>,
478 pub health_max_memory_bytes: Option<u64>,
483 pub(crate) health_cache: Arc<super::handler::HealthCache>,
491 pub shutdown_drain_secs: u64,
500 pub draining: Arc<AtomicBool>,
505 pub custom_response_headers: Vec<(String, String)>,
510 pub max_source_bytes: u64,
514 pub max_watermark_bytes: u64,
518 pub max_remote_redirects: usize,
522 pub enable_compression: bool,
526 pub compression_level: u32,
532 pub transforms_in_flight: Arc<AtomicU64>,
538 pub presets: Arc<std::sync::RwLock<HashMap<String, TransformOptionsPayload>>>,
544 pub presets_file_path: Option<PathBuf>,
550 pub rate_limiter: Option<Arc<super::rate_limit::RateLimiter>>,
557 pub trusted_proxies: Vec<TrustedProxy>,
564 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
568 pub storage_timeout_secs: u64,
569 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
571 pub storage_backend: StorageBackend,
572 #[cfg(feature = "s3")]
574 pub s3_context: Option<Arc<s3::S3Context>>,
575 #[cfg(feature = "gcs")]
577 pub gcs_context: Option<Arc<gcs::GcsContext>>,
578 #[cfg(feature = "azure")]
580 pub azure_context: Option<Arc<azure::AzureContext>>,
581}
582
583impl Clone for ServerConfig {
584 fn clone(&self) -> Self {
585 Self {
586 storage_root: self.storage_root.clone(),
587 bearer_token: self.bearer_token.clone(),
588 public_base_url: self.public_base_url.clone(),
589 signed_url_key_id: self.signed_url_key_id.clone(),
590 signed_url_secret: self.signed_url_secret.clone(),
591 signing_keys: self.signing_keys.clone(),
592 allow_insecure_url_sources: self.allow_insecure_url_sources,
593 cache_root: self.cache_root.clone(),
594 cache_max_bytes: self.cache_max_bytes,
595 cache_eviction_secs: Arc::clone(&self.cache_eviction_secs),
596 public_max_age_seconds: self.public_max_age_seconds,
597 public_stale_while_revalidate_seconds: self.public_stale_while_revalidate_seconds,
598 disable_accept_negotiation: self.disable_accept_negotiation,
599 format_preference: self.format_preference.clone(),
600 log_handler: self.log_handler.clone(),
601 log_level: Arc::clone(&self.log_level),
602 max_concurrent_transforms: self.max_concurrent_transforms,
603 transform_deadline_secs: self.transform_deadline_secs,
604 max_input_pixels: self.max_input_pixels,
605 max_upload_bytes: self.max_upload_bytes,
606 keep_alive_max_requests: self.keep_alive_max_requests,
607 metrics_token: self.metrics_token.clone(),
608 disable_metrics: self.disable_metrics,
609 health_token: self.health_token.clone(),
610 health_cache_min_free_bytes: self.health_cache_min_free_bytes,
611 health_max_memory_bytes: self.health_max_memory_bytes,
612 health_cache: Arc::clone(&self.health_cache),
613 shutdown_drain_secs: self.shutdown_drain_secs,
614 draining: Arc::clone(&self.draining),
615 custom_response_headers: self.custom_response_headers.clone(),
616 max_source_bytes: self.max_source_bytes,
617 max_watermark_bytes: self.max_watermark_bytes,
618 max_remote_redirects: self.max_remote_redirects,
619 enable_compression: self.enable_compression,
620 compression_level: self.compression_level,
621 transforms_in_flight: Arc::clone(&self.transforms_in_flight),
622 presets: Arc::clone(&self.presets),
623 presets_file_path: self.presets_file_path.clone(),
624 rate_limiter: self.rate_limiter.as_ref().map(Arc::clone),
625 trusted_proxies: self.trusted_proxies.clone(),
626 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
627 storage_timeout_secs: self.storage_timeout_secs,
628 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
629 storage_backend: self.storage_backend,
630 #[cfg(feature = "s3")]
631 s3_context: self.s3_context.clone(),
632 #[cfg(feature = "gcs")]
633 gcs_context: self.gcs_context.clone(),
634 #[cfg(feature = "azure")]
635 azure_context: self.azure_context.clone(),
636 }
637 }
638}
639
640impl fmt::Debug for ServerConfig {
641 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
642 let mut d = f.debug_struct("ServerConfig");
643 d.field("storage_root", &self.storage_root)
644 .field(
645 "bearer_token",
646 &self.bearer_token.as_ref().map(|_| "[REDACTED]"),
647 )
648 .field("public_base_url", &self.public_base_url)
649 .field("signed_url_key_id", &self.signed_url_key_id)
650 .field(
651 "signed_url_secret",
652 &self.signed_url_secret.as_ref().map(|_| "[REDACTED]"),
653 )
654 .field(
655 "signing_keys",
656 &self.signing_keys.keys().collect::<Vec<_>>(),
657 )
658 .field(
659 "allow_insecure_url_sources",
660 &self.allow_insecure_url_sources,
661 )
662 .field("cache_root", &self.cache_root)
663 .field("cache_max_bytes", &self.cache_max_bytes)
664 .field("public_max_age_seconds", &self.public_max_age_seconds)
665 .field(
666 "public_stale_while_revalidate_seconds",
667 &self.public_stale_while_revalidate_seconds,
668 )
669 .field(
670 "disable_accept_negotiation",
671 &self.disable_accept_negotiation,
672 )
673 .field("format_preference", &self.format_preference)
674 .field("log_handler", &self.log_handler.as_ref().map(|_| ".."))
675 .field("log_level", &self.current_log_level())
676 .field("max_concurrent_transforms", &self.max_concurrent_transforms)
677 .field("transform_deadline_secs", &self.transform_deadline_secs)
678 .field("max_input_pixels", &self.max_input_pixels)
679 .field("max_upload_bytes", &self.max_upload_bytes)
680 .field("keep_alive_max_requests", &self.keep_alive_max_requests)
681 .field(
682 "metrics_token",
683 &self.metrics_token.as_ref().map(|_| "[REDACTED]"),
684 )
685 .field("disable_metrics", &self.disable_metrics)
686 .field(
687 "health_token",
688 &self.health_token.as_ref().map(|_| "[REDACTED]"),
689 )
690 .field(
691 "health_cache_min_free_bytes",
692 &self.health_cache_min_free_bytes,
693 )
694 .field("health_max_memory_bytes", &self.health_max_memory_bytes)
695 .field("health_cache_ttl_nanos", &self.health_cache.ttl_nanos)
696 .field("shutdown_drain_secs", &self.shutdown_drain_secs)
697 .field(
698 "custom_response_headers",
699 &self.custom_response_headers.len(),
700 )
701 .field("enable_compression", &self.enable_compression)
702 .field("compression_level", &self.compression_level)
703 .field(
704 "presets",
705 &self
706 .presets
707 .read()
708 .map(|p| p.keys().cloned().collect::<Vec<_>>())
709 .unwrap_or_default(),
710 )
711 .field("presets_file_path", &self.presets_file_path)
712 .field("rate_limiter", &self.rate_limiter.is_some())
713 .field("trusted_proxies", &self.trusted_proxies);
714 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
715 {
716 d.field("storage_backend", &self.storage_backend);
717 }
718 #[cfg(feature = "s3")]
719 {
720 d.field("s3_context", &self.s3_context.as_ref().map(|_| ".."));
721 }
722 #[cfg(feature = "gcs")]
723 {
724 d.field("gcs_context", &self.gcs_context.as_ref().map(|_| ".."));
725 }
726 #[cfg(feature = "azure")]
727 {
728 d.field("azure_context", &self.azure_context.as_ref().map(|_| ".."));
729 }
730 d.finish()
731 }
732}
733
734impl PartialEq for ServerConfig {
735 fn eq(&self, other: &Self) -> bool {
736 self.storage_root == other.storage_root
737 && self.bearer_token == other.bearer_token
738 && self.public_base_url == other.public_base_url
739 && self.signed_url_key_id == other.signed_url_key_id
740 && self.signed_url_secret == other.signed_url_secret
741 && self.signing_keys == other.signing_keys
742 && self.allow_insecure_url_sources == other.allow_insecure_url_sources
743 && self.cache_root == other.cache_root
744 && self.cache_max_bytes == other.cache_max_bytes
745 && self.public_max_age_seconds == other.public_max_age_seconds
746 && self.public_stale_while_revalidate_seconds
747 == other.public_stale_while_revalidate_seconds
748 && self.disable_accept_negotiation == other.disable_accept_negotiation
749 && self.format_preference == other.format_preference
750 && self.max_concurrent_transforms == other.max_concurrent_transforms
751 && self.transform_deadline_secs == other.transform_deadline_secs
752 && self.max_input_pixels == other.max_input_pixels
753 && self.max_upload_bytes == other.max_upload_bytes
754 && self.keep_alive_max_requests == other.keep_alive_max_requests
755 && self.metrics_token == other.metrics_token
756 && self.disable_metrics == other.disable_metrics
757 && self.health_token == other.health_token
758 && self.health_cache_min_free_bytes == other.health_cache_min_free_bytes
759 && self.health_max_memory_bytes == other.health_max_memory_bytes
760 && self.health_cache.ttl_nanos == other.health_cache.ttl_nanos
761 && self.health_cache.hysteresis_margin == other.health_cache.hysteresis_margin
762 && self.shutdown_drain_secs == other.shutdown_drain_secs
763 && self.custom_response_headers == other.custom_response_headers
764 && self.max_source_bytes == other.max_source_bytes
765 && self.max_watermark_bytes == other.max_watermark_bytes
766 && self.max_remote_redirects == other.max_remote_redirects
767 && self.enable_compression == other.enable_compression
768 && self.compression_level == other.compression_level
769 && *self.presets.read().unwrap() == *other.presets.read().unwrap()
770 && self.presets_file_path == other.presets_file_path
771 && self.rate_limiter.is_some() == other.rate_limiter.is_some()
772 && self.trusted_proxies == other.trusted_proxies
773 && cfg_storage_eq(self, other)
774 }
775}
776
777fn cfg_storage_eq(_this: &ServerConfig, _other: &ServerConfig) -> bool {
778 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
779 {
780 if _this.storage_backend != _other.storage_backend {
781 return false;
782 }
783 }
784 #[cfg(feature = "s3")]
785 {
786 if _this
787 .s3_context
788 .as_ref()
789 .map(|c| (&c.default_bucket, &c.endpoint_url))
790 != _other
791 .s3_context
792 .as_ref()
793 .map(|c| (&c.default_bucket, &c.endpoint_url))
794 {
795 return false;
796 }
797 }
798 #[cfg(feature = "gcs")]
799 {
800 if _this
801 .gcs_context
802 .as_ref()
803 .map(|c| (&c.default_bucket, &c.endpoint_url))
804 != _other
805 .gcs_context
806 .as_ref()
807 .map(|c| (&c.default_bucket, &c.endpoint_url))
808 {
809 return false;
810 }
811 }
812 #[cfg(feature = "azure")]
813 {
814 if _this
815 .azure_context
816 .as_ref()
817 .map(|c| (&c.default_container, &c.endpoint_url))
818 != _other
819 .azure_context
820 .as_ref()
821 .map(|c| (&c.default_container, &c.endpoint_url))
822 {
823 return false;
824 }
825 }
826 true
827}
828
829impl Eq for ServerConfig {}
830
831impl ServerConfig {
832 pub(super) const fn public_cache_control(&self) -> super::handler::PublicCacheControl {
835 super::handler::PublicCacheControl {
836 max_age: self.public_max_age_seconds,
837 stale_while_revalidate: self.public_stale_while_revalidate_seconds,
838 }
839 }
840
841 pub fn new(storage_root: PathBuf, bearer_token: Option<String>) -> Self {
856 Self {
857 storage_root,
858 bearer_token,
859 public_base_url: None,
860 signed_url_key_id: None,
861 signed_url_secret: None,
862 signing_keys: HashMap::new(),
863 allow_insecure_url_sources: false,
864 cache_root: None,
865 cache_max_bytes: 0,
866 cache_eviction_secs: Arc::new(AtomicU64::new(0)),
867 public_max_age_seconds: DEFAULT_PUBLIC_MAX_AGE_SECONDS,
868 public_stale_while_revalidate_seconds: DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
869 disable_accept_negotiation: false,
870 format_preference: Vec::new(),
871 log_handler: None,
872 log_level: Arc::new(AtomicU8::new(LogLevel::Info as u8)),
873 max_concurrent_transforms: default_max_concurrent_transforms(),
874 transform_deadline_secs: DEFAULT_TRANSFORM_DEADLINE_SECS,
875 max_input_pixels: DEFAULT_MAX_INPUT_PIXELS,
876 max_upload_bytes: DEFAULT_MAX_UPLOAD_BODY_BYTES,
877 keep_alive_max_requests: DEFAULT_KEEP_ALIVE_MAX_REQUESTS,
878 metrics_token: None,
879 disable_metrics: false,
880 health_token: None,
881 health_cache_min_free_bytes: None,
882 health_max_memory_bytes: None,
883 health_cache: Arc::new(super::handler::HealthCache::new(
884 super::handler::DEFAULT_HEALTH_CACHE_TTL_SECS,
885 super::handler::DEFAULT_HYSTERESIS_MARGIN,
886 )),
887 shutdown_drain_secs: DEFAULT_SHUTDOWN_DRAIN_SECS,
888 draining: Arc::new(AtomicBool::new(false)),
889 custom_response_headers: Vec::new(),
890 max_source_bytes: super::remote::MAX_SOURCE_BYTES,
891 max_watermark_bytes: super::remote::MAX_WATERMARK_BYTES,
892 max_remote_redirects: super::remote::MAX_REMOTE_REDIRECTS,
893 enable_compression: true,
894 compression_level: 1,
895 transforms_in_flight: Arc::new(AtomicU64::new(0)),
896 presets: Arc::new(std::sync::RwLock::new(HashMap::new())),
897 presets_file_path: None,
898 rate_limiter: None,
899 trusted_proxies: Vec::new(),
900 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
901 storage_timeout_secs: STORAGE_DOWNLOAD_TIMEOUT_SECS,
902 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
903 storage_backend: StorageBackend::Filesystem,
904 #[cfg(feature = "s3")]
905 s3_context: None,
906 #[cfg(feature = "gcs")]
907 gcs_context: None,
908 #[cfg(feature = "azure")]
909 azure_context: None,
910 }
911 }
912
913 pub fn with_health_cache_ttl_secs(mut self, ttl_secs: u64) -> Self {
918 let margin = self.health_cache.hysteresis_margin;
919 self.health_cache = Arc::new(super::handler::HealthCache::new(ttl_secs, margin));
920 self
921 }
922
923 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
924 pub(super) fn storage_backend_label(&self) -> StorageBackendLabel {
925 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
926 {
927 match self.storage_backend {
928 StorageBackend::Filesystem => StorageBackendLabel::Filesystem,
929 #[cfg(feature = "s3")]
930 StorageBackend::S3 => StorageBackendLabel::S3,
931 #[cfg(feature = "gcs")]
932 StorageBackend::Gcs => StorageBackendLabel::Gcs,
933 #[cfg(feature = "azure")]
934 StorageBackend::Azure => StorageBackendLabel::Azure,
935 }
936 }
937 #[cfg(not(any(feature = "s3", feature = "gcs", feature = "azure")))]
938 {
939 StorageBackendLabel::Filesystem
940 }
941 }
942
943 pub(super) fn current_log_level(&self) -> LogLevel {
945 LogLevel::from_u8(self.log_level.load(Ordering::Relaxed))
946 }
947
948 pub(super) fn log_at(&self, level: LogLevel, msg: &str) {
951 if level > self.current_log_level() {
952 return;
953 }
954 if let Some(handler) = &self.log_handler {
955 handler(msg);
956 } else {
957 stderr_write(msg);
958 }
959 }
960
961 pub(super) fn log(&self, msg: &str) {
965 self.log_at(LogLevel::Info, msg);
966 }
967
968 pub(super) fn log_warn(&self, msg: &str) {
970 self.log_at(LogLevel::Warn, msg);
971 }
972
973 pub fn with_signed_url_credentials(
991 mut self,
992 key_id: impl Into<String>,
993 secret: impl Into<String>,
994 ) -> Self {
995 let key_id = key_id.into();
996 let secret = secret.into();
997 self.signing_keys.insert(key_id.clone(), secret.clone());
998 self.signed_url_key_id = Some(key_id);
999 self.signed_url_secret = Some(secret);
1000 self
1001 }
1002
1003 pub fn with_signing_keys(mut self, keys: HashMap<String, String>) -> Self {
1009 self.signing_keys.extend(keys);
1010 self
1011 }
1012
1013 pub fn with_insecure_url_sources(mut self, allow_insecure_url_sources: bool) -> Self {
1030 self.allow_insecure_url_sources = allow_insecure_url_sources;
1031 self
1032 }
1033
1034 pub fn with_cache_root(mut self, cache_root: impl Into<PathBuf>) -> Self {
1050 self.cache_root = Some(cache_root.into());
1051 self
1052 }
1053
1054 pub fn with_cache_max_bytes(mut self, max_bytes: u64) -> Self {
1071 self.cache_max_bytes = max_bytes;
1072 self
1073 }
1074
1075 #[cfg(feature = "s3")]
1077 pub fn with_s3_context(mut self, context: s3::S3Context) -> Self {
1078 self.storage_backend = StorageBackend::S3;
1079 self.s3_context = Some(Arc::new(context));
1080 self
1081 }
1082
1083 #[cfg(feature = "gcs")]
1085 pub fn with_gcs_context(mut self, context: gcs::GcsContext) -> Self {
1086 self.storage_backend = StorageBackend::Gcs;
1087 self.gcs_context = Some(Arc::new(context));
1088 self
1089 }
1090
1091 #[cfg(feature = "azure")]
1093 pub fn with_azure_context(mut self, context: azure::AzureContext) -> Self {
1094 self.storage_backend = StorageBackend::Azure;
1095 self.azure_context = Some(Arc::new(context));
1096 self
1097 }
1098
1099 pub fn with_presets(mut self, presets: HashMap<String, TransformOptionsPayload>) -> Self {
1101 self.presets = Arc::new(std::sync::RwLock::new(presets));
1102 self
1103 }
1104
1105 pub fn from_env() -> io::Result<Self> {
1202 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1203 let storage_backend = match env::var("TRUSS_STORAGE_BACKEND")
1204 .ok()
1205 .filter(|v| !v.is_empty())
1206 {
1207 Some(value) => StorageBackend::parse(&value)
1208 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?,
1209 None => StorageBackend::Filesystem,
1210 };
1211
1212 let storage_root =
1213 env::var("TRUSS_STORAGE_ROOT").unwrap_or_else(|_| DEFAULT_STORAGE_ROOT.to_string());
1214 let storage_root = PathBuf::from(&storage_root)
1219 .canonicalize()
1220 .map_err(|error| {
1221 io::Error::new(
1222 error.kind(),
1223 format!("TRUSS_STORAGE_ROOT `{storage_root}` cannot be resolved: {error}"),
1224 )
1225 })?;
1226 let bearer_token = env::var("TRUSS_BEARER_TOKEN")
1227 .ok()
1228 .filter(|value| !value.is_empty());
1229 let public_base_url = env::var("TRUSS_PUBLIC_BASE_URL")
1230 .ok()
1231 .filter(|value| !value.is_empty())
1232 .map(validate_public_base_url)
1233 .transpose()?;
1234 let signed_url_key_id = env::var("TRUSS_SIGNED_URL_KEY_ID")
1235 .ok()
1236 .filter(|value| !value.is_empty());
1237 let signed_url_secret = env::var("TRUSS_SIGNED_URL_SECRET")
1238 .ok()
1239 .filter(|value| !value.is_empty());
1240
1241 if signed_url_key_id.is_some() != signed_url_secret.is_some() {
1242 return Err(io::Error::new(
1243 io::ErrorKind::InvalidInput,
1244 "TRUSS_SIGNED_URL_KEY_ID and TRUSS_SIGNED_URL_SECRET must be set together",
1245 ));
1246 }
1247
1248 let mut signing_keys = HashMap::new();
1249 if let (Some(kid), Some(sec)) = (&signed_url_key_id, &signed_url_secret) {
1250 signing_keys.insert(kid.clone(), sec.clone());
1251 }
1252 if let Ok(json) = env::var("TRUSS_SIGNING_KEYS")
1253 && !json.is_empty()
1254 {
1255 let extra: HashMap<String, String> = serde_json::from_str(&json).map_err(|e| {
1256 io::Error::new(
1257 io::ErrorKind::InvalidInput,
1258 format!("TRUSS_SIGNING_KEYS must be valid JSON: {e}"),
1259 )
1260 })?;
1261 for (kid, sec) in &extra {
1262 if kid.is_empty() || sec.is_empty() {
1263 return Err(io::Error::new(
1264 io::ErrorKind::InvalidInput,
1265 "TRUSS_SIGNING_KEYS must not contain empty key IDs or secrets",
1266 ));
1267 }
1268 }
1269 signing_keys.extend(extra);
1270 }
1271
1272 let cache_root = env::var("TRUSS_CACHE_ROOT")
1273 .ok()
1274 .filter(|value| !value.is_empty())
1275 .map(PathBuf::from);
1276
1277 let cache_max_bytes =
1278 parse_env_u64_ranged("TRUSS_CACHE_MAX_BYTES", 0, u64::MAX)?.unwrap_or(0);
1279
1280 let public_max_age_seconds = parse_optional_env_u32("TRUSS_PUBLIC_MAX_AGE")?
1281 .unwrap_or(DEFAULT_PUBLIC_MAX_AGE_SECONDS);
1282 let public_stale_while_revalidate_seconds =
1283 parse_optional_env_u32("TRUSS_PUBLIC_STALE_WHILE_REVALIDATE")?
1284 .unwrap_or(DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS);
1285
1286 let allow_insecure_url_sources = env_flag("TRUSS_ALLOW_INSECURE_URL_SOURCES")?;
1287
1288 let max_concurrent_transforms = parse_env_u64_ranged(
1289 "TRUSS_MAX_CONCURRENT_TRANSFORMS",
1290 MIN_CONCURRENT_TRANSFORMS,
1291 MAX_CONCURRENT_TRANSFORMS,
1292 )?
1293 .unwrap_or_else(default_max_concurrent_transforms);
1294
1295 let transform_deadline_secs =
1296 parse_env_u64_ranged("TRUSS_TRANSFORM_DEADLINE_SECS", 1, 300)?
1297 .unwrap_or(DEFAULT_TRANSFORM_DEADLINE_SECS);
1298
1299 let max_input_pixels =
1300 parse_env_u64_ranged("TRUSS_MAX_INPUT_PIXELS", 1, crate::core::MAX_DECODED_PIXELS)?
1301 .unwrap_or(DEFAULT_MAX_INPUT_PIXELS);
1302
1303 let max_upload_bytes =
1304 parse_env_u64_ranged("TRUSS_MAX_UPLOAD_BYTES", 1, 10 * 1024 * 1024 * 1024)?
1305 .unwrap_or(DEFAULT_MAX_UPLOAD_BODY_BYTES as u64) as usize;
1306
1307 let keep_alive_max_requests =
1308 parse_env_u64_ranged("TRUSS_KEEP_ALIVE_MAX_REQUESTS", 1, 100_000)?
1309 .unwrap_or(DEFAULT_KEEP_ALIVE_MAX_REQUESTS);
1310
1311 let max_source_bytes =
1312 parse_env_u64_ranged("TRUSS_MAX_SOURCE_BYTES", 1, 10 * 1024 * 1024 * 1024)?
1313 .unwrap_or(super::remote::MAX_SOURCE_BYTES);
1314
1315 let max_watermark_bytes =
1316 parse_env_u64_ranged("TRUSS_MAX_WATERMARK_BYTES", 1, 1024 * 1024 * 1024)?
1317 .unwrap_or(super::remote::MAX_WATERMARK_BYTES);
1318
1319 let max_remote_redirects = parse_env_u64_ranged("TRUSS_MAX_REMOTE_REDIRECTS", 0, 20)?
1320 .unwrap_or(super::remote::MAX_REMOTE_REDIRECTS as u64)
1321 as usize;
1322
1323 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1324 let storage_timeout_secs = parse_env_u64_ranged("TRUSS_STORAGE_TIMEOUT_SECS", 1, 300)?
1325 .unwrap_or(STORAGE_DOWNLOAD_TIMEOUT_SECS);
1326
1327 #[cfg(feature = "s3")]
1328 let s3_context = if storage_backend == StorageBackend::S3 {
1329 let bucket = env::var("TRUSS_S3_BUCKET")
1330 .ok()
1331 .filter(|v| !v.is_empty())
1332 .ok_or_else(|| {
1333 io::Error::new(
1334 io::ErrorKind::InvalidInput,
1335 "TRUSS_S3_BUCKET is required when TRUSS_STORAGE_BACKEND=s3",
1336 )
1337 })?;
1338 Some(Arc::new(s3::build_s3_context(
1339 bucket,
1340 allow_insecure_url_sources,
1341 )?))
1342 } else {
1343 None
1344 };
1345
1346 #[cfg(feature = "gcs")]
1347 let gcs_context = if storage_backend == StorageBackend::Gcs {
1348 let bucket = env::var("TRUSS_GCS_BUCKET")
1349 .ok()
1350 .filter(|v| !v.is_empty())
1351 .ok_or_else(|| {
1352 io::Error::new(
1353 io::ErrorKind::InvalidInput,
1354 "TRUSS_GCS_BUCKET is required when TRUSS_STORAGE_BACKEND=gcs",
1355 )
1356 })?;
1357 Some(Arc::new(gcs::build_gcs_context(
1358 bucket,
1359 allow_insecure_url_sources,
1360 )?))
1361 } else {
1362 if env::var("TRUSS_GCS_BUCKET")
1363 .ok()
1364 .filter(|v| !v.is_empty())
1365 .is_some()
1366 {
1367 eprintln!(
1368 "truss: warning: TRUSS_GCS_BUCKET is set but TRUSS_STORAGE_BACKEND is not \
1369 `gcs`. The GCS bucket will be ignored. Set TRUSS_STORAGE_BACKEND=gcs to \
1370 enable the GCS backend."
1371 );
1372 }
1373 None
1374 };
1375
1376 #[cfg(feature = "azure")]
1377 let azure_context = if storage_backend == StorageBackend::Azure {
1378 let container = env::var("TRUSS_AZURE_CONTAINER")
1379 .ok()
1380 .filter(|v| !v.is_empty())
1381 .ok_or_else(|| {
1382 io::Error::new(
1383 io::ErrorKind::InvalidInput,
1384 "TRUSS_AZURE_CONTAINER is required when TRUSS_STORAGE_BACKEND=azure",
1385 )
1386 })?;
1387 Some(Arc::new(azure::build_azure_context(
1388 container,
1389 allow_insecure_url_sources,
1390 )?))
1391 } else {
1392 if env::var("TRUSS_AZURE_CONTAINER")
1393 .ok()
1394 .filter(|v| !v.is_empty())
1395 .is_some()
1396 {
1397 eprintln!(
1398 "truss: warning: TRUSS_AZURE_CONTAINER is set but TRUSS_STORAGE_BACKEND is not \
1399 `azure`. The Azure container will be ignored. Set TRUSS_STORAGE_BACKEND=azure to \
1400 enable the Azure backend."
1401 );
1402 }
1403 None
1404 };
1405
1406 let metrics_token = env::var("TRUSS_METRICS_TOKEN")
1407 .ok()
1408 .filter(|value| !value.trim().is_empty());
1409 let disable_metrics = env_flag("TRUSS_DISABLE_METRICS")?;
1410 let health_token = env::var("TRUSS_HEALTH_TOKEN")
1411 .ok()
1412 .filter(|value| !value.trim().is_empty());
1413 if health_token.is_some() {
1414 eprintln!(
1415 "truss: /health endpoint requires Bearer authentication (TRUSS_HEALTH_TOKEN is set)"
1416 );
1417 }
1418
1419 let health_cache_min_free_bytes =
1420 parse_env_u64_ranged("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", 1, u64::MAX)?;
1421 let health_max_memory_bytes =
1422 parse_env_u64_ranged("TRUSS_HEALTH_MAX_MEMORY_BYTES", 1, u64::MAX)?;
1423 let health_cache_ttl_secs = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_TTL_SECS", 0, 300)?
1424 .unwrap_or(super::handler::DEFAULT_HEALTH_CACHE_TTL_SECS);
1425 let hysteresis_margin = parse_env_f64_ranged("TRUSS_HEALTH_HYSTERESIS_MARGIN", 0.01, 0.50)?
1426 .unwrap_or(super::handler::DEFAULT_HYSTERESIS_MARGIN);
1427 let health_cache = Arc::new(super::handler::HealthCache::new(
1428 health_cache_ttl_secs,
1429 hysteresis_margin,
1430 ));
1431
1432 let (presets, presets_file_path) = parse_presets_from_env()?;
1433
1434 let shutdown_drain_secs = parse_env_u64_ranged("TRUSS_SHUTDOWN_DRAIN_SECS", 0, 300)?
1435 .unwrap_or(DEFAULT_SHUTDOWN_DRAIN_SECS);
1436
1437 let custom_response_headers = parse_response_headers_from_env()?;
1438
1439 let enable_compression = !env_flag("TRUSS_DISABLE_COMPRESSION")?;
1440 let disable_accept_negotiation = env_flag("TRUSS_DISABLE_ACCEPT_NEGOTIATION")?;
1441 #[cfg(feature = "s3")]
1445 env_flag("TRUSS_S3_FORCE_PATH_STYLE")?;
1446 let compression_level =
1447 parse_env_u64_ranged("TRUSS_COMPRESSION_LEVEL", 0, 9)?.unwrap_or(1) as u32;
1448
1449 let log_level = match env::var("TRUSS_LOG_LEVEL").ok().filter(|v| !v.is_empty()) {
1450 Some(val) => val
1451 .parse::<LogLevel>()
1452 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?,
1453 None => LogLevel::Info,
1454 };
1455
1456 let format_preference = parse_format_preference_from_env()?;
1457
1458 let rate_limiter = {
1459 let rps = parse_env_u64_ranged("TRUSS_RATE_LIMIT_RPS", 0, 100_000)?.unwrap_or(0);
1460 if rps > 0 {
1461 let burst =
1462 parse_env_u64_ranged("TRUSS_RATE_LIMIT_BURST", 1, 100_000)?.unwrap_or(rps);
1463 Some(Arc::new(super::rate_limit::RateLimiter::new(
1464 rps as f64,
1465 burst as f64,
1466 )))
1467 } else {
1468 None
1469 }
1470 };
1471
1472 let trusted_proxies = match env::var("TRUSS_TRUSTED_PROXIES")
1473 .ok()
1474 .filter(|v| !v.is_empty())
1475 {
1476 Some(val) => val
1477 .split(',')
1478 .filter(|s| !s.trim().is_empty())
1479 .map(TrustedProxy::parse)
1480 .collect::<Result<Vec<_>, _>>()
1481 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?,
1482 None => Vec::new(),
1483 };
1484
1485 Ok(Self {
1486 storage_root,
1487 bearer_token,
1488 public_base_url,
1489 signed_url_key_id,
1490 signed_url_secret,
1491 signing_keys,
1492 allow_insecure_url_sources,
1493 cache_root,
1494 cache_max_bytes,
1495 cache_eviction_secs: Arc::new(AtomicU64::new(0)),
1496 public_max_age_seconds,
1497 public_stale_while_revalidate_seconds,
1498 disable_accept_negotiation,
1499 format_preference,
1500 log_handler: None,
1501 log_level: Arc::new(AtomicU8::new(log_level as u8)),
1502 max_concurrent_transforms,
1503 transform_deadline_secs,
1504 max_input_pixels,
1505 max_upload_bytes,
1506 keep_alive_max_requests,
1507 metrics_token,
1508 disable_metrics,
1509 health_token,
1510 health_cache_min_free_bytes,
1511 health_max_memory_bytes,
1512 health_cache,
1513 shutdown_drain_secs,
1514 draining: Arc::new(AtomicBool::new(false)),
1515 custom_response_headers,
1516 max_source_bytes,
1517 max_watermark_bytes,
1518 max_remote_redirects,
1519 enable_compression,
1520 compression_level,
1521 transforms_in_flight: Arc::new(AtomicU64::new(0)),
1522 presets: Arc::new(std::sync::RwLock::new(presets)),
1523 presets_file_path,
1524 rate_limiter,
1525 trusted_proxies,
1526 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1527 storage_timeout_secs,
1528 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1529 storage_backend,
1530 #[cfg(feature = "s3")]
1531 s3_context,
1532 #[cfg(feature = "gcs")]
1533 gcs_context,
1534 #[cfg(feature = "azure")]
1535 azure_context,
1536 })
1537 }
1538}
1539
1540pub(super) fn parse_env_u64_ranged(name: &str, min: u64, max: u64) -> io::Result<Option<u64>> {
1545 match env::var(name).ok().filter(|v| !v.is_empty()) {
1546 Some(value) => {
1547 let n: u64 = value.parse().map_err(|_| {
1548 io::Error::new(
1549 io::ErrorKind::InvalidInput,
1550 format!("{name} must be a positive integer"),
1551 )
1552 })?;
1553 if n < min || n > max {
1554 return Err(io::Error::new(
1555 io::ErrorKind::InvalidInput,
1556 format!("{name} must be between {min} and {max}"),
1557 ));
1558 }
1559 Ok(Some(n))
1560 }
1561 None => Ok(None),
1562 }
1563}
1564
1565fn parse_env_f64_ranged(name: &str, min: f64, max: f64) -> io::Result<Option<f64>> {
1570 match env::var(name).ok().filter(|v| !v.is_empty()) {
1571 Some(value) => {
1572 let n: f64 = value.parse().map_err(|_| {
1573 io::Error::new(
1574 io::ErrorKind::InvalidInput,
1575 format!("{name} must be a number"),
1576 )
1577 })?;
1578 if n < min || n > max {
1579 return Err(io::Error::new(
1580 io::ErrorKind::InvalidInput,
1581 format!("{name} must be between {min} and {max}"),
1582 ));
1583 }
1584 Ok(Some(n))
1585 }
1586 None => Ok(None),
1587 }
1588}
1589
1590pub(super) fn parse_format_preference_from_env() -> io::Result<Vec<crate::MediaType>> {
1597 let Some(value) = env::var("TRUSS_FORMAT_PREFERENCE")
1598 .ok()
1599 .filter(|v| !v.is_empty())
1600 else {
1601 return Ok(Vec::new());
1602 };
1603
1604 let mut formats = Vec::new();
1605 for segment in value.split(',') {
1606 let name = segment.trim();
1607 if name.is_empty() {
1608 continue;
1609 }
1610 let media_type: crate::MediaType = name.parse().map_err(|e: String| {
1611 io::Error::new(
1612 io::ErrorKind::InvalidInput,
1613 format!("TRUSS_FORMAT_PREFERENCE: {e}"),
1614 )
1615 })?;
1616 if !media_type.is_encodable() {
1617 return Err(io::Error::new(
1620 io::ErrorKind::InvalidInput,
1621 format!("TRUSS_FORMAT_PREFERENCE: `{name}` is an input-only format"),
1622 ));
1623 }
1624 if formats.contains(&media_type) {
1625 return Err(io::Error::new(
1626 io::ErrorKind::InvalidInput,
1627 format!("TRUSS_FORMAT_PREFERENCE: duplicate format `{name}`"),
1628 ));
1629 }
1630 formats.push(media_type);
1631 }
1632 Ok(formats)
1633}
1634
1635pub(super) fn env_flag(name: &str) -> io::Result<bool> {
1650 let Ok(value) = env::var(name) else {
1651 return Ok(false);
1652 };
1653 match value.to_ascii_lowercase().as_str() {
1654 "1" | "true" | "yes" | "on" => Ok(true),
1655 "0" | "false" | "no" | "off" => Ok(false),
1656 _ => Err(io::Error::new(
1657 io::ErrorKind::InvalidInput,
1658 format!(
1659 "{name} must be one of `1`, `true`, `yes`, `on`, `0`, `false`, `no`, `off` (case-insensitive), got `{value}`"
1660 ),
1661 )),
1662 }
1663}
1664
1665pub(super) fn parse_optional_env_u32(name: &str) -> io::Result<Option<u32>> {
1666 match env::var(name) {
1667 Ok(value) if !value.is_empty() => value.parse::<u32>().map(Some).map_err(|_| {
1668 io::Error::new(
1669 io::ErrorKind::InvalidInput,
1670 format!("{name} must be a non-negative integer"),
1671 )
1672 }),
1673 _ => Ok(None),
1674 }
1675}
1676
1677pub(super) fn parse_presets_from_env()
1680-> io::Result<(HashMap<String, TransformOptionsPayload>, Option<PathBuf>)> {
1681 let (json_str, source, file_path) = match env::var("TRUSS_PRESETS_FILE")
1682 .ok()
1683 .filter(|v| !v.is_empty())
1684 {
1685 Some(path) => {
1686 let content = std::fs::read_to_string(&path).map_err(|e| {
1687 io::Error::new(
1688 io::ErrorKind::InvalidInput,
1689 format!("failed to read TRUSS_PRESETS_FILE `{path}`: {e}"),
1690 )
1691 })?;
1692 let pb = PathBuf::from(&path);
1693 (content, format!("TRUSS_PRESETS_FILE `{path}`"), Some(pb))
1694 }
1695 None => match env::var("TRUSS_PRESETS").ok().filter(|v| !v.is_empty()) {
1696 Some(value) => (value, "TRUSS_PRESETS".to_string(), None),
1697 None => return Ok((HashMap::new(), None)),
1698 },
1699 };
1700
1701 let presets = serde_json::from_str::<HashMap<String, TransformOptionsPayload>>(&json_str)
1702 .map_err(|e| {
1703 io::Error::new(
1704 io::ErrorKind::InvalidInput,
1705 format!("{source} must be valid JSON: {e}"),
1706 )
1707 })?;
1708 reject_nested_presets(&presets, &source)?;
1709 Ok((presets, file_path))
1710}
1711
1712fn reject_nested_presets(
1719 presets: &HashMap<String, TransformOptionsPayload>,
1720 source: &str,
1721) -> io::Result<()> {
1722 for (name, payload) in presets {
1723 if payload.preset.is_some() {
1724 return Err(io::Error::new(
1725 io::ErrorKind::InvalidInput,
1726 format!("{source}: preset `{name}` must not set `preset`"),
1727 ));
1728 }
1729 }
1730 Ok(())
1731}
1732
1733pub(super) fn parse_presets_file(
1735 path: &std::path::Path,
1736) -> io::Result<HashMap<String, TransformOptionsPayload>> {
1737 let content = std::fs::read_to_string(path)?;
1738 let presets = serde_json::from_str::<HashMap<String, TransformOptionsPayload>>(&content)
1739 .map_err(|e| {
1740 io::Error::new(
1741 io::ErrorKind::InvalidData,
1742 format!("invalid preset JSON in `{}`: {e}", path.display()),
1743 )
1744 })?;
1745 reject_nested_presets(&presets, &format!("`{}`", path.display()))?;
1746 Ok(presets)
1747}
1748
1749fn parse_response_headers_from_env() -> io::Result<Vec<(String, String)>> {
1753 let Some(raw) = env::var("TRUSS_RESPONSE_HEADERS")
1754 .ok()
1755 .filter(|v| !v.is_empty())
1756 else {
1757 return Ok(Vec::new());
1758 };
1759
1760 let map: HashMap<String, String> = serde_json::from_str(&raw).map_err(|e| {
1761 io::Error::new(
1762 io::ErrorKind::InvalidInput,
1763 format!("TRUSS_RESPONSE_HEADERS must be a JSON object: {e}"),
1764 )
1765 })?;
1766
1767 let mut headers = Vec::with_capacity(map.len());
1768 for (name, value) in map {
1769 validate_header_name(&name)?;
1770 reject_denied_header(&name)?;
1771 validate_header_value(&name, &value)?;
1772 headers.push((name, value));
1773 }
1774 headers.sort_by(|a, b| a.0.cmp(&b.0));
1776 Ok(headers)
1777}
1778
1779fn validate_header_name(name: &str) -> io::Result<()> {
1781 if name.is_empty() {
1782 return Err(io::Error::new(
1783 io::ErrorKind::InvalidInput,
1784 "TRUSS_RESPONSE_HEADERS: header name must not be empty",
1785 ));
1786 }
1787 for byte in name.bytes() {
1791 let valid = byte.is_ascii_alphanumeric()
1792 || matches!(
1793 byte,
1794 b'!' | b'#'
1795 | b'$'
1796 | b'%'
1797 | b'&'
1798 | b'\''
1799 | b'*'
1800 | b'+'
1801 | b'-'
1802 | b'.'
1803 | b'^'
1804 | b'_'
1805 | b'`'
1806 | b'|'
1807 | b'~'
1808 );
1809 if !valid {
1810 return Err(io::Error::new(
1811 io::ErrorKind::InvalidInput,
1812 format!("TRUSS_RESPONSE_HEADERS: invalid character in header name `{name}`"),
1813 ));
1814 }
1815 }
1816 Ok(())
1817}
1818
1819fn validate_header_value(name: &str, value: &str) -> io::Result<()> {
1821 for byte in value.bytes() {
1822 let valid = byte == b'\t' || (0x20..=0x7E).contains(&byte);
1823 if !valid {
1824 return Err(io::Error::new(
1825 io::ErrorKind::InvalidInput,
1826 format!("TRUSS_RESPONSE_HEADERS: invalid character in value for header `{name}`"),
1827 ));
1828 }
1829 }
1830 Ok(())
1831}
1832
1833fn reject_denied_header(name: &str) -> io::Result<()> {
1837 const DENIED: &[&str] = &[
1838 "content-length",
1839 "transfer-encoding",
1840 "content-encoding",
1841 "content-type",
1842 "connection",
1843 "host",
1844 "upgrade",
1845 "proxy-connection",
1846 "keep-alive",
1847 "te",
1848 "trailer",
1849 ];
1850 let lower = name.to_ascii_lowercase();
1851 if DENIED.contains(&lower.as_str()) {
1852 return Err(io::Error::new(
1853 io::ErrorKind::InvalidInput,
1854 format!(
1855 "TRUSS_RESPONSE_HEADERS: header `{name}` is not allowed (framing/hop-by-hop header)"
1856 ),
1857 ));
1858 }
1859 Ok(())
1860}
1861
1862pub(super) fn validate_public_base_url(value: String) -> io::Result<String> {
1863 let parsed = Url::parse(&value).map_err(|error| {
1864 io::Error::new(
1865 io::ErrorKind::InvalidInput,
1866 format!("TRUSS_PUBLIC_BASE_URL must be a valid URL: {error}"),
1867 )
1868 })?;
1869
1870 match parsed.scheme() {
1871 "http" | "https" => Ok(parsed.to_string()),
1872 _ => Err(io::Error::new(
1873 io::ErrorKind::InvalidInput,
1874 "TRUSS_PUBLIC_BASE_URL must use http or https",
1875 )),
1876 }
1877}
1878
1879#[cfg(test)]
1880mod tests {
1881 use super::*;
1882 use serial_test::serial;
1883
1884 struct ScopedEnv {
1886 key: &'static str,
1887 }
1888
1889 impl ScopedEnv {
1890 fn set(key: &'static str, value: &str) -> Self {
1891 unsafe { env::set_var(key, value) };
1893 Self { key }
1894 }
1895
1896 fn remove(key: &'static str) -> Self {
1897 unsafe { env::remove_var(key) };
1899 Self { key }
1900 }
1901 }
1902
1903 impl Drop for ScopedEnv {
1904 fn drop(&mut self) {
1905 unsafe { env::remove_var(self.key) };
1907 }
1908 }
1909
1910 #[test]
1911 fn keep_alive_default() {
1912 let config = ServerConfig::new(PathBuf::from("."), None);
1913 assert_eq!(config.keep_alive_max_requests, 100);
1914 }
1915
1916 #[test]
1917 #[serial]
1918 fn parse_keep_alive_env_valid() {
1919 let _env = ScopedEnv::set("TRUSS_KEEP_ALIVE_MAX_REQUESTS", "500");
1920 let result = parse_env_u64_ranged("TRUSS_KEEP_ALIVE_MAX_REQUESTS", 1, 100_000);
1921 assert_eq!(result.unwrap(), Some(500));
1922 }
1923
1924 #[test]
1925 #[serial]
1926 fn parse_keep_alive_env_zero_rejected() {
1927 let _env = ScopedEnv::set("TRUSS_KEEP_ALIVE_MAX_REQUESTS", "0");
1928 let result = parse_env_u64_ranged("TRUSS_KEEP_ALIVE_MAX_REQUESTS", 1, 100_000);
1929 assert!(result.is_err());
1930 }
1931
1932 #[test]
1933 #[serial]
1934 fn parse_keep_alive_env_over_max_rejected() {
1935 let _env = ScopedEnv::set("TRUSS_KEEP_ALIVE_MAX_REQUESTS", "100001");
1936 let result = parse_env_u64_ranged("TRUSS_KEEP_ALIVE_MAX_REQUESTS", 1, 100_000);
1937 assert!(result.is_err());
1938 }
1939
1940 #[test]
1941 fn health_thresholds_default_none() {
1942 let config = ServerConfig::new(PathBuf::from("."), None);
1943 assert!(config.health_cache_min_free_bytes.is_none());
1944 assert!(config.health_max_memory_bytes.is_none());
1945 }
1946
1947 #[test]
1948 #[serial]
1949 fn parse_health_cache_min_free_bytes_valid() {
1950 let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", "1073741824");
1951 let result = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", 1, u64::MAX);
1952 assert_eq!(result.unwrap(), Some(1_073_741_824));
1953 }
1954
1955 #[test]
1956 #[serial]
1957 fn parse_health_max_memory_bytes_valid() {
1958 let _env = ScopedEnv::set("TRUSS_HEALTH_MAX_MEMORY_BYTES", "536870912");
1959 let result = parse_env_u64_ranged("TRUSS_HEALTH_MAX_MEMORY_BYTES", 1, u64::MAX);
1960 assert_eq!(result.unwrap(), Some(536_870_912));
1961 }
1962
1963 #[test]
1964 #[serial]
1965 fn parse_health_threshold_zero_rejected() {
1966 let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", "0");
1967 let result = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", 1, u64::MAX);
1968 assert!(result.is_err());
1969 }
1970
1971 #[test]
1974 fn shutdown_drain_secs_default() {
1975 let config = ServerConfig::new(PathBuf::from("."), None);
1976 assert_eq!(config.shutdown_drain_secs, DEFAULT_SHUTDOWN_DRAIN_SECS);
1977 }
1978
1979 #[test]
1980 fn draining_default_false() {
1981 let config = ServerConfig::new(PathBuf::from("."), None);
1982 assert!(!config.draining.load(std::sync::atomic::Ordering::Relaxed));
1983 }
1984
1985 #[test]
1986 #[serial]
1987 fn parse_shutdown_drain_secs_valid() {
1988 let _env = ScopedEnv::set("TRUSS_SHUTDOWN_DRAIN_SECS", "30");
1989 let result = parse_env_u64_ranged("TRUSS_SHUTDOWN_DRAIN_SECS", 0, 300);
1990 assert_eq!(result.unwrap(), Some(30));
1991 }
1992
1993 #[test]
1994 #[serial]
1995 fn parse_shutdown_drain_secs_over_max_rejected() {
1996 let _env = ScopedEnv::set("TRUSS_SHUTDOWN_DRAIN_SECS", "301");
1997 let result = parse_env_u64_ranged("TRUSS_SHUTDOWN_DRAIN_SECS", 0, 300);
1998 assert!(result.is_err());
1999 }
2000
2001 #[test]
2004 fn presets_default_empty() {
2005 let config = ServerConfig::new(PathBuf::from("."), None);
2006 assert!(config.presets.read().unwrap().is_empty());
2007 assert!(config.presets_file_path.is_none());
2008 }
2009
2010 #[test]
2011 fn parse_presets_file_valid() {
2012 let dir = std::env::temp_dir().join(format!(
2013 "truss_test_presets_{}",
2014 std::time::SystemTime::UNIX_EPOCH
2015 .elapsed()
2016 .unwrap()
2017 .as_nanos()
2018 ));
2019 std::fs::create_dir_all(&dir).unwrap();
2020 let path = dir.join("presets.json");
2021 std::fs::write(
2022 &path,
2023 r#"{"thumb":{"width":100,"height":100},"banner":{"width":1200}}"#,
2024 )
2025 .unwrap();
2026
2027 let presets = super::parse_presets_file(&path).unwrap();
2028 assert_eq!(presets.len(), 2);
2029 assert_eq!(presets["thumb"].width, Some(100));
2030 assert_eq!(presets["thumb"].height, Some(100));
2031 assert_eq!(presets["banner"].width, Some(1200));
2032
2033 std::fs::remove_dir_all(&dir).unwrap();
2034 }
2035
2036 #[test]
2037 fn parse_presets_file_invalid_json() {
2038 let dir = std::env::temp_dir().join(format!(
2039 "truss_test_presets_invalid_{}",
2040 std::time::SystemTime::UNIX_EPOCH
2041 .elapsed()
2042 .unwrap()
2043 .as_nanos()
2044 ));
2045 std::fs::create_dir_all(&dir).unwrap();
2046 let path = dir.join("bad.json");
2047 std::fs::write(&path, "not valid json {{{").unwrap();
2048
2049 let result = super::parse_presets_file(&path);
2050 assert!(result.is_err());
2051
2052 std::fs::remove_dir_all(&dir).unwrap();
2053 }
2054
2055 #[test]
2056 fn parse_presets_file_nonexistent() {
2057 let result =
2058 super::parse_presets_file(std::path::Path::new("/tmp/nonexistent_truss_test.json"));
2059 assert!(result.is_err());
2060 }
2061
2062 #[test]
2063 #[serial]
2064 fn parse_presets_from_env_returns_file_path() {
2065 let dir = std::env::temp_dir().join(format!(
2066 "truss_test_presets_path_{}",
2067 std::time::SystemTime::UNIX_EPOCH
2068 .elapsed()
2069 .unwrap()
2070 .as_nanos()
2071 ));
2072 std::fs::create_dir_all(&dir).unwrap();
2073 let path = dir.join("presets.json");
2074 std::fs::write(&path, r#"{"thumb":{"width":100}}"#).unwrap();
2075
2076 let _env = ScopedEnv::set("TRUSS_PRESETS_FILE", path.to_str().unwrap());
2077 let _env2 = ScopedEnv::remove("TRUSS_PRESETS");
2078 let (presets, file_path) = super::parse_presets_from_env().unwrap();
2079
2080 assert_eq!(presets.len(), 1);
2081 assert_eq!(file_path, Some(path));
2082
2083 std::fs::remove_dir_all(&dir).unwrap();
2084 }
2085
2086 #[test]
2087 fn with_presets_sets_presets() {
2088 let mut map = HashMap::new();
2089 map.insert(
2090 "test".to_string(),
2091 super::super::TransformOptionsPayload {
2092 width: Some(200),
2093 height: None,
2094 fit: None,
2095 position: None,
2096 format: None,
2097 quality: None,
2098 optimize: None,
2099 target_quality: None,
2100 background: None,
2101 rotate: None,
2102 auto_orient: None,
2103 strip_metadata: None,
2104 preserve_exif: None,
2105 crop: None,
2106 blur: None,
2107 sharpen: None,
2108 grayscale: None,
2109 without_enlargement: None,
2110 preset: None,
2111 },
2112 );
2113 let config = ServerConfig::new(PathBuf::from("."), None).with_presets(map);
2114 let presets = config.presets.read().unwrap();
2115 assert_eq!(presets.len(), 1);
2116 assert_eq!(presets["test"].width, Some(200));
2117 }
2118
2119 #[test]
2122 fn custom_response_headers_default_empty() {
2123 let config = ServerConfig::new(PathBuf::from("."), None);
2124 assert!(config.custom_response_headers.is_empty());
2125 }
2126
2127 #[test]
2128 #[serial]
2129 fn parse_response_headers_valid_json() {
2130 let _env = ScopedEnv::set(
2131 "TRUSS_RESPONSE_HEADERS",
2132 r#"{"CDN-Cache-Control":"max-age=3600","X-Custom":"value"}"#,
2133 );
2134 let result = parse_response_headers_from_env();
2135 let headers = result.unwrap();
2136 assert_eq!(headers.len(), 2);
2137 assert_eq!(headers[0].0, "CDN-Cache-Control");
2139 assert_eq!(headers[0].1, "max-age=3600");
2140 assert_eq!(headers[1].0, "X-Custom");
2141 assert_eq!(headers[1].1, "value");
2142 }
2143
2144 #[test]
2145 #[serial]
2146 fn parse_response_headers_invalid_json() {
2147 let _env = ScopedEnv::set("TRUSS_RESPONSE_HEADERS", "not json");
2148 let result = parse_response_headers_from_env();
2149 assert!(result.is_err());
2150 }
2151
2152 #[test]
2153 #[serial]
2154 fn parse_response_headers_empty_name_rejected() {
2155 let _env = ScopedEnv::set("TRUSS_RESPONSE_HEADERS", r#"{"":"value"}"#);
2156 let result = parse_response_headers_from_env();
2157 assert!(result.is_err());
2158 }
2159
2160 #[test]
2161 #[serial]
2162 fn parse_response_headers_invalid_name_character() {
2163 let _env = ScopedEnv::set("TRUSS_RESPONSE_HEADERS", r#"{"Bad Header":"value"}"#);
2164 let result = parse_response_headers_from_env();
2165 assert!(result.is_err());
2166 }
2167
2168 #[test]
2169 #[serial]
2170 fn parse_response_headers_invalid_value_character() {
2171 let _env = ScopedEnv::set("TRUSS_RESPONSE_HEADERS", r#"{"X-Bad":"val\u0000ue"}"#);
2172 let result = parse_response_headers_from_env();
2173 assert!(result.is_err());
2174 }
2175
2176 #[test]
2177 fn validate_header_name_valid() {
2178 assert!(super::validate_header_name("Cache-Control").is_ok());
2179 assert!(super::validate_header_name("X-Custom-Header").is_ok());
2180 assert!(super::validate_header_name("CDN-Cache-Control").is_ok());
2181 }
2182
2183 #[test]
2184 fn validate_header_name_rejects_space() {
2185 assert!(super::validate_header_name("Bad Header").is_err());
2186 }
2187
2188 #[test]
2189 fn validate_header_name_rejects_empty() {
2190 assert!(super::validate_header_name("").is_err());
2191 }
2192
2193 #[test]
2194 fn validate_header_value_valid() {
2195 assert!(super::validate_header_value("X", "normal value").is_ok());
2196 assert!(super::validate_header_value("X", "max-age=3600, public").is_ok());
2197 }
2198
2199 #[test]
2200 fn validate_header_value_rejects_null() {
2201 assert!(super::validate_header_value("X", "bad\x00value").is_err());
2202 }
2203
2204 #[test]
2207 fn compression_enabled_by_default() {
2208 let config = ServerConfig::new(PathBuf::from("."), None);
2209 assert!(config.enable_compression);
2210 }
2211
2212 #[test]
2215 fn log_level_default_info() {
2216 let config = ServerConfig::new(PathBuf::from("."), None);
2217 assert_eq!(config.current_log_level(), LogLevel::Info);
2218 }
2219
2220 #[test]
2221 fn log_level_cycle() {
2222 assert_eq!(LogLevel::Info.cycle(), LogLevel::Debug);
2223 assert_eq!(LogLevel::Debug.cycle(), LogLevel::Error);
2224 assert_eq!(LogLevel::Error.cycle(), LogLevel::Warn);
2225 assert_eq!(LogLevel::Warn.cycle(), LogLevel::Info);
2226 }
2227
2228 #[test]
2229 fn log_level_from_str() {
2230 assert_eq!("error".parse::<LogLevel>().unwrap(), LogLevel::Error);
2231 assert_eq!("WARN".parse::<LogLevel>().unwrap(), LogLevel::Warn);
2232 assert_eq!("Info".parse::<LogLevel>().unwrap(), LogLevel::Info);
2233 assert_eq!("DEBUG".parse::<LogLevel>().unwrap(), LogLevel::Debug);
2234 assert!("invalid".parse::<LogLevel>().is_err());
2235 }
2236
2237 #[test]
2238 fn log_level_display() {
2239 assert_eq!(LogLevel::Error.to_string(), "error");
2240 assert_eq!(LogLevel::Warn.to_string(), "warn");
2241 assert_eq!(LogLevel::Info.to_string(), "info");
2242 assert_eq!(LogLevel::Debug.to_string(), "debug");
2243 }
2244
2245 #[test]
2246 fn log_level_from_u8_roundtrip() {
2247 for level in [
2248 LogLevel::Error,
2249 LogLevel::Warn,
2250 LogLevel::Info,
2251 LogLevel::Debug,
2252 ] {
2253 assert_eq!(LogLevel::from_u8(level as u8), level);
2254 }
2255 assert_eq!(LogLevel::from_u8(42), LogLevel::Info);
2257 }
2258
2259 #[test]
2260 #[serial]
2261 fn parse_log_level_from_env() {
2262 let _env = ScopedEnv::set("TRUSS_LOG_LEVEL", "debug");
2263 let config = ServerConfig::from_env().unwrap();
2264 assert_eq!(config.current_log_level(), LogLevel::Debug);
2265 }
2266
2267 #[test]
2268 #[serial]
2269 fn parse_log_level_invalid_rejected() {
2270 let _env = ScopedEnv::set("TRUSS_LOG_LEVEL", "verbose");
2271 let result = ServerConfig::from_env();
2272 assert!(result.is_err());
2273 }
2274
2275 #[test]
2276 fn log_at_filters_by_level() {
2277 use std::sync::Mutex;
2278
2279 let messages: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2280 let msgs = Arc::clone(&messages);
2281 let handler: LogHandler = Arc::new(move |msg: &str| {
2282 msgs.lock().unwrap().push(msg.to_string());
2283 });
2284
2285 let mut config = ServerConfig::new(PathBuf::from("."), None);
2286 config.log_handler = Some(handler);
2287 config
2289 .log_level
2290 .store(LogLevel::Warn as u8, std::sync::atomic::Ordering::Relaxed);
2291
2292 config.log_at(LogLevel::Error, "err");
2293 config.log_at(LogLevel::Warn, "wrn");
2294 config.log_at(LogLevel::Info, "inf");
2295 config.log_at(LogLevel::Debug, "dbg");
2296
2297 let logged = messages.lock().unwrap();
2298 assert_eq!(*logged, vec!["err", "wrn"]);
2299 }
2300
2301 #[test]
2304 #[serial]
2305 fn parse_format_preference_unset_returns_empty() {
2306 let _env = ScopedEnv::remove("TRUSS_FORMAT_PREFERENCE");
2307 let result = parse_format_preference_from_env().unwrap();
2308 assert!(result.is_empty());
2309 }
2310
2311 #[test]
2312 #[serial]
2313 fn parse_format_preference_empty_returns_empty() {
2314 let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "");
2315 let result = parse_format_preference_from_env().unwrap();
2316 assert!(result.is_empty());
2317 }
2318
2319 #[test]
2320 #[serial]
2321 fn parse_format_preference_single_format() {
2322 let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "webp");
2323 let result = parse_format_preference_from_env().unwrap();
2324 assert_eq!(result, vec![crate::MediaType::Webp]);
2325 }
2326
2327 #[test]
2328 #[serial]
2329 fn parse_format_preference_multiple_formats() {
2330 let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "avif,webp,png,jpeg");
2331 let result = parse_format_preference_from_env().unwrap();
2332 assert_eq!(
2333 result,
2334 vec![
2335 crate::MediaType::Avif,
2336 crate::MediaType::Webp,
2337 crate::MediaType::Png,
2338 crate::MediaType::Jpeg,
2339 ]
2340 );
2341 }
2342
2343 #[test]
2344 #[serial]
2345 fn parse_format_preference_with_spaces() {
2346 let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", " webp , jpeg , png ");
2347 let result = parse_format_preference_from_env().unwrap();
2348 assert_eq!(
2349 result,
2350 vec![
2351 crate::MediaType::Webp,
2352 crate::MediaType::Jpeg,
2353 crate::MediaType::Png,
2354 ]
2355 );
2356 }
2357
2358 #[test]
2359 #[serial]
2360 fn parse_format_preference_invalid_format_rejected() {
2361 let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "webp,heic");
2362 let result = parse_format_preference_from_env();
2363 assert!(result.is_err());
2364 let msg = result.unwrap_err().to_string();
2365 assert!(msg.contains("TRUSS_FORMAT_PREFERENCE"));
2366 }
2367
2368 #[test]
2369 #[serial]
2370 fn parse_format_preference_rejects_a_decode_only_format() {
2371 let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "webp,gif");
2374 let result = parse_format_preference_from_env();
2375 let msg = result.unwrap_err().to_string();
2376 assert!(
2377 msg.contains("input-only"),
2378 "the error should say why, got: {msg}"
2379 );
2380 }
2381
2382 #[test]
2383 #[serial]
2384 fn parse_format_preference_duplicate_rejected() {
2385 let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "webp,jpeg,webp");
2386 let result = parse_format_preference_from_env();
2387 assert!(result.is_err());
2388 let msg = result.unwrap_err().to_string();
2389 assert!(msg.contains("duplicate"));
2390 }
2391
2392 #[test]
2393 #[serial]
2394 fn parse_format_preference_trailing_comma_ok() {
2395 let _env = ScopedEnv::set("TRUSS_FORMAT_PREFERENCE", "avif,webp,");
2396 let result = parse_format_preference_from_env().unwrap();
2397 assert_eq!(result, vec![crate::MediaType::Avif, crate::MediaType::Webp]);
2398 }
2399
2400 #[test]
2403 fn trusted_proxy_parse_single_ipv4() {
2404 let tp = TrustedProxy::parse("10.0.0.1").unwrap();
2405 assert_eq!(tp, TrustedProxy::Addr("10.0.0.1".parse().unwrap()));
2406 }
2407
2408 #[test]
2409 fn trusted_proxy_parse_single_ipv6() {
2410 let tp = TrustedProxy::parse("::1").unwrap();
2411 assert_eq!(tp, TrustedProxy::Addr("::1".parse().unwrap()));
2412 }
2413
2414 #[test]
2415 fn trusted_proxy_parse_cidr_v4() {
2416 let tp = TrustedProxy::parse("10.0.0.0/8").unwrap();
2417 assert_eq!(tp, TrustedProxy::Cidr("10.0.0.0".parse().unwrap(), 8));
2418 }
2419
2420 #[test]
2421 fn trusted_proxy_parse_cidr_v6() {
2422 let tp = TrustedProxy::parse("fd00::/8").unwrap();
2423 assert_eq!(tp, TrustedProxy::Cidr("fd00::".parse().unwrap(), 8));
2424 }
2425
2426 #[test]
2427 fn trusted_proxy_parse_with_whitespace() {
2428 let tp = TrustedProxy::parse(" 10.0.0.1 ").unwrap();
2429 assert_eq!(tp, TrustedProxy::Addr("10.0.0.1".parse().unwrap()));
2430 }
2431
2432 #[test]
2433 fn trusted_proxy_parse_cidr_with_whitespace() {
2434 let tp = TrustedProxy::parse(" 10.0.0.0 / 8 ").unwrap();
2435 assert_eq!(tp, TrustedProxy::Cidr("10.0.0.0".parse().unwrap(), 8));
2436 }
2437
2438 #[test]
2439 fn trusted_proxy_parse_invalid_ip() {
2440 assert!(TrustedProxy::parse("not-an-ip").is_err());
2441 }
2442
2443 #[test]
2444 fn trusted_proxy_parse_prefix_too_large_v4() {
2445 assert!(TrustedProxy::parse("10.0.0.0/33").is_err());
2446 }
2447
2448 #[test]
2449 fn trusted_proxy_parse_prefix_too_large_v6() {
2450 assert!(TrustedProxy::parse("::1/129").is_err());
2451 }
2452
2453 #[test]
2454 fn trusted_proxy_contains_exact_match() {
2455 let tp = TrustedProxy::Addr("10.0.0.1".parse().unwrap());
2456 assert!(tp.contains("10.0.0.1".parse().unwrap()));
2457 assert!(!tp.contains("10.0.0.2".parse().unwrap()));
2458 }
2459
2460 #[test]
2461 fn trusted_proxy_contains_cidr_v4() {
2462 let tp = TrustedProxy::Cidr("10.0.0.0".parse().unwrap(), 8);
2463 assert!(tp.contains("10.1.2.3".parse().unwrap()));
2464 assert!(tp.contains("10.255.255.255".parse().unwrap()));
2465 assert!(!tp.contains("11.0.0.1".parse().unwrap()));
2466 }
2467
2468 #[test]
2469 fn trusted_proxy_contains_cidr_v6() {
2470 let tp = TrustedProxy::Cidr("fd00::".parse().unwrap(), 8);
2471 assert!(tp.contains("fd12::1".parse().unwrap()));
2472 assert!(!tp.contains("fe80::1".parse().unwrap()));
2473 }
2474
2475 #[test]
2476 fn trusted_proxy_cidr_v4_does_not_match_v6() {
2477 let tp = TrustedProxy::Cidr("10.0.0.0".parse().unwrap(), 8);
2478 assert!(!tp.contains("::1".parse().unwrap()));
2479 }
2480
2481 #[test]
2482 fn trusted_proxy_cidr_zero_prefix_matches_all() {
2483 let tp = TrustedProxy::Cidr("0.0.0.0".parse().unwrap(), 0);
2484 assert!(tp.contains("1.2.3.4".parse().unwrap()));
2485 assert!(tp.contains("255.255.255.255".parse().unwrap()));
2486 }
2487
2488 #[test]
2489 fn trusted_proxy_cidr_32_matches_exact() {
2490 let tp = TrustedProxy::Cidr("10.0.0.1".parse().unwrap(), 32);
2491 assert!(tp.contains("10.0.0.1".parse().unwrap()));
2492 assert!(!tp.contains("10.0.0.2".parse().unwrap()));
2493 }
2494
2495 #[test]
2496 fn is_trusted_proxy_checks_all_entries() {
2497 let proxies = vec![
2498 TrustedProxy::Addr("10.0.0.1".parse().unwrap()),
2499 TrustedProxy::Cidr("172.16.0.0".parse().unwrap(), 12),
2500 ];
2501 assert!(is_trusted_proxy(&proxies, "10.0.0.1".parse().unwrap()));
2502 assert!(is_trusted_proxy(&proxies, "172.20.1.1".parse().unwrap()));
2503 assert!(!is_trusted_proxy(&proxies, "192.168.1.1".parse().unwrap()));
2504 }
2505
2506 #[test]
2507 fn is_trusted_proxy_empty_list() {
2508 assert!(!is_trusted_proxy(&[], "10.0.0.1".parse().unwrap()));
2509 }
2510
2511 #[test]
2512 #[serial]
2513 fn from_env_trusted_proxies_parsed() {
2514 let _env_proxies = ScopedEnv::set("TRUSS_TRUSTED_PROXIES", "10.0.0.1,172.16.0.0/12");
2515 let config = ServerConfig::from_env().unwrap();
2516 assert_eq!(config.trusted_proxies.len(), 2);
2517 assert_eq!(
2518 config.trusted_proxies[0],
2519 TrustedProxy::Addr("10.0.0.1".parse().unwrap())
2520 );
2521 assert_eq!(
2522 config.trusted_proxies[1],
2523 TrustedProxy::Cidr("172.16.0.0".parse().unwrap(), 12)
2524 );
2525 }
2526
2527 #[test]
2528 #[serial]
2529 fn from_env_trusted_proxies_empty_when_unset() {
2530 let _env = ScopedEnv::remove("TRUSS_TRUSTED_PROXIES");
2531 let config = ServerConfig::from_env().unwrap();
2532 assert!(config.trusted_proxies.is_empty());
2533 }
2534
2535 #[test]
2536 #[serial]
2537 fn from_env_trusted_proxies_invalid_rejects() {
2538 let _env = ScopedEnv::set("TRUSS_TRUSTED_PROXIES", "not-an-ip");
2539 assert!(ServerConfig::from_env().is_err());
2540 }
2541
2542 #[test]
2546 #[serial]
2547 fn env_flag_reads_a_boolean_in_any_case_and_refuses_anything_else() {
2548 let accepted: &[(&str, bool)] = &[
2549 ("1", true),
2550 ("true", true),
2551 ("TRUE", true),
2552 ("True", true),
2553 ("tRuE", true),
2554 ("yes", true),
2555 ("YES", true),
2556 ("Yes", true),
2557 ("on", true),
2558 ("ON", true),
2559 ("On", true),
2560 ("0", false),
2561 ("false", false),
2562 ("FALSE", false),
2563 ("False", false),
2564 ("no", false),
2565 ("No", false),
2566 ("off", false),
2567 ("Off", false),
2568 ];
2569 for &(value, expected) in accepted {
2570 let _env = ScopedEnv::set("TRUSS_DISABLE_METRICS", value);
2571 assert_eq!(
2572 env_flag("TRUSS_DISABLE_METRICS").expect("a documented boolean is accepted"),
2573 expected,
2574 "`{value}` should read as {expected}"
2575 );
2576 }
2577
2578 for value in ["maybe", "2", "-1", "", " 1", "1 ", "trUe\n"] {
2579 let _env = ScopedEnv::set("TRUSS_DISABLE_METRICS", value);
2580 let error = env_flag("TRUSS_DISABLE_METRICS")
2581 .expect_err("a value that is neither true nor false is refused");
2582 assert!(
2583 error.to_string().contains("TRUSS_DISABLE_METRICS"),
2584 "the error names the variable: {error}"
2585 );
2586 }
2587 }
2588
2589 #[test]
2590 #[serial]
2591 fn env_flag_is_false_when_the_variable_is_unset() {
2592 let _env = ScopedEnv::remove("TRUSS_DISABLE_METRICS");
2593 assert!(!env_flag("TRUSS_DISABLE_METRICS").expect("an unset boolean is false"));
2594 }
2595
2596 #[test]
2599 #[serial]
2600 fn from_env_reports_a_boolean_it_cannot_read() {
2601 for name in [
2602 "TRUSS_DISABLE_METRICS",
2603 "TRUSS_DISABLE_COMPRESSION",
2604 "TRUSS_DISABLE_ACCEPT_NEGOTIATION",
2605 "TRUSS_ALLOW_INSECURE_URL_SOURCES",
2606 ] {
2607 let _env = ScopedEnv::set(name, "maybe");
2608 let error = ServerConfig::from_env()
2609 .expect_err("a boolean that is neither true nor false stops startup");
2610 assert!(
2611 error.to_string().contains(name),
2612 "the error names {name}: {error}"
2613 );
2614 }
2615 }
2616
2617 #[test]
2621 fn the_documented_storage_root_default_is_the_one_the_server_uses() {
2622 assert_eq!(
2623 DEFAULT_STORAGE_ROOT, ".",
2624 "the default is the working directory, which is what the reference states"
2625 );
2626 let reference = include_str!("../../../docs/configuration.md");
2627 let row = reference
2628 .lines()
2629 .find(|line| line.starts_with("| `TRUSS_STORAGE_ROOT`"))
2630 .expect("docs/configuration.md has a TRUSS_STORAGE_ROOT row");
2631 assert!(
2632 row.contains("default: the process's current working directory"),
2633 "the row states the default: {row}"
2634 );
2635 }
2636
2637 #[test]
2642 fn every_environment_variable_is_documented() {
2643 let sources = [
2644 include_str!("config.rs"),
2645 include_str!("s3.rs"),
2646 include_str!("gcs.rs"),
2647 include_str!("azure.rs"),
2648 ];
2649 let reference = include_str!("../../../docs/configuration.md");
2650
2651 let mut undocumented: Vec<&str> = Vec::new();
2652 for source in sources {
2653 for (index, _) in source.match_indices("TRUSS_") {
2654 let name: &str = source[index..]
2655 .split(|c: char| !(c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'))
2656 .next()
2657 .unwrap_or("");
2658 if name == "TRUSS_" || reference.contains(&format!("`{name}`")) {
2661 continue;
2662 }
2663 if !undocumented.contains(&name) {
2664 undocumented.push(name);
2665 }
2666 }
2667 }
2668
2669 assert!(
2670 undocumented.is_empty(),
2671 "these environment variables are read by the server but have no row in docs/configuration.md: {undocumented:?}"
2672 );
2673 }
2674
2675 #[test]
2676 #[serial]
2677 fn parse_health_cache_ttl_secs_valid() {
2678 let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_TTL_SECS", "10");
2679 let result = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_TTL_SECS", 0, 300);
2680 assert_eq!(result.unwrap(), Some(10));
2681 }
2682
2683 #[test]
2684 #[serial]
2685 fn parse_health_cache_ttl_secs_zero_disables_caching() {
2686 let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_TTL_SECS", "0");
2687 let result = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_TTL_SECS", 0, 300);
2688 assert_eq!(result.unwrap(), Some(0));
2689 }
2690
2691 #[test]
2692 #[serial]
2693 fn from_env_wires_health_cache_ttl_secs() {
2694 let _env = ScopedEnv::set("TRUSS_HEALTH_CACHE_TTL_SECS", "10");
2695 let config = ServerConfig::from_env().unwrap();
2696 assert_eq!(config.health_cache.ttl_nanos, 10 * 1_000_000_000);
2697 }
2698
2699 #[test]
2700 fn with_health_cache_ttl_secs_overrides_default() {
2701 let config = ServerConfig::new(PathBuf::from("."), None).with_health_cache_ttl_secs(20);
2702 assert_eq!(config.health_cache.ttl_nanos, 20 * 1_000_000_000);
2703 }
2704}