Skip to main content

truss/adapters/server/
config.rs

1use super::TransformOptionsPayload;
2#[cfg(feature = "azure")]
3use super::azure;
4#[cfg(feature = "gcs")]
5use super::gcs;
6/// The narrowest and widest values `TRUSS_MAX_CONCURRENT_TRANSFORMS` accepts.
7///
8/// The default derived from the machine is clamped to the same range the environment
9/// variable is parsed against, so a configured value and a derived one cannot disagree
10/// about what is allowed.
11pub(super) const MIN_CONCURRENT_TRANSFORMS: u64 = 1;
12pub(super) const MAX_CONCURRENT_TRANSFORMS: u64 = 1024;
13
14/// The number of concurrent transforms assumed when the machine will not say how many
15/// cores it has.
16pub(super) const FALLBACK_MAX_CONCURRENT_TRANSFORMS: u64 = 4;
17
18/// The default number of transforms allowed to run at once, one per core.
19///
20/// This used to be a flat 64 on every machine. A transform is CPU work from end to end, so
21/// admitting more of them than the machine can run does not make the server faster: measured
22/// on a 32-core machine with AVIF output, throughput was the same at 32 in flight and at 64,
23/// while the median latency went from 19 to 30 seconds and the 95th percentile from 25 to
24/// 79. With the default 30 second deadline that is not merely slower, it is a failure: half
25/// of a 128 request run was answered `413` after its encode had already finished, so the
26/// work was done and then thrown away. Admitting one per core keeps each accepted request
27/// near its unloaded latency and turns the excess into an immediate `503`, which says retry
28/// rather than too large and says it before the CPU is spent.
29///
30/// A cache hit is answered before a transform slot is taken, so this limit does not apply to
31/// traffic the cache can serve.
32pub(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/// Log verbosity level for the server.
55///
56/// Levels are ordered from least verbose (`Error`) to most verbose (`Debug`).
57/// A message is emitted only when its level is less than or equal to the
58/// currently active level.
59///
60/// Configurable at startup via `TRUSS_LOG_LEVEL` (default: `info`) and
61/// switchable at runtime via `SIGUSR1` (Unix only), which cycles through
62/// `info → debug → error → warn → info`.
63#[repr(u8)]
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
65#[non_exhaustive]
66pub enum LogLevel {
67    /// Errors that indicate a failed operation.
68    Error = 0,
69    /// Warnings about potentially harmful situations.
70    Warn = 1,
71    /// Informational messages about normal operations.
72    Info = 2,
73    /// Detailed diagnostic messages for debugging.
74    Debug = 3,
75}
76
77impl LogLevel {
78    /// Returns the next level in the SIGUSR1 cycle:
79    /// `Info → Debug → Error → Warn → Info`.
80    ///
81    /// `SIGUSR1` is a Unix signal, so nothing on Windows reaches this.
82    #[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    /// Converts a `u8` to a `LogLevel`, defaulting to `Info` for unknown values.
93    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    /// Returns the lowercase name of this level.
104    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/// A trusted proxy specification: either a single IP address or a CIDR block.
137///
138/// Used with `TRUSS_TRUSTED_PROXIES` to identify reverse proxies whose
139/// `X-Forwarded-For` / `X-Real-IP` headers should be trusted when recovering
140/// the client IP the rate limiter buckets by.
141#[non_exhaustive]
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum TrustedProxy {
144    /// An exact IP address (e.g. `10.0.0.1`).
145    Addr(IpAddr),
146    /// A CIDR block (e.g. `10.0.0.0/8`).  Stores the network address and
147    /// prefix length.
148    Cidr(IpAddr, u8),
149}
150
151impl TrustedProxy {
152    /// Parses a string as either `"<ip>"` or `"<ip>/<prefix>"`.
153    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    /// Returns `true` if `ip` matches this proxy specification.
183    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, // v4 CIDR vs v6 addr (or vice versa) never matches.
204                }
205            }
206        }
207    }
208}
209
210/// Returns `true` if `ip` matches any entry in the trusted-proxy list.
211pub(super) fn is_trusted_proxy(trusted: &[TrustedProxy], ip: IpAddr) -> bool {
212    trusted.iter().any(|t| t.contains(ip))
213}
214
215/// Feature-flag-independent label for the active storage backend, used only
216/// by the metrics subsystem to tag duration histograms.
217///
218/// Some variants are only constructed when optional storage backends are enabled.
219#[derive(Debug, Clone, Copy)]
220#[allow(dead_code)]
221pub(super) enum StorageBackendLabel {
222    Filesystem,
223    S3,
224    Gcs,
225    Azure,
226}
227
228/// The storage backend that determines how `Path`-based public GET requests are
229/// resolved.
230#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232#[non_exhaustive]
233pub enum StorageBackend {
234    /// Source images live on the local filesystem under `storage_root`.
235    Filesystem,
236    /// Source images live in an S3-compatible bucket.
237    #[cfg(feature = "s3")]
238    S3,
239    /// Source images live in a Google Cloud Storage bucket.
240    #[cfg(feature = "gcs")]
241    Gcs,
242    /// Source images live in an Azure Blob Storage container.
243    #[cfg(feature = "azure")]
244    Azure,
245}
246
247#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
248impl StorageBackend {
249    /// Parses the `TRUSS_STORAGE_BACKEND` environment variable value.
250    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
292/// The default bind address for the development HTTP server.
293pub(crate) const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
294
295/// The default storage root used by the server adapter.
296pub(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
301/// Default drain period (in seconds) during graceful shutdown.
302/// Configurable at runtime via `TRUSS_SHUTDOWN_DRAIN_SECS`.
303pub(super) const DEFAULT_SHUTDOWN_DRAIN_SECS: u64 = 10;
304
305/// Default wall-clock deadline (in seconds) for server-side transforms.
306/// Configurable at runtime via `TRUSS_TRANSFORM_DEADLINE_SECS`.
307pub(super) const DEFAULT_TRANSFORM_DEADLINE_SECS: u64 = 30;
308
309/// Default maximum number of input pixels allowed before decode.
310/// Configurable at runtime via `TRUSS_MAX_INPUT_PIXELS`.
311pub(super) const DEFAULT_MAX_INPUT_PIXELS: u64 = 40_000_000;
312
313/// Default maximum number of requests served over a single keep-alive
314/// connection before the server closes it.
315/// Configurable at runtime via `TRUSS_KEEP_ALIVE_MAX_REQUESTS`.
316pub(super) const DEFAULT_KEEP_ALIVE_MAX_REQUESTS: u64 = 100;
317
318use super::http_parse::DEFAULT_MAX_UPLOAD_BODY_BYTES;
319
320/// Runtime configuration for the HTTP server adapter.
321///
322/// The HTTP adapter keeps environment-specific concerns, such as the storage root and
323/// authentication secret, outside the Core transformation API. Tests and embedding runtimes
324/// can construct this value directly, while the CLI entry point typically uses
325/// [`ServerConfig::from_env`] to load the same fields from process environment variables.
326/// A logging callback invoked by the server for diagnostic messages.
327///
328/// Adapters that embed the server can supply a custom handler to route
329/// messages to their preferred logging infrastructure instead of stderr.
330pub type LogHandler = Arc<dyn Fn(&str) + Send + Sync>;
331
332#[non_exhaustive]
333pub struct ServerConfig {
334    /// The storage root used for `source.kind=path` lookups.
335    pub storage_root: PathBuf,
336    /// The expected Bearer token for private endpoints.
337    pub bearer_token: Option<String>,
338    /// The externally visible base URL used for public signed-URL authority.
339    ///
340    /// When this value is set, public signed GET requests use its authority component when
341    /// reconstructing the canonical signature payload. This is primarily useful when the server
342    /// runs behind a reverse proxy and the incoming `Host` header is not the externally visible
343    /// authority that clients sign.
344    pub public_base_url: Option<String>,
345    /// The expected key identifier for public signed GET requests.
346    ///
347    /// Deprecated in favor of `signing_keys`. Retained for backward compatibility:
348    /// when set alongside `signed_url_secret`, the pair is automatically inserted
349    /// into `signing_keys`.
350    pub signed_url_key_id: Option<String>,
351    /// The shared secret used to verify public signed GET requests.
352    ///
353    /// Deprecated in favor of `signing_keys`. See `signed_url_key_id`.
354    pub signed_url_secret: Option<String>,
355    /// Multiple signing keys for public signed GET requests (key rotation).
356    ///
357    /// Each entry maps a key identifier to its HMAC shared secret. During
358    /// verification the server looks up the `keyId` from the request in this
359    /// map and uses the corresponding secret for HMAC validation.
360    ///
361    /// Configurable via `TRUSS_SIGNING_KEYS` (JSON object `{"keyId":"secret", ...}`).
362    /// The legacy `TRUSS_SIGNED_URL_KEY_ID` / `TRUSS_SIGNED_URL_SECRET` pair is
363    /// merged into this map automatically.
364    pub signing_keys: HashMap<String, String>,
365    /// Whether server-side URL sources may bypass private-network and port restrictions.
366    ///
367    /// This flag is intended for local development and automated tests where fixture servers
368    /// commonly run on loopback addresses and non-standard ports. Production-like configurations
369    /// should keep this disabled.
370    pub allow_insecure_url_sources: bool,
371    /// Optional directory for the on-disk transform cache.
372    ///
373    /// When set, transformed image bytes are cached on disk using a sharded directory layout
374    /// (`ab/cd/ef/<sha256_hex>`). Repeated requests with the same source and transform options
375    /// are served from the cache instead of re-transforming. When `None`, caching is disabled
376    /// and every request performs a fresh transform.
377    pub cache_root: Option<PathBuf>,
378    /// Maximum total size (in bytes) of the on-disk transform cache.
379    ///
380    /// When set to a positive value, the cache performs LRU-style eviction after
381    /// each write: entries are sorted by modification time and the oldest are
382    /// removed until the total size drops below this limit.
383    ///
384    /// `0` (the default) means unlimited — no size-based eviction is performed.
385    /// Configurable via `TRUSS_CACHE_MAX_BYTES`.
386    pub cache_max_bytes: u64,
387    /// Unix timestamp of the last cache eviction scan, shared by every request.
388    ///
389    /// The scan walks the whole cache directory, so it is throttled to one per minute. It
390    /// lives here rather than on the cache because the cache value is built per request.
391    pub(crate) cache_eviction_secs: Arc<AtomicU64>,
392    /// `Cache-Control: max-age` value (in seconds) for public GET image responses.
393    ///
394    /// Defaults to `3600`. Operators can tune this
395    /// via the `TRUSS_PUBLIC_MAX_AGE` environment variable when running behind a CDN.
396    pub public_max_age_seconds: u32,
397    /// `Cache-Control: stale-while-revalidate` value (in seconds) for public GET image responses.
398    ///
399    /// Defaults to `60`. Configurable
400    /// via `TRUSS_PUBLIC_STALE_WHILE_REVALIDATE`.
401    pub public_stale_while_revalidate_seconds: u32,
402    /// Whether Accept-based content negotiation is disabled for public GET endpoints.
403    ///
404    /// When running behind a CDN such as CloudFront, Accept negotiation combined with
405    /// `Vary: Accept` can cause cache key mismatches or mis-served responses if the CDN
406    /// cache policy does not forward the `Accept` header.  Setting this flag to `true`
407    /// disables Accept negotiation entirely: public GET requests that omit the `format`
408    /// query parameter will preserve the input format instead of negotiating via Accept.
409    pub disable_accept_negotiation: bool,
410    /// Preferred output format order for content negotiation.
411    ///
412    /// When the client's Accept header allows multiple formats with equal quality
413    /// values, the server picks the first format from this list that the client
414    /// accepts. An empty list uses the built-in default order (AVIF, WebP, JPEG/PNG).
415    ///
416    /// Configurable via `TRUSS_FORMAT_PREFERENCE` (comma-separated list of format
417    /// names, e.g. `"avif,webp,png,jpeg"`).
418    pub format_preference: Vec<crate::MediaType>,
419    /// Optional logging callback for diagnostic messages.
420    ///
421    /// When set, the server routes all diagnostic messages (cache errors, connection
422    /// failures, transform warnings) through this handler. When `None`, messages are
423    /// written to stderr via `eprintln!`.
424    pub log_handler: Option<LogHandler>,
425    /// Current log verbosity level.
426    ///
427    /// Configurable at startup via `TRUSS_LOG_LEVEL` (default: `info`).
428    /// Can be changed at runtime via `SIGUSR1` (Unix only).
429    pub log_level: Arc<AtomicU8>,
430    /// Maximum number of concurrent image transforms.
431    ///
432    /// Configurable via `TRUSS_MAX_CONCURRENT_TRANSFORMS`. Defaults to one per core, since
433    /// a transform is CPU work from end to end and admitting more than the machine can run
434    /// makes every one of them slower without making the server faster.
435    pub max_concurrent_transforms: u64,
436    /// Per-transform wall-clock deadline in seconds.
437    ///
438    /// Read at pipeline stage boundaries, so a transform stops at the first boundary past
439    /// the deadline rather than at the deadline itself.
440    ///
441    /// Configurable via `TRUSS_TRANSFORM_DEADLINE_SECS`. Defaults to 30.
442    pub transform_deadline_secs: u64,
443    /// Maximum number of input pixels allowed before decode.
444    ///
445    /// Configurable via `TRUSS_MAX_INPUT_PIXELS`. Defaults to 40,000,000 (~40 MP).
446    /// Images exceeding this limit are rejected with 422 Unprocessable Entity.
447    pub max_input_pixels: u64,
448    /// Maximum upload body size in bytes.
449    ///
450    /// Configurable via `TRUSS_MAX_UPLOAD_BYTES`. Defaults to 100 MB.
451    /// Requests exceeding this limit are rejected with 413 Payload Too Large.
452    pub max_upload_bytes: usize,
453    /// Maximum number of requests served over a single keep-alive connection.
454    ///
455    /// Configurable via `TRUSS_KEEP_ALIVE_MAX_REQUESTS`. Defaults to 100.
456    pub keep_alive_max_requests: u64,
457    /// Bearer token for the `/metrics` endpoint.
458    ///
459    /// When set, the `/metrics` endpoint requires `Authorization: Bearer <token>`.
460    /// When absent, `/metrics` is accessible without authentication.
461    /// Configurable via `TRUSS_METRICS_TOKEN`.
462    pub metrics_token: Option<String>,
463    /// Whether the `/metrics` endpoint is disabled.
464    ///
465    /// Configurable via `TRUSS_DISABLE_METRICS`. When enabled, `/metrics` returns 404.
466    pub disable_metrics: bool,
467    /// Bearer token for the `/health` diagnostic endpoint.
468    ///
469    /// When set, `GET /health` requires `Authorization: Bearer <token>`.
470    /// The `/health/live` and `/health/ready` probe endpoints remain
471    /// unauthenticated. Configurable via `TRUSS_HEALTH_TOKEN`.
472    pub health_token: Option<String>,
473    /// Minimum free bytes on the cache disk before `/health/ready` reports failure.
474    ///
475    /// Configurable via `TRUSS_HEALTH_CACHE_MIN_FREE_BYTES`. When unset, the cache
476    /// disk free-space check is skipped.
477    pub health_cache_min_free_bytes: Option<u64>,
478    /// Maximum resident memory (RSS) in bytes before `/health/ready` reports failure.
479    ///
480    /// Configurable via `TRUSS_HEALTH_MAX_MEMORY_BYTES`. When unset, the memory
481    /// check is skipped. Only effective on Linux.
482    pub health_max_memory_bytes: Option<u64>,
483    /// Cached syscall results for health endpoints.
484    ///
485    /// The TTL is configurable via `TRUSS_HEALTH_CACHE_TTL_SECS`. Defaults to 5
486    /// seconds. Set to `0` to disable caching.
487    ///
488    /// Use [`ServerConfig::with_health_cache_ttl_secs`] to override the TTL
489    /// programmatically.
490    pub(crate) health_cache: Arc<super::handler::HealthCache>,
491    /// Drain period (in seconds) during graceful shutdown.
492    ///
493    /// On receiving a shutdown signal the server immediately marks itself as
494    /// draining (causing `/health/ready` to return 503), then waits this many
495    /// seconds before stopping acceptance of new connections so that load
496    /// balancers have time to remove the instance from rotation.
497    ///
498    /// Configurable via `TRUSS_SHUTDOWN_DRAIN_SECS`. Defaults to 10.
499    pub shutdown_drain_secs: u64,
500    /// Runtime flag indicating the server is draining.
501    ///
502    /// Set to `true` upon receiving SIGTERM/SIGINT. While draining,
503    /// `/health/ready` returns 503 so that load balancers stop routing traffic.
504    pub draining: Arc<AtomicBool>,
505    /// Custom response headers applied to all public image responses.
506    ///
507    /// Configurable via `TRUSS_RESPONSE_HEADERS` (JSON object `{"Header-Name": "value", ...}`).
508    /// Validated at startup; invalid header names or values cause a startup error.
509    pub custom_response_headers: Vec<(String, String)>,
510    /// Maximum size (in bytes) of a source image fetched from the filesystem or remote URL.
511    ///
512    /// Configurable via `TRUSS_MAX_SOURCE_BYTES`. Defaults to 100 MB.
513    pub max_source_bytes: u64,
514    /// Maximum size (in bytes) of a watermark image fetched from a remote URL.
515    ///
516    /// Configurable via `TRUSS_MAX_WATERMARK_BYTES`. Defaults to 10 MB.
517    pub max_watermark_bytes: u64,
518    /// Maximum number of HTTP redirects to follow when fetching a remote URL.
519    ///
520    /// Configurable via `TRUSS_MAX_REMOTE_REDIRECTS`. Defaults to 5.
521    pub max_remote_redirects: usize,
522    /// Whether gzip compression is enabled for non-image responses.
523    ///
524    /// Configurable via `TRUSS_DISABLE_COMPRESSION`. Defaults to `true`.
525    pub enable_compression: bool,
526    /// Gzip compression level (0-9). Higher values produce smaller output but
527    /// use more CPU. `1` is fastest, `6` is the default (a good trade-off),
528    /// and `9` is best compression.
529    ///
530    /// Configurable via `TRUSS_COMPRESSION_LEVEL`. Defaults to `1` (fast).
531    pub compression_level: u32,
532    /// Per-server counter tracking the number of image transforms currently in
533    /// flight.  This is runtime state (not configuration) but lives here so that
534    /// each `serve_with_config` invocation gets an independent counter, avoiding
535    /// cross-server interference when multiple listeners run in the same process
536    /// or during tests.
537    pub transforms_in_flight: Arc<AtomicU64>,
538    /// Named transform presets that can be referenced by name on public endpoints.
539    ///
540    /// Configurable via `TRUSS_PRESETS` (inline JSON) or `TRUSS_PRESETS_FILE` (path to JSON file).
541    /// Each key is a preset name and the value is a set of transform options.
542    /// Wrapped in `Arc<RwLock<...>>` to support hot-reload from `TRUSS_PRESETS_FILE`.
543    pub presets: Arc<std::sync::RwLock<HashMap<String, TransformOptionsPayload>>>,
544    /// Path to the presets JSON file, if configured via `TRUSS_PRESETS_FILE`.
545    ///
546    /// When set, a background thread watches this file for changes and reloads
547    /// presets atomically. When `None` (inline `TRUSS_PRESETS` or no presets),
548    /// hot-reload is disabled.
549    pub presets_file_path: Option<PathBuf>,
550    /// Optional per-IP rate limiter.
551    ///
552    /// When `TRUSS_RATE_LIMIT_RPS` is set to a positive value, each client IP
553    /// is limited to that many requests per second using a token-bucket algorithm.
554    /// Burst size defaults to the RPS value but can be overridden via
555    /// `TRUSS_RATE_LIMIT_BURST`.  Disabled (no limiting) when unset or zero.
556    pub rate_limiter: Option<Arc<super::rate_limit::RateLimiter>>,
557    /// Trusted reverse-proxy addresses or CIDR blocks.
558    ///
559    /// When a connection originates from one of these addresses, the server
560    /// extracts the real client IP from `X-Forwarded-For` (rightmost
561    /// non-trusted entry) or `X-Real-IP` instead of using the TCP peer
562    /// address.  Configurable via `TRUSS_TRUSTED_PROXIES` (comma-separated).
563    pub trusted_proxies: Vec<TrustedProxy>,
564    /// Download timeout in seconds for object storage backends (S3, GCS, Azure).
565    ///
566    /// Configurable via `TRUSS_STORAGE_TIMEOUT_SECS`. Defaults to 30.
567    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
568    pub storage_timeout_secs: u64,
569    /// The storage backend used to resolve `Path`-based public GET requests.
570    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
571    pub storage_backend: StorageBackend,
572    /// Shared S3 client context, present when `storage_backend` is `S3`.
573    #[cfg(feature = "s3")]
574    pub s3_context: Option<Arc<s3::S3Context>>,
575    /// Shared GCS client context, present when `storage_backend` is `Gcs`.
576    #[cfg(feature = "gcs")]
577    pub gcs_context: Option<Arc<gcs::GcsContext>>,
578    /// Shared Azure Blob Storage client context, present when `storage_backend` is `Azure`.
579    #[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    /// The public `Cache-Control` directives as one value, so a caller that builds image
833    /// response headers passes the pair rather than the two fields separately.
834    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    /// Creates a server configuration from explicit values.
842    ///
843    /// This constructor does not canonicalize the storage root. It is primarily intended for
844    /// tests and embedding scenarios where the caller already controls the filesystem layout.
845    ///
846    /// # Examples
847    ///
848    /// ```
849    /// use truss::ServerConfig;
850    ///
851    /// let config = ServerConfig::new(std::env::temp_dir(), Some("secret".to_string()));
852    ///
853    /// assert_eq!(config.bearer_token.as_deref(), Some("secret"));
854    /// ```
855    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    /// Overrides the health-check syscall cache TTL.
914    ///
915    /// This builder-style method allows embedders to configure the TTL
916    /// programmatically without relying on environment variables.
917    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    /// Returns the current log level.
944    pub(super) fn current_log_level(&self) -> LogLevel {
945        LogLevel::from_u8(self.log_level.load(Ordering::Relaxed))
946    }
947
948    /// Emits a diagnostic message if the given `level` is at or below the
949    /// currently active log level.
950    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    /// Emits a diagnostic message through the configured log handler, or falls
962    /// back to stderr when no handler is set. Messages are emitted at
963    /// [`LogLevel::Info`].
964    pub(super) fn log(&self, msg: &str) {
965        self.log_at(LogLevel::Info, msg);
966    }
967
968    /// Emits a warning-level diagnostic message.
969    pub(super) fn log_warn(&self, msg: &str) {
970        self.log_at(LogLevel::Warn, msg);
971    }
972
973    /// Returns a copy of the configuration with signed-URL verification credentials attached.
974    ///
975    /// Public GET endpoints require both a key identifier and a shared secret. Tests and local
976    /// development setups can use this helper to attach those values directly without going
977    /// through environment variables.
978    ///
979    /// # Examples
980    ///
981    /// ```
982    /// use truss::ServerConfig;
983    ///
984    /// let config = ServerConfig::new(std::env::temp_dir(), None)
985    ///     .with_signed_url_credentials("public-dev", "top-secret");
986    ///
987    /// assert_eq!(config.signed_url_key_id.as_deref(), Some("public-dev"));
988    /// assert_eq!(config.signed_url_secret.as_deref(), Some("top-secret"));
989    /// ```
990    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    /// Returns a copy of the configuration with multiple signing keys attached.
1004    ///
1005    /// Each entry maps a key identifier to its HMAC shared secret. During key
1006    /// rotation both old and new keys can be active simultaneously, allowing a
1007    /// graceful cutover.
1008    pub fn with_signing_keys(mut self, keys: HashMap<String, String>) -> Self {
1009        self.signing_keys.extend(keys);
1010        self
1011    }
1012
1013    /// Returns a copy of the configuration with insecure URL source allowances toggled.
1014    ///
1015    /// Enabling this flag allows URL sources that target loopback or private-network addresses
1016    /// and permits non-standard ports. This is useful for local integration tests but weakens
1017    /// the default SSRF protections of the server adapter.
1018    ///
1019    /// # Examples
1020    ///
1021    /// ```
1022    /// use truss::ServerConfig;
1023    ///
1024    /// let config = ServerConfig::new(std::env::temp_dir(), Some("secret".to_string()))
1025    ///     .with_insecure_url_sources(true);
1026    ///
1027    /// assert!(config.allow_insecure_url_sources);
1028    /// ```
1029    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    /// Returns a copy of the configuration with a transform cache directory set.
1035    ///
1036    /// When a cache root is configured, the server stores transformed images on disk using a
1037    /// sharded directory layout and serves subsequent identical requests from the cache.
1038    ///
1039    /// # Examples
1040    ///
1041    /// ```
1042    /// use truss::ServerConfig;
1043    ///
1044    /// let config = ServerConfig::new(std::env::temp_dir(), None)
1045    ///     .with_cache_root(std::env::temp_dir().join("truss-cache"));
1046    ///
1047    /// assert!(config.cache_root.is_some());
1048    /// ```
1049    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    /// Returns a copy of the configuration with a maximum cache size set.
1055    ///
1056    /// When `max_bytes` is positive, the cache performs LRU-style eviction after
1057    /// each write to keep the total on-disk size under this limit. `0` disables
1058    /// size-based eviction.
1059    ///
1060    /// # Examples
1061    ///
1062    /// ```
1063    /// use truss::ServerConfig;
1064    ///
1065    /// let config = ServerConfig::new(std::env::temp_dir(), None)
1066    ///     .with_cache_max_bytes(500 * 1024 * 1024); // 500 MB
1067    ///
1068    /// assert_eq!(config.cache_max_bytes, 500 * 1024 * 1024);
1069    /// ```
1070    pub fn with_cache_max_bytes(mut self, max_bytes: u64) -> Self {
1071        self.cache_max_bytes = max_bytes;
1072        self
1073    }
1074
1075    /// Returns a copy of the configuration with an S3 storage backend attached.
1076    #[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    /// Returns a copy of the configuration with a GCS storage backend attached.
1084    #[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    /// Returns a copy of the configuration with an Azure Blob Storage backend attached.
1092    #[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    /// Returns a copy of the configuration with named transform presets attached.
1100    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    /// Loads server configuration from environment variables.
1106    ///
1107    /// The adapter currently reads:
1108    ///
1109    /// - `TRUSS_STORAGE_ROOT`: filesystem root for `source.kind=path` inputs. Defaults to the
1110    ///   current directory and is canonicalized before use.
1111    /// - `TRUSS_BEARER_TOKEN`: private API Bearer token. When this value is missing, private
1112    ///   endpoints remain unavailable and return `503 Service Unavailable`.
1113    /// - `TRUSS_PUBLIC_BASE_URL`: externally visible base URL for public signed URL verification.
1114    ///   When set, it must parse as an absolute `http` or `https` URL.
1115    /// - `TRUSS_SIGNED_URL_KEY_ID`: key identifier accepted by public signed GET endpoints.
1116    /// - `TRUSS_SIGNED_URL_SECRET`: shared secret used to verify public signed GET signatures.
1117    /// - `TRUSS_ALLOW_INSECURE_URL_SOURCES`: when set to `1`, `true`, `yes`, or `on`, URL
1118    ///   sources may target loopback or private-network addresses and non-standard ports.
1119    /// - `TRUSS_CACHE_ROOT`: directory for the on-disk transform cache. When set, transformed
1120    ///   images are cached using a sharded `ab/cd/ef/<sha256>` layout. When absent, caching is
1121    ///   disabled.
1122    /// - `TRUSS_PUBLIC_MAX_AGE`: `Cache-Control: max-age` value (in seconds) for public GET
1123    ///   image responses. Defaults to 3600.
1124    /// - `TRUSS_PUBLIC_STALE_WHILE_REVALIDATE`: `Cache-Control: stale-while-revalidate` value
1125    ///   (in seconds) for public GET image responses. Defaults to 60.
1126    /// - `TRUSS_DISABLE_ACCEPT_NEGOTIATION`: when set to `1`, `true`, `yes`, or `on`, disables
1127    ///   Accept-based content negotiation on public GET endpoints. This is recommended when running
1128    ///   behind a CDN that does not forward the `Accept` header in its cache key.
1129    /// - `TRUSS_STORAGE_BACKEND` *(requires the `s3`, `gcs`, or `azure` feature)*: storage backend
1130    ///   for resolving `Path`-based public GET requests. Accepts `filesystem` (default), `s3`,
1131    ///   `gcs`, or `azure`.
1132    /// - `TRUSS_S3_BUCKET` *(requires the `s3` feature)*: default S3 bucket name. Required when
1133    ///   the storage backend is `s3`.
1134    /// - `TRUSS_S3_FORCE_PATH_STYLE` *(requires the `s3` feature)*: when set to `1`, `true`,
1135    ///   `yes`, or `on`, use path-style S3 addressing (`http://endpoint/bucket/key`) instead
1136    ///   of virtual-hosted-style. Required for S3-compatible services such as MinIO and
1137    ///   adobe/s3mock.
1138    /// - `TRUSS_GCS_BUCKET` *(requires the `gcs` feature)*: default GCS bucket name. Required
1139    ///   when the storage backend is `gcs`.
1140    /// - `TRUSS_GCS_ENDPOINT` *(requires the `gcs` feature)*: custom GCS endpoint URL. Used for
1141    ///   emulators such as `fake-gcs-server`. When absent, the default Google Cloud Storage
1142    ///   endpoint is used.
1143    /// - `GOOGLE_APPLICATION_CREDENTIALS`: path to a GCS service account JSON key file.
1144    /// - `GOOGLE_APPLICATION_CREDENTIALS_JSON`: inline GCS service account JSON (alternative to
1145    ///   file path).
1146    /// - `TRUSS_AZURE_CONTAINER` *(requires the `azure` feature)*: default Azure Blob Storage
1147    ///   container name. Required when the storage backend is `azure`.
1148    /// - `TRUSS_AZURE_ENDPOINT` *(requires the `azure` feature)*: custom Azure Blob Storage
1149    ///   endpoint URL. Used for emulators such as Azurite. When absent, the endpoint is derived
1150    ///   from `AZURE_STORAGE_ACCOUNT_NAME`.
1151    /// - `AZURE_STORAGE_ACCOUNT_NAME`: Azure storage account name (used to derive the default
1152    ///   endpoint when `TRUSS_AZURE_ENDPOINT` is not set).
1153    /// - `TRUSS_MAX_CONCURRENT_TRANSFORMS`: maximum number of concurrent image transforms
1154    ///   (default: one per core, range: 1–1024). Requests exceeding this limit are rejected with 503.
1155    /// - `TRUSS_TRANSFORM_DEADLINE_SECS`: per-transform wall-clock deadline in seconds
1156    ///   (default: 30, range: 1–300). Transforms exceeding this deadline are cancelled.
1157    /// - `TRUSS_MAX_INPUT_PIXELS`: maximum number of input image pixels allowed before decode
1158    ///   (default: 40,000,000, range: 1–100,000,000). Images exceeding this limit are rejected
1159    ///   with 422 Unprocessable Entity.
1160    /// - `TRUSS_MAX_UPLOAD_BYTES`: maximum upload body size in bytes (default: 104,857,600 = 100 MB,
1161    ///   range: 1–10,737,418,240). Requests exceeding this limit are rejected with 413.
1162    /// - `TRUSS_METRICS_TOKEN`: Bearer token for the `/metrics` endpoint. When set, the endpoint
1163    ///   requires `Authorization: Bearer <token>`. When absent, no authentication is required.
1164    /// - `TRUSS_DISABLE_METRICS`: when set to `1`, `true`, `yes`, or `on`, disables the `/metrics`
1165    ///   endpoint entirely (returns 404).
1166    /// - `TRUSS_HEALTH_TOKEN`: Bearer token for the `/health` diagnostic endpoint. When set,
1167    ///   `GET /health` requires `Authorization: Bearer <token>`. The `/health/live` and
1168    ///   `/health/ready` probe endpoints remain unauthenticated.
1169    /// - `TRUSS_STORAGE_TIMEOUT_SECS`: download timeout for storage backends in seconds
1170    ///   (default: 30, range: 1–300).
1171    /// - `TRUSS_HEALTH_CACHE_MIN_FREE_BYTES`: minimum free bytes on the cache disk before
1172    ///   `/health/ready` reports failure. When unset, the disk free-space check is skipped.
1173    /// - `TRUSS_HEALTH_MAX_MEMORY_BYTES`: maximum resident memory (RSS) in bytes before
1174    ///   `/health/ready` reports failure. When unset, the memory check is skipped (Linux only).
1175    /// - `TRUSS_HEALTH_HYSTERESIS_MARGIN`: recovery margin for readiness probe hysteresis
1176    ///   (default: 0.05, range: 0.01–0.50). A 5 % margin means that after a threshold is
1177    ///   breached, the value must recover past `threshold ± 5 %` before the check returns to ok.
1178    /// - `TRUSS_HEALTH_CACHE_TTL_SECS`: TTL in seconds for cached syscall results
1179    ///   (`disk_free_bytes`, `process_rss_bytes`) used by health endpoints (default: 5,
1180    ///   range: 0–300). Set to `0` to disable caching and call syscalls on every request.
1181    ///
1182    /// # Errors
1183    ///
1184    /// Returns an [`io::Error`] when the configured storage root does not exist or cannot be
1185    /// canonicalized.
1186    ///
1187    /// # Examples
1188    ///
1189    /// ```no_run
1190    /// // SAFETY: This example runs single-threaded; no concurrent env access.
1191    /// unsafe {
1192    ///     std::env::set_var("TRUSS_STORAGE_ROOT", ".");
1193    ///     std::env::set_var("TRUSS_ALLOW_INSECURE_URL_SOURCES", "true");
1194    /// }
1195    ///
1196    /// let config = truss::ServerConfig::from_env().unwrap();
1197    ///
1198    /// assert!(config.storage_root.is_absolute());
1199    /// assert!(config.allow_insecure_url_sources);
1200    /// ```
1201    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        // Every other setting names itself when it is wrong. This one used to surface as
1215        // the bare OS message — "No such file or directory (os error 2)" on Linux, "The
1216        // system cannot find the path specified. (os error 3)" on Windows — leaving the
1217        // reader to guess which of the settings it belonged to.
1218        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        // Read for its side effect: the setting is consumed where the S3 client is
1442        // built, long after startup, so a typo in it would otherwise survive both
1443        // `truss validate` and the first requests.
1444        #[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
1540/// Parse an optional environment variable as `u64`, validating that its value
1541/// falls within `[min, max]`. Returns `Ok(None)` when the variable is unset or
1542/// empty, `Ok(Some(value))` on success, or an `io::Error` on parse / range
1543/// failure.
1544pub(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
1565/// Parse an optional environment variable as `f64`, validating that its value
1566/// falls within `[min, max]`. Returns `Ok(None)` when the variable is unset or
1567/// empty, `Ok(Some(value))` on success, or an `io::Error` on parse / range
1568/// failure.
1569fn 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
1590/// Parses `TRUSS_FORMAT_PREFERENCE` into an ordered list of [`MediaType`] values.
1591///
1592/// The environment variable is a comma-separated list of format short names
1593/// (e.g. `"avif,webp,png,jpeg"`). Unrecognised names cause a startup error.
1594/// Returns an empty `Vec` when the variable is unset or empty, which tells the
1595/// negotiation layer to use its built-in default order.
1596pub(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            // The preference list drives Accept negotiation, so every entry has to be a
1618            // format the server can actually produce.
1619            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
1635/// Reads a boolean setting, refusing a value that is neither true nor false.
1636///
1637/// Every other setting is checked — a log level that is not one of the four, a rate limit
1638/// past its range, a format preference naming no format — and a boolean was the exception:
1639/// anything unrecognised meant `false`, silently. Three of the five variables that reach
1640/// here name something to disable, so falling back to `false` left the thing the operator
1641/// asked to switch off switched on, with nothing printed at startup and `truss validate`
1642/// reporting the configuration as valid.
1643///
1644/// The comparison is case-insensitive, which is what `docs/configuration.md` has always
1645/// said it was: `True` is what Python's `str(True)` produces and what several YAML emitters
1646/// write, and there is nothing to gain by refusing it. Surrounding whitespace is not
1647/// trimmed, so `' 1'` is refused rather than quietly accepted; a quoting mistake that
1648/// changes the value is worth seeing.
1649pub(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
1677/// Parses presets from environment variables, returning both the preset map
1678/// and the file path (if loaded from `TRUSS_PRESETS_FILE`).
1679pub(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
1712/// Refuses a preset that names another preset.
1713///
1714/// `preset` is a field of the transform options every route takes, so it is also a field of
1715/// the object a preset is written as. A preset naming one turns resolution into a graph with
1716/// cycles and a depth, which is a question worth not having: the field is refused where
1717/// presets are defined, and resolution stays one merge.
1718fn 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
1733/// Parses a preset JSON file at the given path. Used by the hot-reload watcher.
1734pub(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
1749/// Parse `TRUSS_RESPONSE_HEADERS` (a JSON object `{"Header-Name": "value", ...}`) and
1750/// validate that every name and value conforms to RFC 7230. Returns an empty vec when the
1751/// variable is unset or empty.
1752fn 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    // Sort for deterministic ordering in responses.
1775    headers.sort_by(|a, b| a.0.cmp(&b.0));
1776    Ok(headers)
1777}
1778
1779/// Validate an HTTP header name per RFC 7230 §3.2.6 (token characters).
1780fn 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    // token = 1*tchar
1788    // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
1789    //         "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
1790    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
1819/// Validate an HTTP header value per RFC 7230 §3.2.6 (visible ASCII + SP + HTAB).
1820fn 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
1833/// Reject HTTP framing and hop-by-hop headers that must not be overridden by
1834/// operator configuration. Allowing these would risk HTTP response smuggling,
1835/// MIME-sniffing attacks, or broken connection handling.
1836fn 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    /// RAII guard that sets an environment variable on creation and removes it on drop.
1885    struct ScopedEnv {
1886        key: &'static str,
1887    }
1888
1889    impl ScopedEnv {
1890        fn set(key: &'static str, value: &str) -> Self {
1891            // SAFETY: tests using ScopedEnv are annotated with #[serial].
1892            unsafe { env::set_var(key, value) };
1893            Self { key }
1894        }
1895
1896        fn remove(key: &'static str) -> Self {
1897            // SAFETY: tests using ScopedEnv are annotated with #[serial].
1898            unsafe { env::remove_var(key) };
1899            Self { key }
1900        }
1901    }
1902
1903    impl Drop for ScopedEnv {
1904        fn drop(&mut self) {
1905            // SAFETY: same as set — #[serial] guarantees no concurrent access.
1906            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    // ── shutdown_drain_secs ────────────────────────────────────────
1972
1973    #[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    // ── presets ────────────────────────────────────────────────────
2002
2003    #[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    // ── custom_response_headers ────────────────────────────────────
2120
2121    #[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        // Sorted by name.
2138        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    // ── enable_compression ─────────────────────────────────────────
2205
2206    #[test]
2207    fn compression_enabled_by_default() {
2208        let config = ServerConfig::new(PathBuf::from("."), None);
2209        assert!(config.enable_compression);
2210    }
2211
2212    // ── log_level ─────────────────────────────────────────────────────
2213
2214    #[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        // Unknown values default to Info.
2256        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        // Set level to Warn — only Error and Warn should pass through.
2288        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    // ── parse_format_preference_from_env ────────────────────────────────
2302
2303    #[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        // `gif` parses as a media type but the server cannot encode it, so it has no
2372        // business in a list that decides what Accept negotiation may return.
2373        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    // --- TrustedProxy tests ---
2401
2402    #[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    /// A boolean setting is read case-insensitively and refuses anything that is neither
2543    /// true nor false. The mixed-case rows are what used to mean `false` silently, and the
2544    /// refusals are what used to be accepted as `false` with nothing said.
2545    #[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    /// Each boolean the server reads surfaces the refusal rather than swallowing it, which
2597    /// is what makes a typo visible at startup and to `truss validate`.
2598    #[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    /// The storage root's default is the working directory, which is the one setting whose
2618    /// wrong value is a disclosure rather than an error, so the table an operator reads has
2619    /// to say so.
2620    #[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    /// Every `TRUSS_*` name in the modules that read the environment has to appear
2638    /// in the reference an operator actually reads. Rustdoc on the corresponding
2639    /// field is not a substitute, because someone deploying the container reads the
2640    /// docs, not the crate.
2641    #[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                // `TRUSS_` on its own is a prefix fragment in a format string, and the
2659                // name has to be a whole word in the reference, not part of a longer one.
2660                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}