Skip to main content

truss/adapters/server/
mod.rs

1mod auth;
2#[cfg(feature = "azure")]
3pub mod azure;
4mod cache;
5#[cfg(feature = "gcs")]
6pub mod gcs;
7mod http_parse;
8mod metrics;
9mod multipart;
10mod negotiate;
11mod remote;
12mod response;
13#[cfg(feature = "s3")]
14pub mod s3;
15
16use auth::{
17    authorize_request, authorize_request_headers, authorize_signed_request,
18    canonical_query_without_signature, extend_transform_query, parse_optional_bool_query,
19    parse_optional_float_query, parse_optional_integer_query, parse_optional_u8_query,
20    parse_query_params, required_query_param, signed_source_query, url_authority,
21    validate_public_query_names,
22};
23use cache::{CacheLookup, TransformCache, compute_cache_key, try_versioned_cache_lookup};
24use http_parse::{
25    HttpRequest, parse_named, parse_optional_named, read_request_body, read_request_headers,
26    request_has_json_content_type,
27};
28use metrics::{
29    CACHE_HITS_TOTAL, CACHE_MISSES_TOTAL, DEFAULT_MAX_CONCURRENT_TRANSFORMS, RouteMetric,
30    record_http_metrics, record_http_request_duration, record_storage_duration,
31    record_transform_duration, record_transform_error, render_metrics_text, status_code,
32    storage_backend_index_from_config, uptime_seconds,
33};
34use multipart::{parse_multipart_boundary, parse_upload_request};
35use negotiate::{
36    CacheHitStatus, ImageResponsePolicy, PublicSourceKind, build_image_etag,
37    build_image_response_headers, if_none_match_matches, negotiate_output_format,
38};
39use remote::resolve_source_bytes;
40use response::{
41    HttpResponse, NOT_FOUND_BODY, bad_request_response, service_unavailable_response,
42    transform_error_response, unsupported_media_type_response, write_response,
43};
44
45use crate::{
46    Fit, MediaType, Position, RawArtifact, Rgba8, Rotation, TransformOptions, TransformRequest,
47    sniff_artifact, transform_raster, transform_svg,
48};
49use hmac::{Hmac, Mac};
50use serde::Deserialize;
51use serde_json::json;
52use sha2::{Digest, Sha256};
53use std::collections::BTreeMap;
54use std::env;
55use std::fmt;
56use std::io;
57use std::net::{TcpListener, TcpStream};
58use std::path::PathBuf;
59use std::str::FromStr;
60use std::sync::Arc;
61use std::sync::atomic::{AtomicU64, Ordering};
62use std::time::{Duration, Instant};
63use url::Url;
64use uuid::Uuid;
65
66/// Writes a line to stderr using a raw file-descriptor/handle write, bypassing
67/// Rust's `std::io::Stderr` type whose internal `ReentrantLock` can interfere
68/// with `MutexGuard` drop ordering in Rust 2024 edition, breaking HTTP
69/// keep-alive.
70fn stderr_write(msg: &str) {
71    use std::io::Write;
72
73    let bytes = msg.as_bytes();
74    let mut buf = Vec::with_capacity(bytes.len() + 1);
75    buf.extend_from_slice(bytes);
76    buf.push(b'\n');
77
78    #[cfg(unix)]
79    {
80        use std::os::fd::FromRawFd;
81        // SAFETY: fd 2 (stderr) is always valid for the lifetime of the process.
82        let mut f = unsafe { std::fs::File::from_raw_fd(2) };
83        let _ = f.write_all(&buf);
84        // Do not drop `f` — that would close fd 2 (stderr).
85        std::mem::forget(f);
86    }
87
88    #[cfg(windows)]
89    {
90        use std::os::windows::io::FromRawHandle;
91
92        unsafe extern "system" {
93            fn GetStdHandle(nStdHandle: u32) -> *mut std::ffi::c_void;
94        }
95
96        const STD_ERROR_HANDLE: u32 = (-12_i32) as u32;
97        // SAFETY: GetStdHandle(STD_ERROR_HANDLE) returns the stderr handle
98        // which is always valid for the lifetime of the process.
99        let handle = unsafe { GetStdHandle(STD_ERROR_HANDLE) };
100        let mut f = unsafe { std::fs::File::from_raw_handle(handle) };
101        let _ = f.write_all(&buf);
102        // Do not drop `f` — that would close the stderr handle.
103        std::mem::forget(f);
104    }
105}
106
107/// Feature-flag-independent label for the active storage backend, used only
108/// by the metrics subsystem to tag duration histograms.
109#[derive(Debug, Clone, Copy)]
110#[allow(dead_code)]
111pub(super) enum StorageBackendLabel {
112    Filesystem,
113    S3,
114    Gcs,
115    Azure,
116}
117
118/// The storage backend that determines how `Path`-based public GET requests are
119/// resolved.
120#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum StorageBackend {
123    /// Source images live on the local filesystem under `storage_root`.
124    Filesystem,
125    /// Source images live in an S3-compatible bucket.
126    #[cfg(feature = "s3")]
127    S3,
128    /// Source images live in a Google Cloud Storage bucket.
129    #[cfg(feature = "gcs")]
130    Gcs,
131    /// Source images live in an Azure Blob Storage container.
132    #[cfg(feature = "azure")]
133    Azure,
134}
135
136#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
137impl StorageBackend {
138    /// Parses the `TRUSS_STORAGE_BACKEND` environment variable value.
139    pub fn parse(value: &str) -> Result<Self, String> {
140        match value.to_ascii_lowercase().as_str() {
141            "filesystem" | "fs" | "local" => Ok(Self::Filesystem),
142            #[cfg(feature = "s3")]
143            "s3" => Ok(Self::S3),
144            #[cfg(feature = "gcs")]
145            "gcs" => Ok(Self::Gcs),
146            #[cfg(feature = "azure")]
147            "azure" => Ok(Self::Azure),
148            _ => {
149                let mut expected = vec!["filesystem"];
150                #[cfg(feature = "s3")]
151                expected.push("s3");
152                #[cfg(feature = "gcs")]
153                expected.push("gcs");
154                #[cfg(feature = "azure")]
155                expected.push("azure");
156
157                #[allow(unused_mut)]
158                let mut hint = String::new();
159                #[cfg(not(feature = "s3"))]
160                if value.eq_ignore_ascii_case("s3") {
161                    hint = " (hint: rebuild with --features s3)".to_string();
162                }
163                #[cfg(not(feature = "gcs"))]
164                if value.eq_ignore_ascii_case("gcs") {
165                    hint = " (hint: rebuild with --features gcs)".to_string();
166                }
167                #[cfg(not(feature = "azure"))]
168                if value.eq_ignore_ascii_case("azure") {
169                    hint = " (hint: rebuild with --features azure)".to_string();
170                }
171
172                Err(format!(
173                    "unknown storage backend `{value}` (expected {}){hint}",
174                    expected.join(" or ")
175                ))
176            }
177        }
178    }
179}
180
181/// The default bind address for the development HTTP server.
182pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
183
184/// The default storage root used by the server adapter.
185pub const DEFAULT_STORAGE_ROOT: &str = ".";
186
187const DEFAULT_PUBLIC_MAX_AGE_SECONDS: u32 = 3600;
188const DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS: u32 = 60;
189const SOCKET_READ_TIMEOUT: Duration = Duration::from_secs(60);
190const SOCKET_WRITE_TIMEOUT: Duration = Duration::from_secs(60);
191/// Number of worker threads for handling incoming connections concurrently.
192const WORKER_THREADS: usize = 8;
193type HmacSha256 = Hmac<Sha256>;
194
195/// Maximum number of requests served over a single keep-alive connection before
196/// the server closes it.  This prevents a single client from monopolising a
197/// worker thread indefinitely.
198const KEEP_ALIVE_MAX_REQUESTS: usize = 100;
199
200/// Default wall-clock deadline (in seconds) for server-side transforms.
201/// Configurable at runtime via `TRUSS_TRANSFORM_DEADLINE_SECS`.
202const DEFAULT_TRANSFORM_DEADLINE_SECS: u64 = 30;
203
204#[derive(Clone, Copy)]
205struct PublicCacheControl {
206    max_age: u32,
207    stale_while_revalidate: u32,
208}
209
210#[derive(Clone, Copy)]
211struct ImageResponseConfig {
212    disable_accept_negotiation: bool,
213    public_cache_control: PublicCacheControl,
214    transform_deadline: Duration,
215}
216
217/// RAII guard that holds a concurrency slot for an in-flight image transform.
218///
219/// The counter is incremented on successful acquisition and decremented when
220/// the guard is dropped, ensuring the slot is always released even if the
221/// caller returns early or panics.
222struct TransformSlot {
223    counter: Arc<AtomicU64>,
224}
225
226impl TransformSlot {
227    fn try_acquire(counter: &Arc<AtomicU64>, limit: u64) -> Option<Self> {
228        let prev = counter.fetch_add(1, Ordering::Relaxed);
229        if prev >= limit {
230            counter.fetch_sub(1, Ordering::Relaxed);
231            None
232        } else {
233            Some(Self {
234                counter: Arc::clone(counter),
235            })
236        }
237    }
238}
239
240impl Drop for TransformSlot {
241    fn drop(&mut self) {
242        self.counter.fetch_sub(1, Ordering::Relaxed);
243    }
244}
245
246/// Runtime configuration for the HTTP server adapter.
247///
248/// The HTTP adapter keeps environment-specific concerns, such as the storage root and
249/// authentication secret, outside the Core transformation API. Tests and embedding runtimes
250/// can construct this value directly, while the CLI entry point typically uses
251/// [`ServerConfig::from_env`] to load the same fields from process environment variables.
252/// A logging callback invoked by the server for diagnostic messages.
253///
254/// Adapters that embed the server can supply a custom handler to route
255/// messages to their preferred logging infrastructure instead of stderr.
256pub type LogHandler = Arc<dyn Fn(&str) + Send + Sync>;
257
258pub struct ServerConfig {
259    /// The storage root used for `source.kind=path` lookups.
260    pub storage_root: PathBuf,
261    /// The expected Bearer token for private endpoints.
262    pub bearer_token: Option<String>,
263    /// The externally visible base URL used for public signed-URL authority.
264    ///
265    /// When this value is set, public signed GET requests use its authority component when
266    /// reconstructing the canonical signature payload. This is primarily useful when the server
267    /// runs behind a reverse proxy and the incoming `Host` header is not the externally visible
268    /// authority that clients sign.
269    pub public_base_url: Option<String>,
270    /// The expected key identifier for public signed GET requests.
271    pub signed_url_key_id: Option<String>,
272    /// The shared secret used to verify public signed GET requests.
273    pub signed_url_secret: Option<String>,
274    /// Whether server-side URL sources may bypass private-network and port restrictions.
275    ///
276    /// This flag is intended for local development and automated tests where fixture servers
277    /// commonly run on loopback addresses and non-standard ports. Production-like configurations
278    /// should keep this disabled.
279    pub allow_insecure_url_sources: bool,
280    /// Optional directory for the on-disk transform cache.
281    ///
282    /// When set, transformed image bytes are cached on disk using a sharded directory layout
283    /// (`ab/cd/ef/<sha256_hex>`). Repeated requests with the same source and transform options
284    /// are served from the cache instead of re-transforming. When `None`, caching is disabled
285    /// and every request performs a fresh transform.
286    pub cache_root: Option<PathBuf>,
287    /// `Cache-Control: max-age` value (in seconds) for public GET image responses.
288    ///
289    /// Defaults to `3600`. Operators can tune this
290    /// via the `TRUSS_PUBLIC_MAX_AGE` environment variable when running behind a CDN.
291    pub public_max_age_seconds: u32,
292    /// `Cache-Control: stale-while-revalidate` value (in seconds) for public GET image responses.
293    ///
294    /// Defaults to `60`. Configurable
295    /// via `TRUSS_PUBLIC_STALE_WHILE_REVALIDATE`.
296    pub public_stale_while_revalidate_seconds: u32,
297    /// Whether Accept-based content negotiation is disabled for public GET endpoints.
298    ///
299    /// When running behind a CDN such as CloudFront, Accept negotiation combined with
300    /// `Vary: Accept` can cause cache key mismatches or mis-served responses if the CDN
301    /// cache policy does not forward the `Accept` header.  Setting this flag to `true`
302    /// disables Accept negotiation entirely: public GET requests that omit the `format`
303    /// query parameter will preserve the input format instead of negotiating via Accept.
304    pub disable_accept_negotiation: bool,
305    /// Optional logging callback for diagnostic messages.
306    ///
307    /// When set, the server routes all diagnostic messages (cache errors, connection
308    /// failures, transform warnings) through this handler. When `None`, messages are
309    /// written to stderr via `eprintln!`.
310    pub log_handler: Option<LogHandler>,
311    /// Maximum number of concurrent image transforms.
312    ///
313    /// Configurable via `TRUSS_MAX_CONCURRENT_TRANSFORMS`. Defaults to 64.
314    pub max_concurrent_transforms: u64,
315    /// Per-transform wall-clock deadline in seconds.
316    ///
317    /// Configurable via `TRUSS_TRANSFORM_DEADLINE_SECS`. Defaults to 30.
318    pub transform_deadline_secs: u64,
319    /// Per-server counter tracking the number of image transforms currently in
320    /// flight.  This is runtime state (not configuration) but lives here so that
321    /// each `serve_with_config` invocation gets an independent counter, avoiding
322    /// cross-server interference when multiple listeners run in the same process
323    /// or during tests.
324    pub transforms_in_flight: Arc<AtomicU64>,
325    /// Download timeout in seconds for object storage backends (S3, GCS, Azure).
326    ///
327    /// Configurable via `TRUSS_STORAGE_TIMEOUT_SECS`. Defaults to 30.
328    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
329    pub storage_timeout_secs: u64,
330    /// The storage backend used to resolve `Path`-based public GET requests.
331    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
332    pub storage_backend: StorageBackend,
333    /// Shared S3 client context, present when `storage_backend` is `S3`.
334    #[cfg(feature = "s3")]
335    pub s3_context: Option<Arc<s3::S3Context>>,
336    /// Shared GCS client context, present when `storage_backend` is `Gcs`.
337    #[cfg(feature = "gcs")]
338    pub gcs_context: Option<Arc<gcs::GcsContext>>,
339    /// Shared Azure Blob Storage client context, present when `storage_backend` is `Azure`.
340    #[cfg(feature = "azure")]
341    pub azure_context: Option<Arc<azure::AzureContext>>,
342}
343
344impl Clone for ServerConfig {
345    fn clone(&self) -> Self {
346        Self {
347            storage_root: self.storage_root.clone(),
348            bearer_token: self.bearer_token.clone(),
349            public_base_url: self.public_base_url.clone(),
350            signed_url_key_id: self.signed_url_key_id.clone(),
351            signed_url_secret: self.signed_url_secret.clone(),
352            allow_insecure_url_sources: self.allow_insecure_url_sources,
353            cache_root: self.cache_root.clone(),
354            public_max_age_seconds: self.public_max_age_seconds,
355            public_stale_while_revalidate_seconds: self.public_stale_while_revalidate_seconds,
356            disable_accept_negotiation: self.disable_accept_negotiation,
357            log_handler: self.log_handler.clone(),
358            max_concurrent_transforms: self.max_concurrent_transforms,
359            transform_deadline_secs: self.transform_deadline_secs,
360            transforms_in_flight: Arc::clone(&self.transforms_in_flight),
361            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
362            storage_timeout_secs: self.storage_timeout_secs,
363            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
364            storage_backend: self.storage_backend,
365            #[cfg(feature = "s3")]
366            s3_context: self.s3_context.clone(),
367            #[cfg(feature = "gcs")]
368            gcs_context: self.gcs_context.clone(),
369            #[cfg(feature = "azure")]
370            azure_context: self.azure_context.clone(),
371        }
372    }
373}
374
375impl fmt::Debug for ServerConfig {
376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
377        let mut d = f.debug_struct("ServerConfig");
378        d.field("storage_root", &self.storage_root)
379            .field(
380                "bearer_token",
381                &self.bearer_token.as_ref().map(|_| "[REDACTED]"),
382            )
383            .field("public_base_url", &self.public_base_url)
384            .field("signed_url_key_id", &self.signed_url_key_id)
385            .field(
386                "signed_url_secret",
387                &self.signed_url_secret.as_ref().map(|_| "[REDACTED]"),
388            )
389            .field(
390                "allow_insecure_url_sources",
391                &self.allow_insecure_url_sources,
392            )
393            .field("cache_root", &self.cache_root)
394            .field("public_max_age_seconds", &self.public_max_age_seconds)
395            .field(
396                "public_stale_while_revalidate_seconds",
397                &self.public_stale_while_revalidate_seconds,
398            )
399            .field(
400                "disable_accept_negotiation",
401                &self.disable_accept_negotiation,
402            )
403            .field("log_handler", &self.log_handler.as_ref().map(|_| ".."))
404            .field("max_concurrent_transforms", &self.max_concurrent_transforms)
405            .field("transform_deadline_secs", &self.transform_deadline_secs);
406        #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
407        {
408            d.field("storage_backend", &self.storage_backend);
409        }
410        #[cfg(feature = "s3")]
411        {
412            d.field("s3_context", &self.s3_context.as_ref().map(|_| ".."));
413        }
414        #[cfg(feature = "gcs")]
415        {
416            d.field("gcs_context", &self.gcs_context.as_ref().map(|_| ".."));
417        }
418        #[cfg(feature = "azure")]
419        {
420            d.field("azure_context", &self.azure_context.as_ref().map(|_| ".."));
421        }
422        d.finish()
423    }
424}
425
426impl PartialEq for ServerConfig {
427    fn eq(&self, other: &Self) -> bool {
428        self.storage_root == other.storage_root
429            && self.bearer_token == other.bearer_token
430            && self.public_base_url == other.public_base_url
431            && self.signed_url_key_id == other.signed_url_key_id
432            && self.signed_url_secret == other.signed_url_secret
433            && self.allow_insecure_url_sources == other.allow_insecure_url_sources
434            && self.cache_root == other.cache_root
435            && self.public_max_age_seconds == other.public_max_age_seconds
436            && self.public_stale_while_revalidate_seconds
437                == other.public_stale_while_revalidate_seconds
438            && self.disable_accept_negotiation == other.disable_accept_negotiation
439            && self.max_concurrent_transforms == other.max_concurrent_transforms
440            && self.transform_deadline_secs == other.transform_deadline_secs
441            && cfg_storage_eq(self, other)
442    }
443}
444
445fn cfg_storage_eq(_this: &ServerConfig, _other: &ServerConfig) -> bool {
446    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
447    {
448        if _this.storage_backend != _other.storage_backend {
449            return false;
450        }
451    }
452    #[cfg(feature = "s3")]
453    {
454        if _this
455            .s3_context
456            .as_ref()
457            .map(|c| (&c.default_bucket, &c.endpoint_url))
458            != _other
459                .s3_context
460                .as_ref()
461                .map(|c| (&c.default_bucket, &c.endpoint_url))
462        {
463            return false;
464        }
465    }
466    #[cfg(feature = "gcs")]
467    {
468        if _this
469            .gcs_context
470            .as_ref()
471            .map(|c| (&c.default_bucket, &c.endpoint_url))
472            != _other
473                .gcs_context
474                .as_ref()
475                .map(|c| (&c.default_bucket, &c.endpoint_url))
476        {
477            return false;
478        }
479    }
480    #[cfg(feature = "azure")]
481    {
482        if _this
483            .azure_context
484            .as_ref()
485            .map(|c| (&c.default_container, &c.endpoint_url))
486            != _other
487                .azure_context
488                .as_ref()
489                .map(|c| (&c.default_container, &c.endpoint_url))
490        {
491            return false;
492        }
493    }
494    true
495}
496
497impl Eq for ServerConfig {}
498
499impl ServerConfig {
500    /// Creates a server configuration from explicit values.
501    ///
502    /// This constructor does not canonicalize the storage root. It is primarily intended for
503    /// tests and embedding scenarios where the caller already controls the filesystem layout.
504    ///
505    /// # Examples
506    ///
507    /// ```
508    /// use truss::adapters::server::ServerConfig;
509    ///
510    /// let config = ServerConfig::new(std::env::temp_dir(), Some("secret".to_string()));
511    ///
512    /// assert_eq!(config.bearer_token.as_deref(), Some("secret"));
513    /// ```
514    pub fn new(storage_root: PathBuf, bearer_token: Option<String>) -> Self {
515        Self {
516            storage_root,
517            bearer_token,
518            public_base_url: None,
519            signed_url_key_id: None,
520            signed_url_secret: None,
521            allow_insecure_url_sources: false,
522            cache_root: None,
523            public_max_age_seconds: DEFAULT_PUBLIC_MAX_AGE_SECONDS,
524            public_stale_while_revalidate_seconds: DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
525            disable_accept_negotiation: false,
526            log_handler: None,
527            max_concurrent_transforms: DEFAULT_MAX_CONCURRENT_TRANSFORMS,
528            transform_deadline_secs: DEFAULT_TRANSFORM_DEADLINE_SECS,
529            transforms_in_flight: Arc::new(AtomicU64::new(0)),
530            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
531            storage_timeout_secs: remote::STORAGE_DOWNLOAD_TIMEOUT_SECS,
532            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
533            storage_backend: StorageBackend::Filesystem,
534            #[cfg(feature = "s3")]
535            s3_context: None,
536            #[cfg(feature = "gcs")]
537            gcs_context: None,
538            #[cfg(feature = "azure")]
539            azure_context: None,
540        }
541    }
542
543    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
544    fn storage_backend_label(&self) -> StorageBackendLabel {
545        #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
546        {
547            match self.storage_backend {
548                StorageBackend::Filesystem => StorageBackendLabel::Filesystem,
549                #[cfg(feature = "s3")]
550                StorageBackend::S3 => StorageBackendLabel::S3,
551                #[cfg(feature = "gcs")]
552                StorageBackend::Gcs => StorageBackendLabel::Gcs,
553                #[cfg(feature = "azure")]
554                StorageBackend::Azure => StorageBackendLabel::Azure,
555            }
556        }
557        #[cfg(not(any(feature = "s3", feature = "gcs", feature = "azure")))]
558        {
559            StorageBackendLabel::Filesystem
560        }
561    }
562
563    /// Emits a diagnostic message through the configured log handler, or falls
564    /// back to stderr when no handler is set.
565    fn log(&self, msg: &str) {
566        if let Some(handler) = &self.log_handler {
567            handler(msg);
568        } else {
569            stderr_write(msg);
570        }
571    }
572
573    /// Returns a copy of the configuration with signed-URL verification credentials attached.
574    ///
575    /// Public GET endpoints require both a key identifier and a shared secret. Tests and local
576    /// development setups can use this helper to attach those values directly without going
577    /// through environment variables.
578    ///
579    /// # Examples
580    ///
581    /// ```
582    /// use truss::adapters::server::ServerConfig;
583    ///
584    /// let config = ServerConfig::new(std::env::temp_dir(), None)
585    ///     .with_signed_url_credentials("public-dev", "top-secret");
586    ///
587    /// assert_eq!(config.signed_url_key_id.as_deref(), Some("public-dev"));
588    /// assert_eq!(config.signed_url_secret.as_deref(), Some("top-secret"));
589    /// ```
590    pub fn with_signed_url_credentials(
591        mut self,
592        key_id: impl Into<String>,
593        secret: impl Into<String>,
594    ) -> Self {
595        self.signed_url_key_id = Some(key_id.into());
596        self.signed_url_secret = Some(secret.into());
597        self
598    }
599
600    /// Returns a copy of the configuration with insecure URL source allowances toggled.
601    ///
602    /// Enabling this flag allows URL sources that target loopback or private-network addresses
603    /// and permits non-standard ports. This is useful for local integration tests but weakens
604    /// the default SSRF protections of the server adapter.
605    ///
606    /// # Examples
607    ///
608    /// ```
609    /// use truss::adapters::server::ServerConfig;
610    ///
611    /// let config = ServerConfig::new(std::env::temp_dir(), Some("secret".to_string()))
612    ///     .with_insecure_url_sources(true);
613    ///
614    /// assert!(config.allow_insecure_url_sources);
615    /// ```
616    pub fn with_insecure_url_sources(mut self, allow_insecure_url_sources: bool) -> Self {
617        self.allow_insecure_url_sources = allow_insecure_url_sources;
618        self
619    }
620
621    /// Returns a copy of the configuration with a transform cache directory set.
622    ///
623    /// When a cache root is configured, the server stores transformed images on disk using a
624    /// sharded directory layout and serves subsequent identical requests from the cache.
625    ///
626    /// # Examples
627    ///
628    /// ```
629    /// use truss::adapters::server::ServerConfig;
630    ///
631    /// let config = ServerConfig::new(std::env::temp_dir(), None)
632    ///     .with_cache_root(std::env::temp_dir().join("truss-cache"));
633    ///
634    /// assert!(config.cache_root.is_some());
635    /// ```
636    pub fn with_cache_root(mut self, cache_root: impl Into<PathBuf>) -> Self {
637        self.cache_root = Some(cache_root.into());
638        self
639    }
640
641    /// Returns a copy of the configuration with an S3 storage backend attached.
642    #[cfg(feature = "s3")]
643    pub fn with_s3_context(mut self, context: s3::S3Context) -> Self {
644        self.storage_backend = StorageBackend::S3;
645        self.s3_context = Some(Arc::new(context));
646        self
647    }
648
649    /// Returns a copy of the configuration with a GCS storage backend attached.
650    #[cfg(feature = "gcs")]
651    pub fn with_gcs_context(mut self, context: gcs::GcsContext) -> Self {
652        self.storage_backend = StorageBackend::Gcs;
653        self.gcs_context = Some(Arc::new(context));
654        self
655    }
656
657    /// Returns a copy of the configuration with an Azure Blob Storage backend attached.
658    #[cfg(feature = "azure")]
659    pub fn with_azure_context(mut self, context: azure::AzureContext) -> Self {
660        self.storage_backend = StorageBackend::Azure;
661        self.azure_context = Some(Arc::new(context));
662        self
663    }
664
665    /// Loads server configuration from environment variables.
666    ///
667    /// The adapter currently reads:
668    ///
669    /// - `TRUSS_STORAGE_ROOT`: filesystem root for `source.kind=path` inputs. Defaults to the
670    ///   current directory and is canonicalized before use.
671    /// - `TRUSS_BEARER_TOKEN`: private API Bearer token. When this value is missing, private
672    ///   endpoints remain unavailable and return `503 Service Unavailable`.
673    /// - `TRUSS_PUBLIC_BASE_URL`: externally visible base URL reserved for future public endpoint
674    ///   signing. When set, it must parse as an absolute `http` or `https` URL.
675    /// - `TRUSS_SIGNED_URL_KEY_ID`: key identifier accepted by public signed GET endpoints.
676    /// - `TRUSS_SIGNED_URL_SECRET`: shared secret used to verify public signed GET signatures.
677    /// - `TRUSS_ALLOW_INSECURE_URL_SOURCES`: when set to `1`, `true`, `yes`, or `on`, URL
678    ///   sources may target loopback or private-network addresses and non-standard ports.
679    /// - `TRUSS_CACHE_ROOT`: directory for the on-disk transform cache. When set, transformed
680    ///   images are cached using a sharded `ab/cd/ef/<sha256>` layout. When absent, caching is
681    ///   disabled.
682    /// - `TRUSS_PUBLIC_MAX_AGE`: `Cache-Control: max-age` value (in seconds) for public GET
683    ///   image responses. Defaults to 3600.
684    /// - `TRUSS_PUBLIC_STALE_WHILE_REVALIDATE`: `Cache-Control: stale-while-revalidate` value
685    ///   (in seconds) for public GET image responses. Defaults to 60.
686    /// - `TRUSS_DISABLE_ACCEPT_NEGOTIATION`: when set to `1`, `true`, `yes`, or `on`, disables
687    ///   Accept-based content negotiation on public GET endpoints. This is recommended when running
688    ///   behind a CDN that does not forward the `Accept` header in its cache key.
689    /// - `TRUSS_STORAGE_BACKEND` *(requires the `s3`, `gcs`, or `azure` feature)*: storage backend
690    ///   for resolving `Path`-based public GET requests. Accepts `filesystem` (default), `s3`,
691    ///   `gcs`, or `azure`.
692    /// - `TRUSS_S3_BUCKET` *(requires the `s3` feature)*: default S3 bucket name. Required when
693    ///   the storage backend is `s3`.
694    /// - `TRUSS_S3_FORCE_PATH_STYLE` *(requires the `s3` feature)*: when set to `1`, `true`,
695    ///   `yes`, or `on`, use path-style S3 addressing (`http://endpoint/bucket/key`) instead
696    ///   of virtual-hosted-style. Required for S3-compatible services such as MinIO and
697    ///   adobe/s3mock.
698    /// - `TRUSS_GCS_BUCKET` *(requires the `gcs` feature)*: default GCS bucket name. Required
699    ///   when the storage backend is `gcs`.
700    /// - `TRUSS_GCS_ENDPOINT` *(requires the `gcs` feature)*: custom GCS endpoint URL. Used for
701    ///   emulators such as `fake-gcs-server`. When absent, the default Google Cloud Storage
702    ///   endpoint is used.
703    /// - `GOOGLE_APPLICATION_CREDENTIALS`: path to a GCS service account JSON key file.
704    /// - `GOOGLE_APPLICATION_CREDENTIALS_JSON`: inline GCS service account JSON (alternative to
705    ///   file path).
706    /// - `TRUSS_AZURE_CONTAINER` *(requires the `azure` feature)*: default Azure Blob Storage
707    ///   container name. Required when the storage backend is `azure`.
708    /// - `TRUSS_AZURE_ENDPOINT` *(requires the `azure` feature)*: custom Azure Blob Storage
709    ///   endpoint URL. Used for emulators such as Azurite. When absent, the endpoint is derived
710    ///   from `AZURE_STORAGE_ACCOUNT_NAME`.
711    /// - `AZURE_STORAGE_ACCOUNT_NAME`: Azure storage account name (used to derive the default
712    ///   endpoint when `TRUSS_AZURE_ENDPOINT` is not set).
713    /// - `TRUSS_MAX_CONCURRENT_TRANSFORMS`: maximum number of concurrent image transforms
714    ///   (default: 64, range: 1–1024). Requests exceeding this limit are rejected with 503.
715    /// - `TRUSS_TRANSFORM_DEADLINE_SECS`: per-transform wall-clock deadline in seconds
716    ///   (default: 30, range: 1–300). Transforms exceeding this deadline are cancelled.
717    /// - `TRUSS_STORAGE_TIMEOUT_SECS`: download timeout for storage backends in seconds
718    ///   (default: 30, range: 1–300).
719    ///
720    /// # Errors
721    ///
722    /// Returns an [`io::Error`] when the configured storage root does not exist or cannot be
723    /// canonicalized.
724    ///
725    /// # Examples
726    ///
727    /// ```no_run
728    /// // SAFETY: This example runs single-threaded; no concurrent env access.
729    /// unsafe {
730    ///     std::env::set_var("TRUSS_STORAGE_ROOT", ".");
731    ///     std::env::set_var("TRUSS_ALLOW_INSECURE_URL_SOURCES", "true");
732    /// }
733    ///
734    /// let config = truss::adapters::server::ServerConfig::from_env().unwrap();
735    ///
736    /// assert!(config.storage_root.is_absolute());
737    /// assert!(config.allow_insecure_url_sources);
738    /// ```
739    pub fn from_env() -> io::Result<Self> {
740        #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
741        let storage_backend = match env::var("TRUSS_STORAGE_BACKEND")
742            .ok()
743            .filter(|v| !v.is_empty())
744        {
745            Some(value) => StorageBackend::parse(&value)
746                .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?,
747            None => StorageBackend::Filesystem,
748        };
749
750        let storage_root =
751            env::var("TRUSS_STORAGE_ROOT").unwrap_or_else(|_| DEFAULT_STORAGE_ROOT.to_string());
752        let storage_root = PathBuf::from(storage_root).canonicalize()?;
753        let bearer_token = env::var("TRUSS_BEARER_TOKEN")
754            .ok()
755            .filter(|value| !value.is_empty());
756        let public_base_url = env::var("TRUSS_PUBLIC_BASE_URL")
757            .ok()
758            .filter(|value| !value.is_empty())
759            .map(validate_public_base_url)
760            .transpose()?;
761        let signed_url_key_id = env::var("TRUSS_SIGNED_URL_KEY_ID")
762            .ok()
763            .filter(|value| !value.is_empty());
764        let signed_url_secret = env::var("TRUSS_SIGNED_URL_SECRET")
765            .ok()
766            .filter(|value| !value.is_empty());
767
768        if signed_url_key_id.is_some() != signed_url_secret.is_some() {
769            return Err(io::Error::new(
770                io::ErrorKind::InvalidInput,
771                "TRUSS_SIGNED_URL_KEY_ID and TRUSS_SIGNED_URL_SECRET must be set together",
772            ));
773        }
774
775        if signed_url_key_id.is_some() && public_base_url.is_none() {
776            eprintln!(
777                "truss: warning: TRUSS_SIGNED_URL_KEY_ID is set but TRUSS_PUBLIC_BASE_URL is not. \
778                 Behind a reverse proxy or CDN the Host header may differ from the externally \
779                 visible authority, causing signed URL verification to fail. Consider setting \
780                 TRUSS_PUBLIC_BASE_URL to the canonical external origin."
781            );
782        }
783
784        let cache_root = env::var("TRUSS_CACHE_ROOT")
785            .ok()
786            .filter(|value| !value.is_empty())
787            .map(PathBuf::from);
788
789        let public_max_age_seconds = parse_optional_env_u32("TRUSS_PUBLIC_MAX_AGE")?
790            .unwrap_or(DEFAULT_PUBLIC_MAX_AGE_SECONDS);
791        let public_stale_while_revalidate_seconds =
792            parse_optional_env_u32("TRUSS_PUBLIC_STALE_WHILE_REVALIDATE")?
793                .unwrap_or(DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS);
794
795        let allow_insecure_url_sources = env_flag("TRUSS_ALLOW_INSECURE_URL_SOURCES");
796
797        let max_concurrent_transforms = match env::var("TRUSS_MAX_CONCURRENT_TRANSFORMS")
798            .ok()
799            .filter(|v| !v.is_empty())
800        {
801            Some(value) => {
802                let n: u64 = value.parse().map_err(|_| {
803                    io::Error::new(
804                        io::ErrorKind::InvalidInput,
805                        "TRUSS_MAX_CONCURRENT_TRANSFORMS must be a positive integer",
806                    )
807                })?;
808                if n == 0 || n > 1024 {
809                    return Err(io::Error::new(
810                        io::ErrorKind::InvalidInput,
811                        "TRUSS_MAX_CONCURRENT_TRANSFORMS must be between 1 and 1024",
812                    ));
813                }
814                n
815            }
816            None => DEFAULT_MAX_CONCURRENT_TRANSFORMS,
817        };
818
819        let transform_deadline_secs = match env::var("TRUSS_TRANSFORM_DEADLINE_SECS")
820            .ok()
821            .filter(|v| !v.is_empty())
822        {
823            Some(value) => {
824                let secs: u64 = value.parse().map_err(|_| {
825                    io::Error::new(
826                        io::ErrorKind::InvalidInput,
827                        "TRUSS_TRANSFORM_DEADLINE_SECS must be a positive integer",
828                    )
829                })?;
830                if secs == 0 || secs > 300 {
831                    return Err(io::Error::new(
832                        io::ErrorKind::InvalidInput,
833                        "TRUSS_TRANSFORM_DEADLINE_SECS must be between 1 and 300",
834                    ));
835                }
836                secs
837            }
838            None => DEFAULT_TRANSFORM_DEADLINE_SECS,
839        };
840
841        #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
842        let storage_timeout_secs = match env::var("TRUSS_STORAGE_TIMEOUT_SECS")
843            .ok()
844            .filter(|v| !v.is_empty())
845        {
846            Some(value) => {
847                let secs: u64 = value.parse().map_err(|_| {
848                    io::Error::new(
849                        io::ErrorKind::InvalidInput,
850                        "TRUSS_STORAGE_TIMEOUT_SECS must be a positive integer",
851                    )
852                })?;
853                if secs == 0 || secs > 300 {
854                    return Err(io::Error::new(
855                        io::ErrorKind::InvalidInput,
856                        "TRUSS_STORAGE_TIMEOUT_SECS must be between 1 and 300",
857                    ));
858                }
859                secs
860            }
861            None => remote::STORAGE_DOWNLOAD_TIMEOUT_SECS,
862        };
863
864        #[cfg(feature = "s3")]
865        let s3_context = if storage_backend == StorageBackend::S3 {
866            let bucket = env::var("TRUSS_S3_BUCKET")
867                .ok()
868                .filter(|v| !v.is_empty())
869                .ok_or_else(|| {
870                    io::Error::new(
871                        io::ErrorKind::InvalidInput,
872                        "TRUSS_S3_BUCKET is required when TRUSS_STORAGE_BACKEND=s3",
873                    )
874                })?;
875            Some(Arc::new(s3::build_s3_context(
876                bucket,
877                allow_insecure_url_sources,
878            )?))
879        } else {
880            None
881        };
882
883        #[cfg(feature = "gcs")]
884        let gcs_context = if storage_backend == StorageBackend::Gcs {
885            let bucket = env::var("TRUSS_GCS_BUCKET")
886                .ok()
887                .filter(|v| !v.is_empty())
888                .ok_or_else(|| {
889                    io::Error::new(
890                        io::ErrorKind::InvalidInput,
891                        "TRUSS_GCS_BUCKET is required when TRUSS_STORAGE_BACKEND=gcs",
892                    )
893                })?;
894            Some(Arc::new(gcs::build_gcs_context(
895                bucket,
896                allow_insecure_url_sources,
897            )?))
898        } else {
899            if env::var("TRUSS_GCS_BUCKET")
900                .ok()
901                .filter(|v| !v.is_empty())
902                .is_some()
903            {
904                eprintln!(
905                    "truss: warning: TRUSS_GCS_BUCKET is set but TRUSS_STORAGE_BACKEND is not \
906                     `gcs`. The GCS bucket will be ignored. Set TRUSS_STORAGE_BACKEND=gcs to \
907                     enable the GCS backend."
908                );
909            }
910            None
911        };
912
913        #[cfg(feature = "azure")]
914        let azure_context = if storage_backend == StorageBackend::Azure {
915            let container = env::var("TRUSS_AZURE_CONTAINER")
916                .ok()
917                .filter(|v| !v.is_empty())
918                .ok_or_else(|| {
919                    io::Error::new(
920                        io::ErrorKind::InvalidInput,
921                        "TRUSS_AZURE_CONTAINER is required when TRUSS_STORAGE_BACKEND=azure",
922                    )
923                })?;
924            Some(Arc::new(azure::build_azure_context(
925                container,
926                allow_insecure_url_sources,
927            )?))
928        } else {
929            if env::var("TRUSS_AZURE_CONTAINER")
930                .ok()
931                .filter(|v| !v.is_empty())
932                .is_some()
933            {
934                eprintln!(
935                    "truss: warning: TRUSS_AZURE_CONTAINER is set but TRUSS_STORAGE_BACKEND is not \
936                     `azure`. The Azure container will be ignored. Set TRUSS_STORAGE_BACKEND=azure to \
937                     enable the Azure backend."
938                );
939            }
940            None
941        };
942
943        Ok(Self {
944            storage_root,
945            bearer_token,
946            public_base_url,
947            signed_url_key_id,
948            signed_url_secret,
949            allow_insecure_url_sources,
950            cache_root,
951            public_max_age_seconds,
952            public_stale_while_revalidate_seconds,
953            disable_accept_negotiation: env_flag("TRUSS_DISABLE_ACCEPT_NEGOTIATION"),
954            log_handler: None,
955            max_concurrent_transforms,
956            transform_deadline_secs,
957            transforms_in_flight: Arc::new(AtomicU64::new(0)),
958            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
959            storage_timeout_secs,
960            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
961            storage_backend,
962            #[cfg(feature = "s3")]
963            s3_context,
964            #[cfg(feature = "gcs")]
965            gcs_context,
966            #[cfg(feature = "azure")]
967            azure_context,
968        })
969    }
970}
971
972/// Source selector used when generating a signed public transform URL.
973#[derive(Debug, Clone, PartialEq, Eq)]
974pub enum SignedUrlSource {
975    /// Generates a signed `GET /images/by-path` URL.
976    Path {
977        /// The storage-relative source path.
978        path: String,
979        /// An optional source version token.
980        version: Option<String>,
981    },
982    /// Generates a signed `GET /images/by-url` URL.
983    Url {
984        /// The remote source URL.
985        url: String,
986        /// An optional source version token.
987        version: Option<String>,
988    },
989}
990
991/// Builds a signed public transform URL for the server adapter.
992///
993/// The resulting URL targets either `GET /images/by-path` or `GET /images/by-url` depending on
994/// `source`. `base_url` must be an absolute `http` or `https` URL that points at the externally
995/// visible server origin. The helper applies the same canonical query and HMAC-SHA256 signature
996/// scheme that the server adapter verifies at request time.
997///
998/// The helper serializes only explicitly requested transform options and omits fields that would
999/// resolve to the documented defaults on the server side.
1000///
1001/// # Errors
1002///
1003/// Returns an error string when `base_url` is not an absolute `http` or `https` URL, when the
1004/// visible authority cannot be determined, or when the HMAC state cannot be initialized.
1005///
1006/// # Examples
1007///
1008/// ```
1009/// use truss::adapters::server::{sign_public_url, SignedUrlSource};
1010/// use truss::{MediaType, TransformOptions};
1011///
1012/// let url = sign_public_url(
1013///     "https://cdn.example.com",
1014///     SignedUrlSource::Path {
1015///         path: "/image.png".to_string(),
1016///         version: None,
1017///     },
1018///     &TransformOptions {
1019///         format: Some(MediaType::Jpeg),
1020///         ..TransformOptions::default()
1021///     },
1022///     "public-dev",
1023///     "secret-value",
1024///     4_102_444_800,
1025/// )
1026/// .unwrap();
1027///
1028/// assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
1029/// assert!(url.contains("keyId=public-dev"));
1030/// assert!(url.contains("signature="));
1031/// ```
1032pub fn sign_public_url(
1033    base_url: &str,
1034    source: SignedUrlSource,
1035    options: &TransformOptions,
1036    key_id: &str,
1037    secret: &str,
1038    expires: u64,
1039) -> Result<String, String> {
1040    let base_url = Url::parse(base_url).map_err(|error| format!("base URL is invalid: {error}"))?;
1041    match base_url.scheme() {
1042        "http" | "https" => {}
1043        _ => return Err("base URL must use the http or https scheme".to_string()),
1044    }
1045
1046    let route_path = match source {
1047        SignedUrlSource::Path { .. } => "/images/by-path",
1048        SignedUrlSource::Url { .. } => "/images/by-url",
1049    };
1050    let mut endpoint = base_url
1051        .join(route_path)
1052        .map_err(|error| format!("failed to resolve the public endpoint URL: {error}"))?;
1053    let authority = url_authority(&endpoint)?;
1054    let mut query = signed_source_query(source);
1055    extend_transform_query(&mut query, options);
1056    query.insert("keyId".to_string(), key_id.to_string());
1057    query.insert("expires".to_string(), expires.to_string());
1058
1059    let canonical = format!(
1060        "GET\n{}\n{}\n{}",
1061        authority,
1062        endpoint.path(),
1063        canonical_query_without_signature(&query)
1064    );
1065    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
1066        .map_err(|error| format!("failed to initialize signed URL HMAC: {error}"))?;
1067    mac.update(canonical.as_bytes());
1068    query.insert(
1069        "signature".to_string(),
1070        hex::encode(mac.finalize().into_bytes()),
1071    );
1072
1073    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
1074    for (name, value) in query {
1075        serializer.append_pair(&name, &value);
1076    }
1077    endpoint.set_query(Some(&serializer.finish()));
1078    Ok(endpoint.into())
1079}
1080
1081/// Returns the bind address for the HTTP server adapter.
1082///
1083/// The adapter reads `TRUSS_BIND_ADDR` when it is present. Otherwise it falls back to
1084/// [`DEFAULT_BIND_ADDR`].
1085pub fn bind_addr() -> String {
1086    env::var("TRUSS_BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string())
1087}
1088
1089/// Serves requests until the listener stops producing connections.
1090///
1091/// This helper loads [`ServerConfig`] from the process environment and then delegates to
1092/// [`serve_with_config`]. Health endpoints remain available even when the private API is not
1093/// configured, but authenticated transform requests will return `503 Service Unavailable`
1094/// unless `TRUSS_BEARER_TOKEN` is set.
1095///
1096/// # Errors
1097///
1098/// Returns an [`io::Error`] when the storage root cannot be resolved, when accepting the next
1099/// connection fails, or when a response cannot be written to the socket.
1100pub fn serve(listener: TcpListener) -> io::Result<()> {
1101    let config = ServerConfig::from_env()?;
1102
1103    // Fail fast: verify the storage backend is reachable before accepting
1104    // connections so that configuration errors are surfaced immediately.
1105    for (ok, name) in storage_health_check(&config) {
1106        if !ok {
1107            return Err(io::Error::new(
1108                io::ErrorKind::ConnectionRefused,
1109                format!(
1110                    "storage connectivity check failed for `{name}` — verify the backend \
1111                     endpoint, credentials, and container/bucket configuration"
1112                ),
1113            ));
1114        }
1115    }
1116
1117    serve_with_config(listener, config)
1118}
1119
1120/// Serves requests with an explicit server configuration.
1121///
1122/// This is the adapter entry point for tests and embedding scenarios that want deterministic
1123/// configuration instead of environment-variable lookup.
1124///
1125/// # Errors
1126///
1127/// Returns an [`io::Error`] when accepting the next connection fails or when a response cannot
1128/// be written to the socket.
1129pub fn serve_with_config(listener: TcpListener, config: ServerConfig) -> io::Result<()> {
1130    let config = Arc::new(config);
1131    let (sender, receiver) = std::sync::mpsc::channel::<TcpStream>();
1132
1133    // Spawn a pool of worker threads sized to the configured concurrency limit
1134    // (with a minimum of WORKER_THREADS to leave headroom for non-transform
1135    // requests such as health checks and metrics).  Each thread pulls connections
1136    // from the shared channel and handles them independently, so a slow request
1137    // no longer blocks all other clients.
1138    let receiver = Arc::new(std::sync::Mutex::new(receiver));
1139    let pool_size = (config.max_concurrent_transforms as usize).max(WORKER_THREADS);
1140    let mut workers = Vec::with_capacity(pool_size);
1141    for _ in 0..pool_size {
1142        let rx = Arc::clone(&receiver);
1143        let cfg = Arc::clone(&config);
1144        workers.push(std::thread::spawn(move || {
1145            loop {
1146                let stream = {
1147                    let guard = rx.lock().expect("worker lock poisoned");
1148                    match guard.recv() {
1149                        Ok(stream) => stream,
1150                        Err(_) => break,
1151                    }
1152                }; // MutexGuard dropped here — before handle_stream runs.
1153                if let Err(err) = handle_stream(stream, &cfg) {
1154                    cfg.log(&format!("failed to handle connection: {err}"));
1155                }
1156            }
1157        }));
1158    }
1159
1160    for stream in listener.incoming() {
1161        match stream {
1162            Ok(stream) => {
1163                if sender.send(stream).is_err() {
1164                    break;
1165                }
1166            }
1167            Err(err) => return Err(err),
1168        }
1169    }
1170
1171    drop(sender);
1172    let deadline = std::time::Instant::now() + Duration::from_secs(30);
1173    for worker in workers {
1174        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1175        if remaining.is_zero() {
1176            eprintln!("shutdown: timed out waiting for worker threads");
1177            break;
1178        }
1179        // Park the main thread until the worker finishes or the deadline
1180        // elapses. We cannot interrupt a blocked worker, but the socket
1181        // read/write timeouts ensure workers do not block forever.
1182        let worker_done =
1183            std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
1184        let wd = std::sync::Arc::clone(&worker_done);
1185        std::thread::spawn(move || {
1186            let _ = worker.join();
1187            let (lock, cvar) = &*wd;
1188            *lock.lock().expect("shutdown notify lock") = true;
1189            cvar.notify_one();
1190        });
1191        let (lock, cvar) = &*worker_done;
1192        let mut done = lock.lock().expect("shutdown wait lock");
1193        while !*done {
1194            let (guard, timeout) = cvar
1195                .wait_timeout(done, remaining)
1196                .expect("shutdown condvar wait");
1197            done = guard;
1198            if timeout.timed_out() {
1199                eprintln!("shutdown: timed out waiting for a worker thread");
1200                break;
1201            }
1202        }
1203    }
1204
1205    Ok(())
1206}
1207
1208/// Serves exactly one request using configuration loaded from the environment.
1209///
1210/// This helper is primarily useful in tests that want to drive the server over a real TCP
1211/// socket but do not need a long-running loop.
1212///
1213/// # Errors
1214///
1215/// Returns an [`io::Error`] when the storage root cannot be resolved, when accepting the next
1216/// connection fails, or when a response cannot be written to the socket.
1217pub fn serve_once(listener: TcpListener) -> io::Result<()> {
1218    let config = ServerConfig::from_env()?;
1219    serve_once_with_config(listener, config)
1220}
1221
1222/// Serves exactly one request with an explicit server configuration.
1223///
1224/// # Errors
1225///
1226/// Returns an [`io::Error`] when accepting the next connection fails or when a response cannot
1227/// be written to the socket.
1228pub fn serve_once_with_config(listener: TcpListener, config: ServerConfig) -> io::Result<()> {
1229    let (stream, _) = listener.accept()?;
1230    handle_stream(stream, &config)
1231}
1232
1233#[derive(Debug, Deserialize)]
1234#[serde(deny_unknown_fields)]
1235struct TransformImageRequestPayload {
1236    source: TransformSourcePayload,
1237    #[serde(default)]
1238    options: TransformOptionsPayload,
1239}
1240
1241#[derive(Debug, Deserialize)]
1242#[serde(tag = "kind", rename_all = "lowercase")]
1243enum TransformSourcePayload {
1244    Path {
1245        path: String,
1246        version: Option<String>,
1247    },
1248    Url {
1249        url: String,
1250        version: Option<String>,
1251    },
1252    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1253    Storage {
1254        bucket: Option<String>,
1255        key: String,
1256        version: Option<String>,
1257    },
1258}
1259
1260impl TransformSourcePayload {
1261    /// Computes a stable source hash from the reference and version, avoiding the
1262    /// need to read the full source bytes when a version tag is present. Returns
1263    /// `None` when no version is available, in which case the caller must fall back
1264    /// to the content-hash approach.
1265    /// Computes a stable source hash that includes the instance configuration
1266    /// boundaries (storage root, allow_insecure_url_sources) so that cache entries
1267    /// cannot be reused across instances with different security settings sharing
1268    /// the same cache directory.
1269    fn versioned_source_hash(&self, config: &ServerConfig) -> Option<String> {
1270        let (kind, reference, version): (&str, std::borrow::Cow<'_, str>, Option<&str>) = match self
1271        {
1272            Self::Path { path, version } => ("path", path.as_str().into(), version.as_deref()),
1273            Self::Url { url, version } => ("url", url.as_str().into(), version.as_deref()),
1274            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1275            Self::Storage {
1276                bucket,
1277                key,
1278                version,
1279            } => {
1280                let (scheme, effective_bucket) =
1281                    storage_scheme_and_bucket(bucket.as_deref(), config);
1282                let effective_bucket = effective_bucket?;
1283                (
1284                    "storage",
1285                    format!("{scheme}://{effective_bucket}/{key}").into(),
1286                    version.as_deref(),
1287                )
1288            }
1289        };
1290        let version = version?;
1291        // Use newline separators so that values containing colons cannot collide
1292        // with different (reference, version) pairs. Include configuration boundaries
1293        // to prevent cross-instance cache poisoning.
1294        let mut id = String::new();
1295        id.push_str(kind);
1296        id.push('\n');
1297        id.push_str(&reference);
1298        id.push('\n');
1299        id.push_str(version);
1300        id.push('\n');
1301        id.push_str(config.storage_root.to_string_lossy().as_ref());
1302        id.push('\n');
1303        id.push_str(if config.allow_insecure_url_sources {
1304            "insecure"
1305        } else {
1306            "strict"
1307        });
1308        #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1309        {
1310            id.push('\n');
1311            id.push_str(storage_backend_label(config));
1312            #[cfg(feature = "s3")]
1313            if let Some(ref ctx) = config.s3_context
1314                && let Some(ref endpoint) = ctx.endpoint_url
1315            {
1316                id.push('\n');
1317                id.push_str(endpoint);
1318            }
1319            #[cfg(feature = "gcs")]
1320            if let Some(ref ctx) = config.gcs_context
1321                && let Some(ref endpoint) = ctx.endpoint_url
1322            {
1323                id.push('\n');
1324                id.push_str(endpoint);
1325            }
1326            #[cfg(feature = "azure")]
1327            if let Some(ref ctx) = config.azure_context {
1328                id.push('\n');
1329                id.push_str(&ctx.endpoint_url);
1330            }
1331        }
1332        Some(hex::encode(Sha256::digest(id.as_bytes())))
1333    }
1334
1335    /// Returns the storage backend label for metrics based on the source kind,
1336    /// rather than the server config default.  Path → Filesystem, Storage →
1337    /// whatever the config backend is, Url → None (no storage backend).
1338    fn metrics_backend_label(&self, _config: &ServerConfig) -> Option<StorageBackendLabel> {
1339        match self {
1340            Self::Path { .. } => Some(StorageBackendLabel::Filesystem),
1341            Self::Url { .. } => None,
1342            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1343            Self::Storage { .. } => Some(_config.storage_backend_label()),
1344        }
1345    }
1346}
1347
1348#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1349fn storage_scheme_and_bucket<'a>(
1350    explicit_bucket: Option<&'a str>,
1351    config: &'a ServerConfig,
1352) -> (&'static str, Option<&'a str>) {
1353    match config.storage_backend {
1354        #[cfg(feature = "s3")]
1355        StorageBackend::S3 => {
1356            let bucket = explicit_bucket.or(config
1357                .s3_context
1358                .as_ref()
1359                .map(|ctx| ctx.default_bucket.as_str()));
1360            ("s3", bucket)
1361        }
1362        #[cfg(feature = "gcs")]
1363        StorageBackend::Gcs => {
1364            let bucket = explicit_bucket.or(config
1365                .gcs_context
1366                .as_ref()
1367                .map(|ctx| ctx.default_bucket.as_str()));
1368            ("gcs", bucket)
1369        }
1370        StorageBackend::Filesystem => ("fs", explicit_bucket),
1371        #[cfg(feature = "azure")]
1372        StorageBackend::Azure => {
1373            let bucket = explicit_bucket.or(config
1374                .azure_context
1375                .as_ref()
1376                .map(|ctx| ctx.default_container.as_str()));
1377            ("azure", bucket)
1378        }
1379    }
1380}
1381
1382#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1383fn is_object_storage_backend(config: &ServerConfig) -> bool {
1384    match config.storage_backend {
1385        StorageBackend::Filesystem => false,
1386        #[cfg(feature = "s3")]
1387        StorageBackend::S3 => true,
1388        #[cfg(feature = "gcs")]
1389        StorageBackend::Gcs => true,
1390        #[cfg(feature = "azure")]
1391        StorageBackend::Azure => true,
1392    }
1393}
1394
1395#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1396fn storage_backend_label(config: &ServerConfig) -> &'static str {
1397    match config.storage_backend {
1398        StorageBackend::Filesystem => "fs-backend",
1399        #[cfg(feature = "s3")]
1400        StorageBackend::S3 => "s3-backend",
1401        #[cfg(feature = "gcs")]
1402        StorageBackend::Gcs => "gcs-backend",
1403        #[cfg(feature = "azure")]
1404        StorageBackend::Azure => "azure-backend",
1405    }
1406}
1407
1408#[derive(Debug, Default, Deserialize)]
1409#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
1410struct TransformOptionsPayload {
1411    width: Option<u32>,
1412    height: Option<u32>,
1413    fit: Option<String>,
1414    position: Option<String>,
1415    format: Option<String>,
1416    quality: Option<u8>,
1417    background: Option<String>,
1418    rotate: Option<u16>,
1419    auto_orient: Option<bool>,
1420    strip_metadata: Option<bool>,
1421    preserve_exif: Option<bool>,
1422    blur: Option<f32>,
1423}
1424
1425impl TransformOptionsPayload {
1426    fn into_options(self) -> Result<TransformOptions, HttpResponse> {
1427        let defaults = TransformOptions::default();
1428
1429        Ok(TransformOptions {
1430            width: self.width,
1431            height: self.height,
1432            fit: parse_optional_named(self.fit.as_deref(), "fit", Fit::from_str)?,
1433            position: parse_optional_named(
1434                self.position.as_deref(),
1435                "position",
1436                Position::from_str,
1437            )?,
1438            format: parse_optional_named(self.format.as_deref(), "format", MediaType::from_str)?,
1439            quality: self.quality,
1440            background: parse_optional_named(
1441                self.background.as_deref(),
1442                "background",
1443                Rgba8::from_hex,
1444            )?,
1445            rotate: match self.rotate {
1446                Some(value) => parse_named(&value.to_string(), "rotate", Rotation::from_str)?,
1447                None => defaults.rotate,
1448            },
1449            auto_orient: self.auto_orient.unwrap_or(defaults.auto_orient),
1450            strip_metadata: self.strip_metadata.unwrap_or(defaults.strip_metadata),
1451            preserve_exif: self.preserve_exif.unwrap_or(defaults.preserve_exif),
1452            blur: self.blur,
1453            deadline: defaults.deadline,
1454        })
1455    }
1456}
1457
1458struct AccessLogEntry<'a> {
1459    request_id: &'a str,
1460    method: &'a str,
1461    path: &'a str,
1462    route: &'a str,
1463    status: &'a str,
1464    start: Instant,
1465    cache_status: Option<&'a str>,
1466}
1467
1468/// Extracts the `X-Request-Id` header value from request headers.
1469/// Returns `None` if the header is absent or empty.
1470fn extract_request_id(headers: &[(String, String)]) -> Option<String> {
1471    headers.iter().find_map(|(name, value)| {
1472        (name == "x-request-id" && !value.is_empty()).then_some(value.clone())
1473    })
1474}
1475
1476/// Classifies the `Cache-Status` response header as `"hit"` or `"miss"`.
1477/// Returns `None` when the header is absent.
1478fn extract_cache_status(headers: &[(&'static str, String)]) -> Option<&'static str> {
1479    headers
1480        .iter()
1481        .find_map(|(name, value)| (*name == "Cache-Status").then_some(value.as_str()))
1482        .map(|v| if v.contains("hit") { "hit" } else { "miss" })
1483}
1484
1485fn emit_access_log(config: &ServerConfig, entry: &AccessLogEntry<'_>) {
1486    config.log(
1487        &json!({
1488            "kind": "access_log",
1489            "request_id": entry.request_id,
1490            "method": entry.method,
1491            "path": entry.path,
1492            "route": entry.route,
1493            "status": entry.status,
1494            "latency_ms": entry.start.elapsed().as_millis() as u64,
1495            "cache_status": entry.cache_status,
1496        })
1497        .to_string(),
1498    );
1499}
1500
1501fn handle_stream(mut stream: TcpStream, config: &ServerConfig) -> io::Result<()> {
1502    // Prevent slow or stalled clients from blocking the accept loop indefinitely.
1503    if let Err(err) = stream.set_read_timeout(Some(SOCKET_READ_TIMEOUT)) {
1504        config.log(&format!("failed to set socket read timeout: {err}"));
1505    }
1506    if let Err(err) = stream.set_write_timeout(Some(SOCKET_WRITE_TIMEOUT)) {
1507        config.log(&format!("failed to set socket write timeout: {err}"));
1508    }
1509
1510    let mut requests_served: usize = 0;
1511
1512    loop {
1513        let partial = match read_request_headers(&mut stream) {
1514            Ok(partial) => partial,
1515            Err(response) => {
1516                if requests_served > 0 {
1517                    return Ok(());
1518                }
1519                let _ = write_response(&mut stream, response, true);
1520                return Ok(());
1521            }
1522        };
1523
1524        // Start timing after headers are read so latency reflects server
1525        // processing time, not client send / socket-wait time.
1526        let start = Instant::now();
1527
1528        let request_id =
1529            extract_request_id(&partial.headers).unwrap_or_else(|| Uuid::new_v4().to_string());
1530
1531        let client_wants_close = partial
1532            .headers
1533            .iter()
1534            .any(|(name, value)| name == "connection" && value.eq_ignore_ascii_case("close"));
1535
1536        let is_head = partial.method == "HEAD";
1537
1538        let requires_auth = matches!(
1539            (partial.method.as_str(), partial.path()),
1540            ("POST", "/images:transform") | ("POST", "/images")
1541        );
1542        if requires_auth
1543            && let Err(mut response) = authorize_request_headers(&partial.headers, config)
1544        {
1545            response.headers.push(("X-Request-Id", request_id.clone()));
1546            record_http_metrics(RouteMetric::Unknown, response.status);
1547            let sc = status_code(response.status).unwrap_or("unknown");
1548            let method_log = partial.method.clone();
1549            let path_log = partial.path().to_string();
1550            let _ = write_response(&mut stream, response, true);
1551            record_http_request_duration(RouteMetric::Unknown, start);
1552            emit_access_log(
1553                config,
1554                &AccessLogEntry {
1555                    request_id: &request_id,
1556                    method: &method_log,
1557                    path: &path_log,
1558                    route: &path_log,
1559                    status: sc,
1560                    start,
1561                    cache_status: None,
1562                },
1563            );
1564            return Ok(());
1565        }
1566
1567        // Clone method/path before `read_request_body` consumes `partial`.
1568        let method = partial.method.clone();
1569        let path = partial.path().to_string();
1570
1571        let request = match read_request_body(&mut stream, partial) {
1572            Ok(request) => request,
1573            Err(mut response) => {
1574                response.headers.push(("X-Request-Id", request_id.clone()));
1575                record_http_metrics(RouteMetric::Unknown, response.status);
1576                let sc = status_code(response.status).unwrap_or("unknown");
1577                let _ = write_response(&mut stream, response, true);
1578                record_http_request_duration(RouteMetric::Unknown, start);
1579                emit_access_log(
1580                    config,
1581                    &AccessLogEntry {
1582                        request_id: &request_id,
1583                        method: &method,
1584                        path: &path,
1585                        route: &path,
1586                        status: sc,
1587                        start,
1588                        cache_status: None,
1589                    },
1590                );
1591                return Ok(());
1592            }
1593        };
1594        let route = classify_route(&request);
1595        let mut response = route_request(request, config);
1596        record_http_metrics(route, response.status);
1597
1598        response.headers.push(("X-Request-Id", request_id.clone()));
1599
1600        let cache_status = extract_cache_status(&response.headers);
1601
1602        let sc = status_code(response.status).unwrap_or("unknown");
1603
1604        if is_head {
1605            response.body = Vec::new();
1606        }
1607
1608        requests_served += 1;
1609        let close_after = client_wants_close || requests_served >= KEEP_ALIVE_MAX_REQUESTS;
1610
1611        write_response(&mut stream, response, close_after)?;
1612        record_http_request_duration(route, start);
1613
1614        emit_access_log(
1615            config,
1616            &AccessLogEntry {
1617                request_id: &request_id,
1618                method: &method,
1619                path: &path,
1620                route: route.as_label(),
1621                status: sc,
1622                start,
1623                cache_status,
1624            },
1625        );
1626
1627        if close_after {
1628            return Ok(());
1629        }
1630    }
1631}
1632
1633fn route_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1634    let method = request.method.clone();
1635    let path = request.path().to_string();
1636
1637    match (method.as_str(), path.as_str()) {
1638        ("GET" | "HEAD", "/health") => handle_health(config),
1639        ("GET" | "HEAD", "/health/live") => handle_health_live(),
1640        ("GET" | "HEAD", "/health/ready") => handle_health_ready(config),
1641        ("GET" | "HEAD", "/images/by-path") => handle_public_path_request(request, config),
1642        ("GET" | "HEAD", "/images/by-url") => handle_public_url_request(request, config),
1643        ("POST", "/images:transform") => handle_transform_request(request, config),
1644        ("POST", "/images") => handle_upload_request(request, config),
1645        ("GET" | "HEAD", "/metrics") => handle_metrics_request(request, config),
1646        _ => HttpResponse::problem("404 Not Found", NOT_FOUND_BODY.as_bytes().to_vec()),
1647    }
1648}
1649
1650fn classify_route(request: &HttpRequest) -> RouteMetric {
1651    match (request.method.as_str(), request.path()) {
1652        ("GET" | "HEAD", "/health") => RouteMetric::Health,
1653        ("GET" | "HEAD", "/health/live") => RouteMetric::HealthLive,
1654        ("GET" | "HEAD", "/health/ready") => RouteMetric::HealthReady,
1655        ("GET" | "HEAD", "/images/by-path") => RouteMetric::PublicByPath,
1656        ("GET" | "HEAD", "/images/by-url") => RouteMetric::PublicByUrl,
1657        ("POST", "/images:transform") => RouteMetric::Transform,
1658        ("POST", "/images") => RouteMetric::Upload,
1659        ("GET" | "HEAD", "/metrics") => RouteMetric::Metrics,
1660        _ => RouteMetric::Unknown,
1661    }
1662}
1663
1664fn handle_transform_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1665    if let Err(response) = authorize_request(&request, config) {
1666        return response;
1667    }
1668
1669    if !request_has_json_content_type(&request) {
1670        return unsupported_media_type_response("content-type must be application/json");
1671    }
1672
1673    let payload: TransformImageRequestPayload = match serde_json::from_slice(&request.body) {
1674        Ok(payload) => payload,
1675        Err(error) => {
1676            return bad_request_response(&format!("request body must be valid JSON: {error}"));
1677        }
1678    };
1679    let options = match payload.options.into_options() {
1680        Ok(options) => options,
1681        Err(response) => return response,
1682    };
1683
1684    let versioned_hash = payload.source.versioned_source_hash(config);
1685    if let Some(response) = try_versioned_cache_lookup(
1686        versioned_hash.as_deref(),
1687        &options,
1688        &request,
1689        ImageResponsePolicy::PrivateTransform,
1690        config,
1691    ) {
1692        return response;
1693    }
1694
1695    let storage_start = Instant::now();
1696    let backend_label = payload.source.metrics_backend_label(config);
1697    let backend_idx = backend_label.map(|l| storage_backend_index_from_config(&l));
1698    let source_bytes = match resolve_source_bytes(payload.source, config) {
1699        Ok(bytes) => {
1700            if let Some(idx) = backend_idx {
1701                record_storage_duration(idx, storage_start);
1702            }
1703            bytes
1704        }
1705        Err(response) => {
1706            if let Some(idx) = backend_idx {
1707                record_storage_duration(idx, storage_start);
1708            }
1709            return response;
1710        }
1711    };
1712    transform_source_bytes(
1713        source_bytes,
1714        options,
1715        versioned_hash.as_deref(),
1716        &request,
1717        ImageResponsePolicy::PrivateTransform,
1718        config,
1719    )
1720}
1721
1722fn handle_public_path_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1723    handle_public_get_request(request, config, PublicSourceKind::Path)
1724}
1725
1726fn handle_public_url_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1727    handle_public_get_request(request, config, PublicSourceKind::Url)
1728}
1729
1730fn handle_public_get_request(
1731    request: HttpRequest,
1732    config: &ServerConfig,
1733    source_kind: PublicSourceKind,
1734) -> HttpResponse {
1735    let query = match parse_query_params(&request) {
1736        Ok(query) => query,
1737        Err(response) => return response,
1738    };
1739    if let Err(response) = authorize_signed_request(&request, &query, config) {
1740        return response;
1741    }
1742    let (source, options) = match parse_public_get_request(&query, source_kind) {
1743        Ok(parsed) => parsed,
1744        Err(response) => return response,
1745    };
1746
1747    // When the storage backend is object storage (S3 or GCS), convert Path
1748    // sources to Storage sources so that the `path` query parameter is
1749    // resolved as an object key.
1750    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1751    let source = if is_object_storage_backend(config) {
1752        match source {
1753            TransformSourcePayload::Path { path, version } => TransformSourcePayload::Storage {
1754                bucket: None,
1755                key: path.trim_start_matches('/').to_string(),
1756                version,
1757            },
1758            other => other,
1759        }
1760    } else {
1761        source
1762    };
1763
1764    let versioned_hash = source.versioned_source_hash(config);
1765    if let Some(response) = try_versioned_cache_lookup(
1766        versioned_hash.as_deref(),
1767        &options,
1768        &request,
1769        ImageResponsePolicy::PublicGet,
1770        config,
1771    ) {
1772        return response;
1773    }
1774
1775    let storage_start = Instant::now();
1776    let backend_label = source.metrics_backend_label(config);
1777    let backend_idx = backend_label.map(|l| storage_backend_index_from_config(&l));
1778    let source_bytes = match resolve_source_bytes(source, config) {
1779        Ok(bytes) => {
1780            if let Some(idx) = backend_idx {
1781                record_storage_duration(idx, storage_start);
1782            }
1783            bytes
1784        }
1785        Err(response) => {
1786            if let Some(idx) = backend_idx {
1787                record_storage_duration(idx, storage_start);
1788            }
1789            return response;
1790        }
1791    };
1792
1793    transform_source_bytes(
1794        source_bytes,
1795        options,
1796        versioned_hash.as_deref(),
1797        &request,
1798        ImageResponsePolicy::PublicGet,
1799        config,
1800    )
1801}
1802
1803fn handle_upload_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1804    if let Err(response) = authorize_request(&request, config) {
1805        return response;
1806    }
1807
1808    let boundary = match parse_multipart_boundary(&request) {
1809        Ok(boundary) => boundary,
1810        Err(response) => return response,
1811    };
1812    let (file_bytes, options) = match parse_upload_request(&request.body, &boundary) {
1813        Ok(parts) => parts,
1814        Err(response) => return response,
1815    };
1816    transform_source_bytes(
1817        file_bytes,
1818        options,
1819        None,
1820        &request,
1821        ImageResponsePolicy::PrivateTransform,
1822        config,
1823    )
1824}
1825
1826/// Returns a minimal liveness response confirming the process is running.
1827fn handle_health_live() -> HttpResponse {
1828    let body = serde_json::to_vec(&json!({
1829        "status": "ok",
1830        "service": "truss",
1831        "version": env!("CARGO_PKG_VERSION"),
1832    }))
1833    .expect("serialize liveness");
1834    let mut body = body;
1835    body.push(b'\n');
1836    HttpResponse::json("200 OK", body)
1837}
1838
1839/// Returns a readiness response after checking that critical infrastructure
1840/// dependencies are available (storage root, cache root if configured, S3
1841/// reachability).  Transform capacity is intentionally excluded — it is a
1842/// runtime signal reported only by the full `/health` diagnostic endpoint.
1843fn handle_health_ready(config: &ServerConfig) -> HttpResponse {
1844    let mut checks: Vec<serde_json::Value> = Vec::new();
1845    let mut all_ok = true;
1846
1847    for (ok, name) in storage_health_check(config) {
1848        checks.push(json!({
1849            "name": name,
1850            "status": if ok { "ok" } else { "fail" },
1851        }));
1852        if !ok {
1853            all_ok = false;
1854        }
1855    }
1856
1857    if let Some(cache_root) = &config.cache_root {
1858        let cache_ok = cache_root.is_dir();
1859        checks.push(json!({
1860            "name": "cacheRoot",
1861            "status": if cache_ok { "ok" } else { "fail" },
1862        }));
1863        if !cache_ok {
1864            all_ok = false;
1865        }
1866    }
1867
1868    let status_str = if all_ok { "ok" } else { "fail" };
1869    let mut body = serde_json::to_vec(&json!({
1870        "status": status_str,
1871        "checks": checks,
1872    }))
1873    .expect("serialize readiness");
1874    body.push(b'\n');
1875
1876    if all_ok {
1877        HttpResponse::json("200 OK", body)
1878    } else {
1879        HttpResponse::json("503 Service Unavailable", body)
1880    }
1881}
1882
1883/// Returns a comprehensive diagnostic health response.
1884fn storage_health_check(config: &ServerConfig) -> Vec<(bool, &'static str)> {
1885    #[allow(unused_mut)]
1886    let mut checks = vec![(config.storage_root.is_dir(), "storageRoot")];
1887    #[cfg(feature = "s3")]
1888    if config.storage_backend == StorageBackend::S3 {
1889        let reachable = config
1890            .s3_context
1891            .as_ref()
1892            .is_some_and(|ctx| ctx.check_reachable());
1893        checks.push((reachable, "storageBackend"));
1894    }
1895    #[cfg(feature = "gcs")]
1896    if config.storage_backend == StorageBackend::Gcs {
1897        let reachable = config
1898            .gcs_context
1899            .as_ref()
1900            .is_some_and(|ctx| ctx.check_reachable());
1901        checks.push((reachable, "storageBackend"));
1902    }
1903    #[cfg(feature = "azure")]
1904    if config.storage_backend == StorageBackend::Azure {
1905        let reachable = config
1906            .azure_context
1907            .as_ref()
1908            .is_some_and(|ctx| ctx.check_reachable());
1909        checks.push((reachable, "storageBackend"));
1910    }
1911    checks
1912}
1913
1914fn handle_health(config: &ServerConfig) -> HttpResponse {
1915    let mut checks: Vec<serde_json::Value> = Vec::new();
1916    let mut all_ok = true;
1917
1918    for (ok, name) in storage_health_check(config) {
1919        checks.push(json!({
1920            "name": name,
1921            "status": if ok { "ok" } else { "fail" },
1922        }));
1923        if !ok {
1924            all_ok = false;
1925        }
1926    }
1927
1928    if let Some(cache_root) = &config.cache_root {
1929        let cache_ok = cache_root.is_dir();
1930        checks.push(json!({
1931            "name": "cacheRoot",
1932            "status": if cache_ok { "ok" } else { "fail" },
1933        }));
1934        if !cache_ok {
1935            all_ok = false;
1936        }
1937    }
1938
1939    let in_flight = config.transforms_in_flight.load(Ordering::Relaxed);
1940    let overloaded = in_flight >= config.max_concurrent_transforms;
1941    checks.push(json!({
1942        "name": "transformCapacity",
1943        "status": if overloaded { "fail" } else { "ok" },
1944    }));
1945    if overloaded {
1946        all_ok = false;
1947    }
1948
1949    let status_str = if all_ok { "ok" } else { "fail" };
1950    let mut body = serde_json::to_vec(&json!({
1951        "status": status_str,
1952        "service": "truss",
1953        "version": env!("CARGO_PKG_VERSION"),
1954        "uptimeSeconds": uptime_seconds(),
1955        "checks": checks,
1956    }))
1957    .expect("serialize health");
1958    body.push(b'\n');
1959
1960    HttpResponse::json("200 OK", body)
1961}
1962
1963fn handle_metrics_request(_request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1964    HttpResponse::text(
1965        "200 OK",
1966        "text/plain; version=0.0.4; charset=utf-8",
1967        render_metrics_text(
1968            config.max_concurrent_transforms,
1969            &config.transforms_in_flight,
1970        )
1971        .into_bytes(),
1972    )
1973}
1974
1975fn parse_public_get_request(
1976    query: &BTreeMap<String, String>,
1977    source_kind: PublicSourceKind,
1978) -> Result<(TransformSourcePayload, TransformOptions), HttpResponse> {
1979    validate_public_query_names(query, source_kind)?;
1980
1981    let source = match source_kind {
1982        PublicSourceKind::Path => TransformSourcePayload::Path {
1983            path: required_query_param(query, "path")?.to_string(),
1984            version: query.get("version").cloned(),
1985        },
1986        PublicSourceKind::Url => TransformSourcePayload::Url {
1987            url: required_query_param(query, "url")?.to_string(),
1988            version: query.get("version").cloned(),
1989        },
1990    };
1991
1992    let defaults = TransformOptions::default();
1993    let options = TransformOptions {
1994        width: parse_optional_integer_query(query, "width")?,
1995        height: parse_optional_integer_query(query, "height")?,
1996        fit: parse_optional_named(query.get("fit").map(String::as_str), "fit", Fit::from_str)?,
1997        position: parse_optional_named(
1998            query.get("position").map(String::as_str),
1999            "position",
2000            Position::from_str,
2001        )?,
2002        format: parse_optional_named(
2003            query.get("format").map(String::as_str),
2004            "format",
2005            MediaType::from_str,
2006        )?,
2007        quality: parse_optional_u8_query(query, "quality")?,
2008        background: parse_optional_named(
2009            query.get("background").map(String::as_str),
2010            "background",
2011            Rgba8::from_hex,
2012        )?,
2013        rotate: match query.get("rotate") {
2014            Some(value) => parse_named(value, "rotate", Rotation::from_str)?,
2015            None => defaults.rotate,
2016        },
2017        auto_orient: parse_optional_bool_query(query, "autoOrient")?
2018            .unwrap_or(defaults.auto_orient),
2019        strip_metadata: parse_optional_bool_query(query, "stripMetadata")?
2020            .unwrap_or(defaults.strip_metadata),
2021        preserve_exif: parse_optional_bool_query(query, "preserveExif")?
2022            .unwrap_or(defaults.preserve_exif),
2023        blur: parse_optional_float_query(query, "blur")?,
2024        deadline: defaults.deadline,
2025    };
2026
2027    Ok((source, options))
2028}
2029
2030fn transform_source_bytes(
2031    source_bytes: Vec<u8>,
2032    options: TransformOptions,
2033    versioned_hash: Option<&str>,
2034    request: &HttpRequest,
2035    response_policy: ImageResponsePolicy,
2036    config: &ServerConfig,
2037) -> HttpResponse {
2038    let content_hash;
2039    let source_hash = match versioned_hash {
2040        Some(hash) => hash,
2041        None => {
2042            content_hash = hex::encode(Sha256::digest(&source_bytes));
2043            &content_hash
2044        }
2045    };
2046
2047    let cache = config
2048        .cache_root
2049        .as_ref()
2050        .map(|root| TransformCache::new(root.clone()).with_log_handler(config.log_handler.clone()));
2051
2052    if let Some(ref cache) = cache
2053        && options.format.is_some()
2054    {
2055        let cache_key = compute_cache_key(source_hash, &options, None);
2056        if let CacheLookup::Hit {
2057            media_type,
2058            body,
2059            age,
2060        } = cache.get(&cache_key)
2061        {
2062            CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
2063            let etag = build_image_etag(&body);
2064            let mut headers = build_image_response_headers(
2065                media_type,
2066                &etag,
2067                response_policy,
2068                false,
2069                CacheHitStatus::Hit,
2070                config.public_max_age_seconds,
2071                config.public_stale_while_revalidate_seconds,
2072            );
2073            headers.push(("Age", age.as_secs().to_string()));
2074            if matches!(response_policy, ImageResponsePolicy::PublicGet)
2075                && if_none_match_matches(request.header("if-none-match"), &etag)
2076            {
2077                return HttpResponse::empty("304 Not Modified", headers);
2078            }
2079            return HttpResponse::binary_with_headers(
2080                "200 OK",
2081                media_type.as_mime(),
2082                headers,
2083                body,
2084            );
2085        }
2086    }
2087
2088    let _slot = match TransformSlot::try_acquire(
2089        &config.transforms_in_flight,
2090        config.max_concurrent_transforms,
2091    ) {
2092        Some(slot) => slot,
2093        None => return service_unavailable_response("too many concurrent transforms; retry later"),
2094    };
2095    transform_source_bytes_inner(
2096        source_bytes,
2097        options,
2098        request,
2099        response_policy,
2100        cache.as_ref(),
2101        source_hash,
2102        ImageResponseConfig {
2103            disable_accept_negotiation: config.disable_accept_negotiation,
2104            public_cache_control: PublicCacheControl {
2105                max_age: config.public_max_age_seconds,
2106                stale_while_revalidate: config.public_stale_while_revalidate_seconds,
2107            },
2108            transform_deadline: Duration::from_secs(config.transform_deadline_secs),
2109        },
2110    )
2111}
2112
2113fn transform_source_bytes_inner(
2114    source_bytes: Vec<u8>,
2115    mut options: TransformOptions,
2116    request: &HttpRequest,
2117    response_policy: ImageResponsePolicy,
2118    cache: Option<&TransformCache>,
2119    source_hash: &str,
2120    response_config: ImageResponseConfig,
2121) -> HttpResponse {
2122    if options.deadline.is_none() {
2123        options.deadline = Some(response_config.transform_deadline);
2124    }
2125    let artifact = match sniff_artifact(RawArtifact::new(source_bytes, None)) {
2126        Ok(artifact) => artifact,
2127        Err(error) => {
2128            record_transform_error(&error);
2129            return transform_error_response(error);
2130        }
2131    };
2132    let negotiation_used =
2133        if options.format.is_none() && !response_config.disable_accept_negotiation {
2134            match negotiate_output_format(request.header("accept"), &artifact) {
2135                Ok(Some(format)) => {
2136                    options.format = Some(format);
2137                    true
2138                }
2139                Ok(None) => false,
2140                Err(response) => return response,
2141            }
2142        } else {
2143            false
2144        };
2145
2146    let negotiated_accept = if negotiation_used {
2147        request.header("accept")
2148    } else {
2149        None
2150    };
2151    let cache_key = compute_cache_key(source_hash, &options, negotiated_accept);
2152
2153    if let Some(cache) = cache
2154        && let CacheLookup::Hit {
2155            media_type,
2156            body,
2157            age,
2158        } = cache.get(&cache_key)
2159    {
2160        CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
2161        let etag = build_image_etag(&body);
2162        let mut headers = build_image_response_headers(
2163            media_type,
2164            &etag,
2165            response_policy,
2166            negotiation_used,
2167            CacheHitStatus::Hit,
2168            response_config.public_cache_control.max_age,
2169            response_config.public_cache_control.stale_while_revalidate,
2170        );
2171        headers.push(("Age", age.as_secs().to_string()));
2172        if matches!(response_policy, ImageResponsePolicy::PublicGet)
2173            && if_none_match_matches(request.header("if-none-match"), &etag)
2174        {
2175            return HttpResponse::empty("304 Not Modified", headers);
2176        }
2177        return HttpResponse::binary_with_headers("200 OK", media_type.as_mime(), headers, body);
2178    }
2179
2180    if cache.is_some() {
2181        CACHE_MISSES_TOTAL.fetch_add(1, Ordering::Relaxed);
2182    }
2183
2184    let is_svg = artifact.media_type == MediaType::Svg;
2185    let transform_start = Instant::now();
2186    let result = if is_svg {
2187        match transform_svg(TransformRequest::new(artifact, options)) {
2188            Ok(result) => result,
2189            Err(error) => {
2190                record_transform_error(&error);
2191                return transform_error_response(error);
2192            }
2193        }
2194    } else {
2195        match transform_raster(TransformRequest::new(artifact, options)) {
2196            Ok(result) => result,
2197            Err(error) => {
2198                record_transform_error(&error);
2199                return transform_error_response(error);
2200            }
2201        }
2202    };
2203    record_transform_duration(result.artifact.media_type, transform_start);
2204
2205    for warning in &result.warnings {
2206        let msg = format!("truss: {warning}");
2207        if let Some(c) = cache
2208            && let Some(handler) = &c.log_handler
2209        {
2210            handler(&msg);
2211        } else {
2212            stderr_write(&msg);
2213        }
2214    }
2215
2216    let output = result.artifact;
2217
2218    if let Some(cache) = cache {
2219        cache.put(&cache_key, output.media_type, &output.bytes);
2220    }
2221
2222    let cache_hit_status = if cache.is_some() {
2223        CacheHitStatus::Miss
2224    } else {
2225        CacheHitStatus::Disabled
2226    };
2227
2228    let etag = build_image_etag(&output.bytes);
2229    let headers = build_image_response_headers(
2230        output.media_type,
2231        &etag,
2232        response_policy,
2233        negotiation_used,
2234        cache_hit_status,
2235        response_config.public_cache_control.max_age,
2236        response_config.public_cache_control.stale_while_revalidate,
2237    );
2238
2239    if matches!(response_policy, ImageResponsePolicy::PublicGet)
2240        && if_none_match_matches(request.header("if-none-match"), &etag)
2241    {
2242        return HttpResponse::empty("304 Not Modified", headers);
2243    }
2244
2245    HttpResponse::binary_with_headers("200 OK", output.media_type.as_mime(), headers, output.bytes)
2246}
2247
2248fn env_flag(name: &str) -> bool {
2249    env::var(name)
2250        .map(|value| {
2251            matches!(
2252                value.as_str(),
2253                "1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON"
2254            )
2255        })
2256        .unwrap_or(false)
2257}
2258
2259fn parse_optional_env_u32(name: &str) -> io::Result<Option<u32>> {
2260    match env::var(name) {
2261        Ok(value) if !value.is_empty() => value.parse::<u32>().map(Some).map_err(|_| {
2262            io::Error::new(
2263                io::ErrorKind::InvalidInput,
2264                format!("{name} must be a non-negative integer"),
2265            )
2266        }),
2267        _ => Ok(None),
2268    }
2269}
2270
2271fn validate_public_base_url(value: String) -> io::Result<String> {
2272    let parsed = Url::parse(&value).map_err(|error| {
2273        io::Error::new(
2274            io::ErrorKind::InvalidInput,
2275            format!("TRUSS_PUBLIC_BASE_URL must be a valid URL: {error}"),
2276        )
2277    })?;
2278
2279    match parsed.scheme() {
2280        "http" | "https" => Ok(parsed.to_string()),
2281        _ => Err(io::Error::new(
2282            io::ErrorKind::InvalidInput,
2283            "TRUSS_PUBLIC_BASE_URL must use http or https",
2284        )),
2285    }
2286}
2287
2288#[cfg(test)]
2289mod tests {
2290    use serial_test::serial;
2291
2292    use super::http_parse::{
2293        HttpRequest, find_header_terminator, read_request_body, read_request_headers,
2294        resolve_storage_path,
2295    };
2296    use super::multipart::parse_multipart_form_data;
2297    use super::remote::{PinnedResolver, prepare_remote_fetch_target};
2298    use super::response::auth_required_response;
2299    use super::response::{HttpResponse, bad_request_response};
2300    use super::{
2301        CacheHitStatus, DEFAULT_BIND_ADDR, DEFAULT_MAX_CONCURRENT_TRANSFORMS,
2302        DEFAULT_PUBLIC_MAX_AGE_SECONDS, DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2303        ImageResponsePolicy, PublicSourceKind, ServerConfig, SignedUrlSource,
2304        TransformSourcePayload, authorize_signed_request, bind_addr, build_image_etag,
2305        build_image_response_headers, canonical_query_without_signature, negotiate_output_format,
2306        parse_public_get_request, route_request, serve_once_with_config, sign_public_url,
2307        transform_source_bytes,
2308    };
2309    use crate::{
2310        Artifact, ArtifactMetadata, MediaType, RawArtifact, TransformOptions, sniff_artifact,
2311    };
2312    use hmac::{Hmac, Mac};
2313    use image::codecs::png::PngEncoder;
2314    use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
2315    use sha2::Sha256;
2316    use std::collections::BTreeMap;
2317    use std::fs;
2318    use std::io::{Cursor, Read, Write};
2319    use std::net::{SocketAddr, TcpListener, TcpStream};
2320    use std::path::{Path, PathBuf};
2321    use std::sync::atomic::Ordering;
2322    use std::thread;
2323    use std::time::{Duration, SystemTime, UNIX_EPOCH};
2324
2325    /// Test-only convenience wrapper that reads headers + body in one shot,
2326    /// preserving the original `read_request` semantics for existing tests.
2327    fn read_request<R: Read>(stream: &mut R) -> Result<HttpRequest, HttpResponse> {
2328        let partial = read_request_headers(stream)?;
2329        read_request_body(stream, partial)
2330    }
2331
2332    fn png_bytes() -> Vec<u8> {
2333        let image = RgbaImage::from_pixel(4, 3, Rgba([10, 20, 30, 255]));
2334        let mut bytes = Vec::new();
2335        PngEncoder::new(&mut bytes)
2336            .write_image(&image, 4, 3, ColorType::Rgba8.into())
2337            .expect("encode png");
2338        bytes
2339    }
2340
2341    fn temp_dir(name: &str) -> PathBuf {
2342        let unique = SystemTime::now()
2343            .duration_since(UNIX_EPOCH)
2344            .expect("current time")
2345            .as_nanos();
2346        let path = std::env::temp_dir().join(format!("truss-server-{name}-{unique}"));
2347        fs::create_dir_all(&path).expect("create temp dir");
2348        path
2349    }
2350
2351    fn write_png(path: &Path) {
2352        fs::write(path, png_bytes()).expect("write png fixture");
2353    }
2354
2355    fn artifact_with_alpha(has_alpha: bool) -> Artifact {
2356        Artifact::new(
2357            png_bytes(),
2358            MediaType::Png,
2359            ArtifactMetadata {
2360                width: Some(4),
2361                height: Some(3),
2362                frame_count: 1,
2363                duration: None,
2364                has_alpha: Some(has_alpha),
2365            },
2366        )
2367    }
2368
2369    fn sign_public_query(
2370        method: &str,
2371        authority: &str,
2372        path: &str,
2373        query: &BTreeMap<String, String>,
2374        secret: &str,
2375    ) -> String {
2376        let canonical = format!(
2377            "{method}\n{authority}\n{path}\n{}",
2378            canonical_query_without_signature(query)
2379        );
2380        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("create hmac");
2381        mac.update(canonical.as_bytes());
2382        hex::encode(mac.finalize().into_bytes())
2383    }
2384
2385    type FixtureResponse = (String, Vec<(String, String)>, Vec<u8>);
2386
2387    fn read_fixture_request(stream: &mut TcpStream) {
2388        stream
2389            .set_nonblocking(false)
2390            .expect("configure fixture stream blocking mode");
2391        stream
2392            .set_read_timeout(Some(Duration::from_millis(100)))
2393            .expect("configure fixture stream timeout");
2394
2395        let deadline = std::time::Instant::now() + Duration::from_secs(2);
2396        let mut buffer = Vec::new();
2397        let mut chunk = [0_u8; 1024];
2398        let header_end = loop {
2399            let read = match stream.read(&mut chunk) {
2400                Ok(read) => read,
2401                Err(error)
2402                    if matches!(
2403                        error.kind(),
2404                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
2405                    ) && std::time::Instant::now() < deadline =>
2406                {
2407                    thread::sleep(Duration::from_millis(10));
2408                    continue;
2409                }
2410                Err(error) => panic!("read fixture request headers: {error}"),
2411            };
2412            if read == 0 {
2413                panic!("fixture request ended before headers were complete");
2414            }
2415            buffer.extend_from_slice(&chunk[..read]);
2416            if let Some(index) = find_header_terminator(&buffer) {
2417                break index;
2418            }
2419        };
2420
2421        let header_text = std::str::from_utf8(&buffer[..header_end]).expect("fixture request utf8");
2422        let content_length = header_text
2423            .split("\r\n")
2424            .filter_map(|line| line.split_once(':'))
2425            .find_map(|(name, value)| {
2426                name.trim()
2427                    .eq_ignore_ascii_case("content-length")
2428                    .then_some(value.trim())
2429            })
2430            .map(|value| {
2431                value
2432                    .parse::<usize>()
2433                    .expect("fixture content-length should be numeric")
2434            })
2435            .unwrap_or(0);
2436
2437        let mut body = buffer.len().saturating_sub(header_end + 4);
2438        while body < content_length {
2439            let read = match stream.read(&mut chunk) {
2440                Ok(read) => read,
2441                Err(error)
2442                    if matches!(
2443                        error.kind(),
2444                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
2445                    ) && std::time::Instant::now() < deadline =>
2446                {
2447                    thread::sleep(Duration::from_millis(10));
2448                    continue;
2449                }
2450                Err(error) => panic!("read fixture request body: {error}"),
2451            };
2452            if read == 0 {
2453                panic!("fixture request body was truncated");
2454            }
2455            body += read;
2456        }
2457    }
2458
2459    fn spawn_http_server(responses: Vec<FixtureResponse>) -> (String, thread::JoinHandle<()>) {
2460        let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture server");
2461        listener
2462            .set_nonblocking(true)
2463            .expect("configure fixture server");
2464        let addr = listener.local_addr().expect("fixture server addr");
2465        let url = format!("http://{addr}/image");
2466
2467        let handle = thread::spawn(move || {
2468            for (status, headers, body) in responses {
2469                let deadline = std::time::Instant::now() + Duration::from_secs(10);
2470                let mut accepted = None;
2471                while std::time::Instant::now() < deadline {
2472                    match listener.accept() {
2473                        Ok(stream) => {
2474                            accepted = Some(stream);
2475                            break;
2476                        }
2477                        Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
2478                            thread::sleep(Duration::from_millis(10));
2479                        }
2480                        Err(error) => panic!("accept fixture request: {error}"),
2481                    }
2482                }
2483
2484                let Some((mut stream, _)) = accepted else {
2485                    break;
2486                };
2487                read_fixture_request(&mut stream);
2488                let mut header = format!(
2489                    "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
2490                    body.len()
2491                );
2492                for (name, value) in headers {
2493                    header.push_str(&format!("{name}: {value}\r\n"));
2494                }
2495                header.push_str("\r\n");
2496                stream
2497                    .write_all(header.as_bytes())
2498                    .expect("write fixture headers");
2499                stream.write_all(&body).expect("write fixture body");
2500                stream.flush().expect("flush fixture response");
2501            }
2502        });
2503
2504        (url, handle)
2505    }
2506
2507    fn transform_request(path: &str) -> HttpRequest {
2508        HttpRequest {
2509            method: "POST".to_string(),
2510            target: "/images:transform".to_string(),
2511            version: "HTTP/1.1".to_string(),
2512            headers: vec![
2513                ("authorization".to_string(), "Bearer secret".to_string()),
2514                ("content-type".to_string(), "application/json".to_string()),
2515            ],
2516            body: format!(
2517                "{{\"source\":{{\"kind\":\"path\",\"path\":\"{path}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
2518            )
2519            .into_bytes(),
2520        }
2521    }
2522
2523    fn transform_url_request(url: &str) -> HttpRequest {
2524        HttpRequest {
2525            method: "POST".to_string(),
2526            target: "/images:transform".to_string(),
2527            version: "HTTP/1.1".to_string(),
2528            headers: vec![
2529                ("authorization".to_string(), "Bearer secret".to_string()),
2530                ("content-type".to_string(), "application/json".to_string()),
2531            ],
2532            body: format!(
2533                "{{\"source\":{{\"kind\":\"url\",\"url\":\"{url}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
2534            )
2535            .into_bytes(),
2536        }
2537    }
2538
2539    fn upload_request(file_bytes: &[u8], options_json: Option<&str>) -> HttpRequest {
2540        let boundary = "truss-test-boundary";
2541        let mut body = Vec::new();
2542        body.extend_from_slice(
2543            format!(
2544                "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"image.png\"\r\nContent-Type: image/png\r\n\r\n"
2545            )
2546            .as_bytes(),
2547        );
2548        body.extend_from_slice(file_bytes);
2549        body.extend_from_slice(b"\r\n");
2550
2551        if let Some(options_json) = options_json {
2552            body.extend_from_slice(
2553                format!(
2554                    "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{options_json}\r\n"
2555                )
2556                .as_bytes(),
2557            );
2558        }
2559
2560        body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
2561
2562        HttpRequest {
2563            method: "POST".to_string(),
2564            target: "/images".to_string(),
2565            version: "HTTP/1.1".to_string(),
2566            headers: vec![
2567                ("authorization".to_string(), "Bearer secret".to_string()),
2568                (
2569                    "content-type".to_string(),
2570                    format!("multipart/form-data; boundary={boundary}"),
2571                ),
2572            ],
2573            body,
2574        }
2575    }
2576
2577    fn metrics_request(with_auth: bool) -> HttpRequest {
2578        let mut headers = Vec::new();
2579        if with_auth {
2580            headers.push(("authorization".to_string(), "Bearer secret".to_string()));
2581        }
2582
2583        HttpRequest {
2584            method: "GET".to_string(),
2585            target: "/metrics".to_string(),
2586            version: "HTTP/1.1".to_string(),
2587            headers,
2588            body: Vec::new(),
2589        }
2590    }
2591
2592    fn response_body(response: &HttpResponse) -> &str {
2593        std::str::from_utf8(&response.body).expect("utf8 response body")
2594    }
2595
2596    fn signed_public_request(target: &str, host: &str, secret: &str) -> HttpRequest {
2597        let (path, query) = target.split_once('?').expect("target has query");
2598        let mut query = url::form_urlencoded::parse(query.as_bytes())
2599            .into_owned()
2600            .collect::<BTreeMap<_, _>>();
2601        let signature = sign_public_query("GET", host, path, &query, secret);
2602        query.insert("signature".to_string(), signature);
2603        let final_query = url::form_urlencoded::Serializer::new(String::new())
2604            .extend_pairs(
2605                query
2606                    .iter()
2607                    .map(|(name, value)| (name.as_str(), value.as_str())),
2608            )
2609            .finish();
2610
2611        HttpRequest {
2612            method: "GET".to_string(),
2613            target: format!("{path}?{final_query}"),
2614            version: "HTTP/1.1".to_string(),
2615            headers: vec![("host".to_string(), host.to_string())],
2616            body: Vec::new(),
2617        }
2618    }
2619
2620    #[test]
2621    fn uses_default_bind_addr_when_env_is_missing() {
2622        unsafe { std::env::remove_var("TRUSS_BIND_ADDR") };
2623        assert_eq!(bind_addr(), DEFAULT_BIND_ADDR);
2624    }
2625
2626    #[test]
2627    fn authorize_signed_request_accepts_a_valid_signature() {
2628        let request = signed_public_request(
2629            "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
2630            "assets.example.com",
2631            "secret-value",
2632        );
2633        let query = super::auth::parse_query_params(&request).expect("parse query");
2634        let config = ServerConfig::new(temp_dir("public-auth"), None)
2635            .with_signed_url_credentials("public-dev", "secret-value");
2636
2637        authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
2638    }
2639
2640    #[test]
2641    fn authorize_signed_request_uses_public_base_url_authority() {
2642        let request = signed_public_request(
2643            "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
2644            "cdn.example.com",
2645            "secret-value",
2646        );
2647        let query = super::auth::parse_query_params(&request).expect("parse query");
2648        let mut config = ServerConfig::new(temp_dir("public-authority"), None)
2649            .with_signed_url_credentials("public-dev", "secret-value");
2650        config.public_base_url = Some("https://cdn.example.com".to_string());
2651
2652        authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
2653    }
2654
2655    #[test]
2656    fn negotiate_output_format_prefers_alpha_safe_formats_for_transparent_inputs() {
2657        let format =
2658            negotiate_output_format(Some("image/jpeg,image/png"), &artifact_with_alpha(true))
2659                .expect("negotiate output format")
2660                .expect("resolved output format");
2661
2662        assert_eq!(format, MediaType::Png);
2663    }
2664
2665    #[test]
2666    fn negotiate_output_format_prefers_avif_for_wildcard_accept() {
2667        let format = negotiate_output_format(Some("image/*"), &artifact_with_alpha(false))
2668            .expect("negotiate output format")
2669            .expect("resolved output format");
2670
2671        assert_eq!(format, MediaType::Avif);
2672    }
2673
2674    #[test]
2675    fn build_image_response_headers_include_cache_and_safety_metadata() {
2676        let headers = build_image_response_headers(
2677            MediaType::Webp,
2678            &build_image_etag(b"demo"),
2679            ImageResponsePolicy::PublicGet,
2680            true,
2681            CacheHitStatus::Disabled,
2682            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2683            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2684        );
2685
2686        assert!(headers.contains(&(
2687            "Cache-Control",
2688            "public, max-age=3600, stale-while-revalidate=60".to_string()
2689        )));
2690        assert!(headers.contains(&("Vary", "Accept".to_string())));
2691        assert!(headers.contains(&("X-Content-Type-Options", "nosniff".to_string())));
2692        assert!(headers.contains(&(
2693            "Content-Disposition",
2694            "inline; filename=\"truss.webp\"".to_string()
2695        )));
2696        assert!(headers.contains(&("Cache-Status", "\"truss\"; fwd=miss".to_string())));
2697    }
2698
2699    #[test]
2700    fn build_image_response_headers_include_csp_sandbox_for_svg() {
2701        let headers = build_image_response_headers(
2702            MediaType::Svg,
2703            &build_image_etag(b"svg-data"),
2704            ImageResponsePolicy::PublicGet,
2705            true,
2706            CacheHitStatus::Disabled,
2707            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2708            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2709        );
2710
2711        assert!(headers.contains(&("Content-Security-Policy", "sandbox".to_string())));
2712    }
2713
2714    #[test]
2715    fn build_image_response_headers_omit_csp_sandbox_for_raster() {
2716        let headers = build_image_response_headers(
2717            MediaType::Png,
2718            &build_image_etag(b"png-data"),
2719            ImageResponsePolicy::PublicGet,
2720            true,
2721            CacheHitStatus::Disabled,
2722            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2723            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2724        );
2725
2726        assert!(!headers.iter().any(|(k, _)| *k == "Content-Security-Policy"));
2727    }
2728
2729    #[test]
2730    fn backpressure_rejects_when_at_capacity() {
2731        let config = ServerConfig::new(std::env::temp_dir(), None);
2732        config
2733            .transforms_in_flight
2734            .store(DEFAULT_MAX_CONCURRENT_TRANSFORMS, Ordering::Relaxed);
2735
2736        let request = HttpRequest {
2737            method: "POST".to_string(),
2738            target: "/transform".to_string(),
2739            version: "HTTP/1.1".to_string(),
2740            headers: Vec::new(),
2741            body: Vec::new(),
2742        };
2743
2744        let png_bytes = {
2745            let mut buf = Vec::new();
2746            let encoder = image::codecs::png::PngEncoder::new(&mut buf);
2747            encoder
2748                .write_image(&[255, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8)
2749                .unwrap();
2750            buf
2751        };
2752
2753        let response = transform_source_bytes(
2754            png_bytes,
2755            TransformOptions::default(),
2756            None,
2757            &request,
2758            ImageResponsePolicy::PrivateTransform,
2759            &config,
2760        );
2761
2762        assert!(response.status.contains("503"));
2763
2764        assert_eq!(
2765            config.transforms_in_flight.load(Ordering::Relaxed),
2766            DEFAULT_MAX_CONCURRENT_TRANSFORMS
2767        );
2768    }
2769
2770    #[test]
2771    fn backpressure_rejects_with_custom_concurrency_limit() {
2772        let custom_limit = 2u64;
2773        let mut config = ServerConfig::new(std::env::temp_dir(), None);
2774        config.max_concurrent_transforms = custom_limit;
2775        config
2776            .transforms_in_flight
2777            .store(custom_limit, Ordering::Relaxed);
2778
2779        let request = HttpRequest {
2780            method: "POST".to_string(),
2781            target: "/transform".to_string(),
2782            version: "HTTP/1.1".to_string(),
2783            headers: Vec::new(),
2784            body: Vec::new(),
2785        };
2786
2787        let png_bytes = {
2788            let mut buf = Vec::new();
2789            let encoder = image::codecs::png::PngEncoder::new(&mut buf);
2790            encoder
2791                .write_image(&[255, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8)
2792                .unwrap();
2793            buf
2794        };
2795
2796        let response = transform_source_bytes(
2797            png_bytes,
2798            TransformOptions::default(),
2799            None,
2800            &request,
2801            ImageResponsePolicy::PrivateTransform,
2802            &config,
2803        );
2804
2805        assert!(response.status.contains("503"));
2806    }
2807
2808    #[test]
2809    fn compute_cache_key_is_deterministic() {
2810        let opts = TransformOptions {
2811            width: Some(300),
2812            height: Some(200),
2813            format: Some(MediaType::Webp),
2814            ..TransformOptions::default()
2815        };
2816        let key1 = super::cache::compute_cache_key("source-abc", &opts, None);
2817        let key2 = super::cache::compute_cache_key("source-abc", &opts, None);
2818        assert_eq!(key1, key2);
2819        assert_eq!(key1.len(), 64);
2820    }
2821
2822    #[test]
2823    fn compute_cache_key_differs_for_different_options() {
2824        let opts1 = TransformOptions {
2825            width: Some(300),
2826            format: Some(MediaType::Webp),
2827            ..TransformOptions::default()
2828        };
2829        let opts2 = TransformOptions {
2830            width: Some(400),
2831            format: Some(MediaType::Webp),
2832            ..TransformOptions::default()
2833        };
2834        let key1 = super::cache::compute_cache_key("same-source", &opts1, None);
2835        let key2 = super::cache::compute_cache_key("same-source", &opts2, None);
2836        assert_ne!(key1, key2);
2837    }
2838
2839    #[test]
2840    fn compute_cache_key_includes_accept_when_present() {
2841        let opts = TransformOptions::default();
2842        let key_no_accept = super::cache::compute_cache_key("src", &opts, None);
2843        let key_with_accept = super::cache::compute_cache_key("src", &opts, Some("image/webp"));
2844        assert_ne!(key_no_accept, key_with_accept);
2845    }
2846
2847    #[test]
2848    fn transform_cache_put_and_get_round_trips() {
2849        let dir = tempfile::tempdir().expect("create tempdir");
2850        let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2851
2852        cache.put(
2853            "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
2854            MediaType::Png,
2855            b"png-data",
2856        );
2857        let result = cache.get("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
2858
2859        match result {
2860            super::cache::CacheLookup::Hit {
2861                media_type, body, ..
2862            } => {
2863                assert_eq!(media_type, MediaType::Png);
2864                assert_eq!(body, b"png-data");
2865            }
2866            super::cache::CacheLookup::Miss => panic!("expected cache hit"),
2867        }
2868    }
2869
2870    #[test]
2871    fn transform_cache_miss_for_unknown_key() {
2872        let dir = tempfile::tempdir().expect("create tempdir");
2873        let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2874
2875        let result = cache.get("0000001234567890abcdef1234567890abcdef1234567890abcdef1234567890");
2876        assert!(matches!(result, super::cache::CacheLookup::Miss));
2877    }
2878
2879    #[test]
2880    fn transform_cache_uses_sharded_layout() {
2881        let dir = tempfile::tempdir().expect("create tempdir");
2882        let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2883
2884        let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2885        cache.put(key, MediaType::Jpeg, b"jpeg-data");
2886
2887        let expected = dir.path().join("ab").join("cd").join("ef").join(key);
2888        assert!(
2889            expected.exists(),
2890            "sharded file should exist at {expected:?}"
2891        );
2892    }
2893
2894    #[test]
2895    fn transform_cache_expired_entry_is_miss() {
2896        let dir = tempfile::tempdir().expect("create tempdir");
2897        let mut cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2898        cache.ttl = Duration::from_secs(0);
2899
2900        let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2901        cache.put(key, MediaType::Png, b"data");
2902
2903        std::thread::sleep(Duration::from_millis(10));
2904
2905        let result = cache.get(key);
2906        assert!(matches!(result, super::cache::CacheLookup::Miss));
2907    }
2908
2909    #[test]
2910    fn transform_cache_handles_corrupted_entry_as_miss() {
2911        let dir = tempfile::tempdir().expect("create tempdir");
2912        let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2913
2914        let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2915        let path = cache.entry_path(key);
2916        fs::create_dir_all(path.parent().unwrap()).unwrap();
2917        fs::write(&path, b"corrupted-data-without-header").unwrap();
2918
2919        let result = cache.get(key);
2920        assert!(matches!(result, super::cache::CacheLookup::Miss));
2921    }
2922
2923    #[test]
2924    fn cache_status_header_reflects_hit() {
2925        let headers = build_image_response_headers(
2926            MediaType::Png,
2927            &build_image_etag(b"data"),
2928            ImageResponsePolicy::PublicGet,
2929            false,
2930            CacheHitStatus::Hit,
2931            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2932            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2933        );
2934        assert!(headers.contains(&("Cache-Status", "\"truss\"; hit".to_string())));
2935    }
2936
2937    #[test]
2938    fn cache_status_header_reflects_miss() {
2939        let headers = build_image_response_headers(
2940            MediaType::Png,
2941            &build_image_etag(b"data"),
2942            ImageResponsePolicy::PublicGet,
2943            false,
2944            CacheHitStatus::Miss,
2945            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2946            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2947        );
2948        assert!(headers.contains(&("Cache-Status", "\"truss\"; fwd=miss".to_string())));
2949    }
2950
2951    #[test]
2952    fn origin_cache_put_and_get_round_trips() {
2953        let dir = tempfile::tempdir().expect("create tempdir");
2954        let cache = super::cache::OriginCache::new(dir.path());
2955
2956        cache.put("https://example.com/image.png", b"raw-source-bytes");
2957        let result = cache.get("https://example.com/image.png");
2958
2959        assert_eq!(result.as_deref(), Some(b"raw-source-bytes".as_ref()));
2960    }
2961
2962    #[test]
2963    fn origin_cache_miss_for_unknown_url() {
2964        let dir = tempfile::tempdir().expect("create tempdir");
2965        let cache = super::cache::OriginCache::new(dir.path());
2966
2967        assert!(
2968            cache
2969                .get("https://unknown.example.com/missing.png")
2970                .is_none()
2971        );
2972    }
2973
2974    #[test]
2975    fn origin_cache_expired_entry_is_none() {
2976        let dir = tempfile::tempdir().expect("create tempdir");
2977        let mut cache = super::cache::OriginCache::new(dir.path());
2978        cache.ttl = Duration::from_secs(0);
2979
2980        cache.put("https://example.com/img.png", b"data");
2981        std::thread::sleep(Duration::from_millis(10));
2982
2983        assert!(cache.get("https://example.com/img.png").is_none());
2984    }
2985
2986    #[test]
2987    fn origin_cache_uses_origin_subdirectory() {
2988        let dir = tempfile::tempdir().expect("create tempdir");
2989        let cache = super::cache::OriginCache::new(dir.path());
2990
2991        cache.put("https://example.com/test.png", b"bytes");
2992
2993        let origin_dir = dir.path().join("origin");
2994        assert!(origin_dir.exists(), "origin subdirectory should exist");
2995    }
2996
2997    #[test]
2998    fn sign_public_url_builds_a_signed_path_url() {
2999        let url = sign_public_url(
3000            "https://cdn.example.com",
3001            SignedUrlSource::Path {
3002                path: "/image.png".to_string(),
3003                version: Some("v1".to_string()),
3004            },
3005            &crate::TransformOptions {
3006                format: Some(MediaType::Jpeg),
3007                width: Some(320),
3008                ..crate::TransformOptions::default()
3009            },
3010            "public-dev",
3011            "secret-value",
3012            4_102_444_800,
3013        )
3014        .expect("sign public URL");
3015
3016        assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
3017        assert!(url.contains("path=%2Fimage.png"));
3018        assert!(url.contains("version=v1"));
3019        assert!(url.contains("width=320"));
3020        assert!(url.contains("format=jpeg"));
3021        assert!(url.contains("keyId=public-dev"));
3022        assert!(url.contains("expires=4102444800"));
3023        assert!(url.contains("signature="));
3024    }
3025
3026    #[test]
3027    fn parse_public_get_request_rejects_unknown_query_parameters() {
3028        let query = BTreeMap::from([
3029            ("path".to_string(), "/image.png".to_string()),
3030            ("keyId".to_string(), "public-dev".to_string()),
3031            ("expires".to_string(), "4102444800".to_string()),
3032            ("signature".to_string(), "deadbeef".to_string()),
3033            ("unexpected".to_string(), "value".to_string()),
3034        ]);
3035
3036        let response = parse_public_get_request(&query, PublicSourceKind::Path)
3037            .expect_err("unknown query should fail");
3038
3039        assert_eq!(response.status, "400 Bad Request");
3040        assert!(response_body(&response).contains("is not supported"));
3041    }
3042
3043    #[test]
3044    fn prepare_remote_fetch_target_pins_the_validated_netloc() {
3045        let target = prepare_remote_fetch_target(
3046            "http://1.1.1.1/image.png",
3047            &ServerConfig::new(temp_dir("pin"), Some("secret".to_string())),
3048        )
3049        .expect("prepare remote target");
3050
3051        assert_eq!(target.netloc, "1.1.1.1:80");
3052        assert_eq!(target.addrs, vec![SocketAddr::from(([1, 1, 1, 1], 80))]);
3053    }
3054
3055    #[test]
3056    fn pinned_resolver_rejects_unexpected_netlocs() {
3057        use ureq::unversioned::resolver::Resolver;
3058
3059        let resolver = PinnedResolver {
3060            expected_netloc: "example.com:443".to_string(),
3061            addrs: vec![SocketAddr::from(([93, 184, 216, 34], 443))],
3062        };
3063
3064        let config = ureq::config::Config::builder().build();
3065        let timeout = ureq::unversioned::transport::NextTimeout {
3066            after: ureq::unversioned::transport::time::Duration::Exact(
3067                std::time::Duration::from_secs(30),
3068            ),
3069            reason: ureq::Timeout::Resolve,
3070        };
3071
3072        let uri: ureq::http::Uri = "https://example.com/path".parse().unwrap();
3073        let result = resolver
3074            .resolve(&uri, &config, timeout)
3075            .expect("resolve expected netloc");
3076        assert_eq!(&result[..], &[SocketAddr::from(([93, 184, 216, 34], 443))]);
3077
3078        let bad_uri: ureq::http::Uri = "https://proxy.example:8080/path".parse().unwrap();
3079        let timeout2 = ureq::unversioned::transport::NextTimeout {
3080            after: ureq::unversioned::transport::time::Duration::Exact(
3081                std::time::Duration::from_secs(30),
3082            ),
3083            reason: ureq::Timeout::Resolve,
3084        };
3085        let error = resolver
3086            .resolve(&bad_uri, &config, timeout2)
3087            .expect_err("unexpected netloc should fail");
3088        assert!(matches!(error, ureq::Error::HostNotFound));
3089    }
3090
3091    #[test]
3092    fn health_live_returns_status_service_version() {
3093        let request = HttpRequest {
3094            method: "GET".to_string(),
3095            target: "/health/live".to_string(),
3096            version: "HTTP/1.1".to_string(),
3097            headers: Vec::new(),
3098            body: Vec::new(),
3099        };
3100
3101        let response = route_request(request, &ServerConfig::new(temp_dir("live"), None));
3102
3103        assert_eq!(response.status, "200 OK");
3104        let body: serde_json::Value =
3105            serde_json::from_slice(&response.body).expect("parse live body");
3106        assert_eq!(body["status"], "ok");
3107        assert_eq!(body["service"], "truss");
3108        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
3109    }
3110
3111    #[test]
3112    fn health_ready_returns_ok_when_storage_exists() {
3113        let storage = temp_dir("ready-ok");
3114        let request = HttpRequest {
3115            method: "GET".to_string(),
3116            target: "/health/ready".to_string(),
3117            version: "HTTP/1.1".to_string(),
3118            headers: Vec::new(),
3119            body: Vec::new(),
3120        };
3121
3122        let response = route_request(request, &ServerConfig::new(storage, None));
3123
3124        assert_eq!(response.status, "200 OK");
3125        let body: serde_json::Value =
3126            serde_json::from_slice(&response.body).expect("parse ready body");
3127        assert_eq!(body["status"], "ok");
3128        let checks = body["checks"].as_array().expect("checks array");
3129        assert!(
3130            checks
3131                .iter()
3132                .any(|c| c["name"] == "storageRoot" && c["status"] == "ok")
3133        );
3134    }
3135
3136    #[test]
3137    fn health_ready_returns_503_when_storage_missing() {
3138        let request = HttpRequest {
3139            method: "GET".to_string(),
3140            target: "/health/ready".to_string(),
3141            version: "HTTP/1.1".to_string(),
3142            headers: Vec::new(),
3143            body: Vec::new(),
3144        };
3145
3146        let config = ServerConfig::new(PathBuf::from("/nonexistent-truss-test-dir"), None);
3147        let response = route_request(request, &config);
3148
3149        assert_eq!(response.status, "503 Service Unavailable");
3150        let body: serde_json::Value =
3151            serde_json::from_slice(&response.body).expect("parse ready fail body");
3152        assert_eq!(body["status"], "fail");
3153        let checks = body["checks"].as_array().expect("checks array");
3154        assert!(
3155            checks
3156                .iter()
3157                .any(|c| c["name"] == "storageRoot" && c["status"] == "fail")
3158        );
3159    }
3160
3161    #[test]
3162    fn health_ready_returns_503_when_cache_root_missing() {
3163        let storage = temp_dir("ready-cache-fail");
3164        let mut config = ServerConfig::new(storage, None);
3165        config.cache_root = Some(PathBuf::from("/nonexistent-truss-cache-dir"));
3166
3167        let request = HttpRequest {
3168            method: "GET".to_string(),
3169            target: "/health/ready".to_string(),
3170            version: "HTTP/1.1".to_string(),
3171            headers: Vec::new(),
3172            body: Vec::new(),
3173        };
3174
3175        let response = route_request(request, &config);
3176
3177        assert_eq!(response.status, "503 Service Unavailable");
3178        let body: serde_json::Value =
3179            serde_json::from_slice(&response.body).expect("parse ready cache body");
3180        assert_eq!(body["status"], "fail");
3181        let checks = body["checks"].as_array().expect("checks array");
3182        assert!(
3183            checks
3184                .iter()
3185                .any(|c| c["name"] == "cacheRoot" && c["status"] == "fail")
3186        );
3187    }
3188
3189    #[test]
3190    fn health_returns_comprehensive_diagnostic() {
3191        let storage = temp_dir("health-diag");
3192        let request = HttpRequest {
3193            method: "GET".to_string(),
3194            target: "/health".to_string(),
3195            version: "HTTP/1.1".to_string(),
3196            headers: Vec::new(),
3197            body: Vec::new(),
3198        };
3199
3200        let response = route_request(request, &ServerConfig::new(storage, None));
3201
3202        assert_eq!(response.status, "200 OK");
3203        let body: serde_json::Value =
3204            serde_json::from_slice(&response.body).expect("parse health body");
3205        assert_eq!(body["status"], "ok");
3206        assert_eq!(body["service"], "truss");
3207        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
3208        assert!(body["uptimeSeconds"].is_u64());
3209        assert!(body["checks"].is_array());
3210    }
3211
3212    #[test]
3213    fn unknown_path_returns_not_found() {
3214        let request = HttpRequest {
3215            method: "GET".to_string(),
3216            target: "/unknown".to_string(),
3217            version: "HTTP/1.1".to_string(),
3218            headers: Vec::new(),
3219            body: Vec::new(),
3220        };
3221
3222        let response = route_request(request, &ServerConfig::new(temp_dir("not-found"), None));
3223
3224        assert_eq!(response.status, "404 Not Found");
3225        assert_eq!(response.content_type, Some("application/problem+json"));
3226        let body = response_body(&response);
3227        assert!(body.contains("\"type\":\"about:blank\""));
3228        assert!(body.contains("\"title\":\"Not Found\""));
3229        assert!(body.contains("\"status\":404"));
3230        assert!(body.contains("not found"));
3231    }
3232
3233    #[test]
3234    fn transform_endpoint_requires_authentication() {
3235        let storage_root = temp_dir("auth");
3236        write_png(&storage_root.join("image.png"));
3237        let mut request = transform_request("/image.png");
3238        request.headers.retain(|(name, _)| name != "authorization");
3239
3240        let response = route_request(
3241            request,
3242            &ServerConfig::new(storage_root, Some("secret".to_string())),
3243        );
3244
3245        assert_eq!(response.status, "401 Unauthorized");
3246        assert!(response_body(&response).contains("authorization required"));
3247    }
3248
3249    #[test]
3250    fn transform_endpoint_returns_service_unavailable_without_configured_token() {
3251        let storage_root = temp_dir("token");
3252        write_png(&storage_root.join("image.png"));
3253
3254        let response = route_request(
3255            transform_request("/image.png"),
3256            &ServerConfig::new(storage_root, None),
3257        );
3258
3259        assert_eq!(response.status, "503 Service Unavailable");
3260        assert!(response_body(&response).contains("bearer token is not configured"));
3261    }
3262
3263    #[test]
3264    fn transform_endpoint_transforms_a_path_source() {
3265        let storage_root = temp_dir("transform");
3266        write_png(&storage_root.join("image.png"));
3267
3268        let response = route_request(
3269            transform_request("/image.png"),
3270            &ServerConfig::new(storage_root, Some("secret".to_string())),
3271        );
3272
3273        assert_eq!(response.status, "200 OK");
3274        assert_eq!(response.content_type, Some("image/jpeg"));
3275
3276        let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
3277        assert_eq!(artifact.media_type, MediaType::Jpeg);
3278        assert_eq!(artifact.metadata.width, Some(4));
3279        assert_eq!(artifact.metadata.height, Some(3));
3280    }
3281
3282    #[test]
3283    fn transform_endpoint_rejects_private_url_sources_by_default() {
3284        let response = route_request(
3285            transform_url_request("http://127.0.0.1:8080/image.png"),
3286            &ServerConfig::new(temp_dir("url-blocked"), Some("secret".to_string())),
3287        );
3288
3289        assert_eq!(response.status, "403 Forbidden");
3290        assert!(response_body(&response).contains("port is not allowed"));
3291    }
3292
3293    #[test]
3294    fn transform_endpoint_transforms_a_url_source_when_insecure_allowance_is_enabled() {
3295        let (url, handle) = spawn_http_server(vec![(
3296            "200 OK".to_string(),
3297            vec![("Content-Type".to_string(), "image/png".to_string())],
3298            png_bytes(),
3299        )]);
3300
3301        let response = route_request(
3302            transform_url_request(&url),
3303            &ServerConfig::new(temp_dir("url"), Some("secret".to_string()))
3304                .with_insecure_url_sources(true),
3305        );
3306
3307        handle.join().expect("join fixture server");
3308
3309        assert_eq!(response.status, "200 OK");
3310        assert_eq!(response.content_type, Some("image/jpeg"));
3311
3312        let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
3313        assert_eq!(artifact.media_type, MediaType::Jpeg);
3314    }
3315
3316    #[test]
3317    fn transform_endpoint_follows_remote_redirects() {
3318        let (redirect_url, handle) = spawn_http_server(vec![
3319            (
3320                "302 Found".to_string(),
3321                vec![("Location".to_string(), "/final-image".to_string())],
3322                Vec::new(),
3323            ),
3324            (
3325                "200 OK".to_string(),
3326                vec![("Content-Type".to_string(), "image/png".to_string())],
3327                png_bytes(),
3328            ),
3329        ]);
3330
3331        let response = route_request(
3332            transform_url_request(&redirect_url),
3333            &ServerConfig::new(temp_dir("redirect"), Some("secret".to_string()))
3334                .with_insecure_url_sources(true),
3335        );
3336
3337        handle.join().expect("join fixture server");
3338
3339        assert_eq!(response.status, "200 OK");
3340        let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
3341        assert_eq!(artifact.media_type, MediaType::Jpeg);
3342    }
3343
3344    #[test]
3345    fn upload_endpoint_transforms_uploaded_file() {
3346        let response = route_request(
3347            upload_request(&png_bytes(), Some(r#"{"format":"jpeg"}"#)),
3348            &ServerConfig::new(temp_dir("upload"), Some("secret".to_string())),
3349        );
3350
3351        assert_eq!(response.status, "200 OK");
3352        assert_eq!(response.content_type, Some("image/jpeg"));
3353
3354        let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
3355        assert_eq!(artifact.media_type, MediaType::Jpeg);
3356    }
3357
3358    #[test]
3359    fn upload_endpoint_requires_a_file_field() {
3360        let boundary = "truss-test-boundary";
3361        let request = HttpRequest {
3362            method: "POST".to_string(),
3363            target: "/images".to_string(),
3364            version: "HTTP/1.1".to_string(),
3365            headers: vec![
3366                ("authorization".to_string(), "Bearer secret".to_string()),
3367                (
3368                    "content-type".to_string(),
3369                    format!("multipart/form-data; boundary={boundary}"),
3370                ),
3371            ],
3372            body: format!(
3373                "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{{\"format\":\"jpeg\"}}\r\n--{boundary}--\r\n"
3374            )
3375            .into_bytes(),
3376        };
3377
3378        let response = route_request(
3379            request,
3380            &ServerConfig::new(temp_dir("upload-missing-file"), Some("secret".to_string())),
3381        );
3382
3383        assert_eq!(response.status, "400 Bad Request");
3384        assert!(response_body(&response).contains("requires a `file` field"));
3385    }
3386
3387    #[test]
3388    fn upload_endpoint_rejects_non_multipart_content_type() {
3389        let request = HttpRequest {
3390            method: "POST".to_string(),
3391            target: "/images".to_string(),
3392            version: "HTTP/1.1".to_string(),
3393            headers: vec![
3394                ("authorization".to_string(), "Bearer secret".to_string()),
3395                ("content-type".to_string(), "application/json".to_string()),
3396            ],
3397            body: br#"{"file":"not-really-json"}"#.to_vec(),
3398        };
3399
3400        let response = route_request(
3401            request,
3402            &ServerConfig::new(temp_dir("upload-content-type"), Some("secret".to_string())),
3403        );
3404
3405        assert_eq!(response.status, "415 Unsupported Media Type");
3406        assert!(response_body(&response).contains("multipart/form-data"));
3407    }
3408
3409    #[test]
3410    fn parse_upload_request_extracts_file_and_options() {
3411        let request = upload_request(&png_bytes(), Some(r#"{"width":8,"format":"jpeg"}"#));
3412        let boundary =
3413            super::multipart::parse_multipart_boundary(&request).expect("parse boundary");
3414        let (file_bytes, options) =
3415            super::multipart::parse_upload_request(&request.body, &boundary)
3416                .expect("parse upload body");
3417
3418        assert_eq!(file_bytes, png_bytes());
3419        assert_eq!(options.width, Some(8));
3420        assert_eq!(options.format, Some(MediaType::Jpeg));
3421    }
3422
3423    #[test]
3424    fn metrics_endpoint_does_not_require_authentication() {
3425        let response = route_request(
3426            metrics_request(false),
3427            &ServerConfig::new(temp_dir("metrics-no-auth"), Some("secret".to_string())),
3428        );
3429
3430        assert_eq!(response.status, "200 OK");
3431    }
3432
3433    #[test]
3434    fn metrics_endpoint_returns_prometheus_text() {
3435        super::metrics::record_http_metrics(super::metrics::RouteMetric::Health, "200 OK");
3436        let response = route_request(
3437            metrics_request(true),
3438            &ServerConfig::new(temp_dir("metrics"), Some("secret".to_string())),
3439        );
3440        let body = response_body(&response);
3441
3442        assert_eq!(response.status, "200 OK");
3443        assert_eq!(
3444            response.content_type,
3445            Some("text/plain; version=0.0.4; charset=utf-8")
3446        );
3447        assert!(body.contains("truss_http_requests_total"));
3448        assert!(body.contains("truss_http_requests_by_route_total{route=\"/health\"}"));
3449        assert!(body.contains("truss_http_responses_total{status=\"200\"}"));
3450        // Histogram metrics
3451        assert!(body.contains("# TYPE truss_http_request_duration_seconds histogram"));
3452        assert!(
3453            body.contains(
3454                "truss_http_request_duration_seconds_bucket{route=\"/health\",le=\"+Inf\"}"
3455            )
3456        );
3457        assert!(body.contains("# TYPE truss_transform_duration_seconds histogram"));
3458        assert!(body.contains("# TYPE truss_storage_request_duration_seconds histogram"));
3459        // Transform error counter
3460        assert!(body.contains("# TYPE truss_transform_errors_total counter"));
3461        assert!(body.contains("truss_transform_errors_total{error_type=\"decode_failed\"}"));
3462    }
3463
3464    #[test]
3465    fn transform_endpoint_rejects_unsupported_remote_content_encoding() {
3466        let (url, handle) = spawn_http_server(vec![(
3467            "200 OK".to_string(),
3468            vec![
3469                ("Content-Type".to_string(), "image/png".to_string()),
3470                ("Content-Encoding".to_string(), "compress".to_string()),
3471            ],
3472            png_bytes(),
3473        )]);
3474
3475        let response = route_request(
3476            transform_url_request(&url),
3477            &ServerConfig::new(temp_dir("encoding"), Some("secret".to_string()))
3478                .with_insecure_url_sources(true),
3479        );
3480
3481        handle.join().expect("join fixture server");
3482
3483        assert_eq!(response.status, "502 Bad Gateway");
3484        assert!(response_body(&response).contains("unsupported content-encoding"));
3485    }
3486
3487    #[test]
3488    fn resolve_storage_path_rejects_parent_segments() {
3489        let storage_root = temp_dir("resolve");
3490        let response = resolve_storage_path(&storage_root, "../escape.png")
3491            .expect_err("parent segments should be rejected");
3492
3493        assert_eq!(response.status, "400 Bad Request");
3494        assert!(response_body(&response).contains("must not contain root"));
3495    }
3496
3497    #[test]
3498    fn read_request_parses_headers_and_body() {
3499        let request_bytes = b"POST /images:transform HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}";
3500        let mut cursor = Cursor::new(request_bytes);
3501        let request = read_request(&mut cursor).expect("parse request");
3502
3503        assert_eq!(request.method, "POST");
3504        assert_eq!(request.target, "/images:transform");
3505        assert_eq!(request.version, "HTTP/1.1");
3506        assert_eq!(request.header("host"), Some("localhost"));
3507        assert_eq!(request.body, b"{}");
3508    }
3509
3510    #[test]
3511    fn read_request_rejects_duplicate_content_length() {
3512        let request_bytes =
3513            b"POST /images:transform HTTP/1.1\r\nContent-Length: 2\r\nContent-Length: 2\r\n\r\n{}";
3514        let mut cursor = Cursor::new(request_bytes);
3515        let response = read_request(&mut cursor).expect_err("duplicate headers should fail");
3516
3517        assert_eq!(response.status, "400 Bad Request");
3518        assert!(response_body(&response).contains("content-length"));
3519    }
3520
3521    #[test]
3522    fn serve_once_handles_a_tcp_request() {
3523        let storage_root = temp_dir("serve-once");
3524        let config = ServerConfig::new(storage_root, None);
3525        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
3526        let addr = listener.local_addr().expect("read local addr");
3527
3528        let server = thread::spawn(move || serve_once_with_config(listener, config));
3529
3530        let mut stream = TcpStream::connect(addr).expect("connect to test server");
3531        stream
3532            .write_all(b"GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
3533            .expect("write request");
3534
3535        let mut response = String::new();
3536        stream.read_to_string(&mut response).expect("read response");
3537
3538        server
3539            .join()
3540            .expect("join test server thread")
3541            .expect("serve one request");
3542
3543        assert!(response.starts_with("HTTP/1.1 200 OK"));
3544        assert!(response.contains("Content-Type: application/json"));
3545        assert!(response.contains("\"status\":\"ok\""));
3546        assert!(response.contains("\"service\":\"truss\""));
3547        assert!(response.contains("\"version\":"));
3548    }
3549
3550    #[test]
3551    fn helper_error_responses_use_rfc7807_problem_details() {
3552        let response = auth_required_response("authorization required");
3553        let bad_request = bad_request_response("bad input");
3554
3555        assert_eq!(
3556            response.content_type,
3557            Some("application/problem+json"),
3558            "error responses must use application/problem+json"
3559        );
3560        assert_eq!(bad_request.content_type, Some("application/problem+json"),);
3561
3562        let auth_body = response_body(&response);
3563        assert!(auth_body.contains("authorization required"));
3564        assert!(auth_body.contains("\"type\":\"about:blank\""));
3565        assert!(auth_body.contains("\"title\":\"Unauthorized\""));
3566        assert!(auth_body.contains("\"status\":401"));
3567
3568        let bad_body = response_body(&bad_request);
3569        assert!(bad_body.contains("bad input"));
3570        assert!(bad_body.contains("\"type\":\"about:blank\""));
3571        assert!(bad_body.contains("\"title\":\"Bad Request\""));
3572        assert!(bad_body.contains("\"status\":400"));
3573    }
3574
3575    #[test]
3576    fn parse_headers_rejects_duplicate_host() {
3577        let lines = "Host: example.com\r\nHost: evil.com\r\n";
3578        let result = super::http_parse::parse_headers(lines.split("\r\n"));
3579        assert!(result.is_err());
3580    }
3581
3582    #[test]
3583    fn parse_headers_rejects_duplicate_authorization() {
3584        let lines = "Authorization: Bearer a\r\nAuthorization: Bearer b\r\n";
3585        let result = super::http_parse::parse_headers(lines.split("\r\n"));
3586        assert!(result.is_err());
3587    }
3588
3589    #[test]
3590    fn parse_headers_rejects_duplicate_content_type() {
3591        let lines = "Content-Type: application/json\r\nContent-Type: text/plain\r\n";
3592        let result = super::http_parse::parse_headers(lines.split("\r\n"));
3593        assert!(result.is_err());
3594    }
3595
3596    #[test]
3597    fn parse_headers_rejects_duplicate_transfer_encoding() {
3598        let lines = "Transfer-Encoding: chunked\r\nTransfer-Encoding: gzip\r\n";
3599        let result = super::http_parse::parse_headers(lines.split("\r\n"));
3600        assert!(result.is_err());
3601    }
3602
3603    #[test]
3604    fn parse_headers_rejects_single_transfer_encoding() {
3605        let lines = "Host: example.com\r\nTransfer-Encoding: chunked\r\n";
3606        let result = super::http_parse::parse_headers(lines.split("\r\n"));
3607        let err = result.unwrap_err();
3608        assert!(
3609            err.status.starts_with("501"),
3610            "expected 501 status, got: {}",
3611            err.status
3612        );
3613        assert!(
3614            String::from_utf8_lossy(&err.body).contains("Transfer-Encoding"),
3615            "error response should mention Transfer-Encoding"
3616        );
3617    }
3618
3619    #[test]
3620    fn parse_headers_rejects_transfer_encoding_identity() {
3621        let lines = "Transfer-Encoding: identity\r\n";
3622        let result = super::http_parse::parse_headers(lines.split("\r\n"));
3623        assert!(result.is_err());
3624    }
3625
3626    #[test]
3627    fn parse_headers_allows_single_instances_of_singleton_headers() {
3628        let lines =
3629            "Host: example.com\r\nAuthorization: Bearer tok\r\nContent-Type: application/json\r\n";
3630        let result = super::http_parse::parse_headers(lines.split("\r\n"));
3631        assert!(result.is_ok());
3632        assert_eq!(result.unwrap().len(), 3);
3633    }
3634
3635    #[test]
3636    fn max_body_for_multipart_uses_upload_limit() {
3637        let headers = vec![(
3638            "content-type".to_string(),
3639            "multipart/form-data; boundary=abc".to_string(),
3640        )];
3641        assert_eq!(
3642            super::http_parse::max_body_for_headers(&headers),
3643            super::http_parse::MAX_UPLOAD_BODY_BYTES
3644        );
3645    }
3646
3647    #[test]
3648    fn max_body_for_json_uses_default_limit() {
3649        let headers = vec![("content-type".to_string(), "application/json".to_string())];
3650        assert_eq!(
3651            super::http_parse::max_body_for_headers(&headers),
3652            super::http_parse::MAX_REQUEST_BODY_BYTES
3653        );
3654    }
3655
3656    #[test]
3657    fn max_body_for_no_content_type_uses_default_limit() {
3658        let headers: Vec<(String, String)> = vec![];
3659        assert_eq!(
3660            super::http_parse::max_body_for_headers(&headers),
3661            super::http_parse::MAX_REQUEST_BODY_BYTES
3662        );
3663    }
3664
3665    fn make_test_config() -> ServerConfig {
3666        ServerConfig::new(std::env::temp_dir(), None)
3667    }
3668
3669    #[test]
3670    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
3671    fn storage_backend_parse_filesystem_aliases() {
3672        assert_eq!(
3673            super::StorageBackend::parse("filesystem").unwrap(),
3674            super::StorageBackend::Filesystem
3675        );
3676        assert_eq!(
3677            super::StorageBackend::parse("fs").unwrap(),
3678            super::StorageBackend::Filesystem
3679        );
3680        assert_eq!(
3681            super::StorageBackend::parse("local").unwrap(),
3682            super::StorageBackend::Filesystem
3683        );
3684    }
3685
3686    #[test]
3687    #[cfg(feature = "s3")]
3688    fn storage_backend_parse_s3() {
3689        assert_eq!(
3690            super::StorageBackend::parse("s3").unwrap(),
3691            super::StorageBackend::S3
3692        );
3693        assert_eq!(
3694            super::StorageBackend::parse("S3").unwrap(),
3695            super::StorageBackend::S3
3696        );
3697    }
3698
3699    #[test]
3700    #[cfg(feature = "gcs")]
3701    fn storage_backend_parse_gcs() {
3702        assert_eq!(
3703            super::StorageBackend::parse("gcs").unwrap(),
3704            super::StorageBackend::Gcs
3705        );
3706        assert_eq!(
3707            super::StorageBackend::parse("GCS").unwrap(),
3708            super::StorageBackend::Gcs
3709        );
3710    }
3711
3712    #[test]
3713    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
3714    fn storage_backend_parse_rejects_unknown() {
3715        assert!(super::StorageBackend::parse("").is_err());
3716        #[cfg(not(feature = "azure"))]
3717        assert!(super::StorageBackend::parse("azure").is_err());
3718        #[cfg(feature = "azure")]
3719        assert!(super::StorageBackend::parse("azure").is_ok());
3720    }
3721
3722    #[test]
3723    fn versioned_source_hash_returns_none_without_version() {
3724        let source = TransformSourcePayload::Path {
3725            path: "/photos/hero.jpg".to_string(),
3726            version: None,
3727        };
3728        assert!(source.versioned_source_hash(&make_test_config()).is_none());
3729    }
3730
3731    #[test]
3732    fn versioned_source_hash_is_deterministic() {
3733        let cfg = make_test_config();
3734        let source = TransformSourcePayload::Path {
3735            path: "/photos/hero.jpg".to_string(),
3736            version: Some("v1".to_string()),
3737        };
3738        let hash1 = source.versioned_source_hash(&cfg).unwrap();
3739        let hash2 = source.versioned_source_hash(&cfg).unwrap();
3740        assert_eq!(hash1, hash2);
3741        assert_eq!(hash1.len(), 64);
3742    }
3743
3744    #[test]
3745    fn versioned_source_hash_differs_by_version() {
3746        let cfg = make_test_config();
3747        let v1 = TransformSourcePayload::Path {
3748            path: "/photos/hero.jpg".to_string(),
3749            version: Some("v1".to_string()),
3750        };
3751        let v2 = TransformSourcePayload::Path {
3752            path: "/photos/hero.jpg".to_string(),
3753            version: Some("v2".to_string()),
3754        };
3755        assert_ne!(
3756            v1.versioned_source_hash(&cfg).unwrap(),
3757            v2.versioned_source_hash(&cfg).unwrap()
3758        );
3759    }
3760
3761    #[test]
3762    fn versioned_source_hash_differs_by_kind() {
3763        let cfg = make_test_config();
3764        let path = TransformSourcePayload::Path {
3765            path: "example.com/image.jpg".to_string(),
3766            version: Some("v1".to_string()),
3767        };
3768        let url = TransformSourcePayload::Url {
3769            url: "example.com/image.jpg".to_string(),
3770            version: Some("v1".to_string()),
3771        };
3772        assert_ne!(
3773            path.versioned_source_hash(&cfg).unwrap(),
3774            url.versioned_source_hash(&cfg).unwrap()
3775        );
3776    }
3777
3778    #[test]
3779    fn versioned_source_hash_differs_by_storage_root() {
3780        let cfg1 = ServerConfig::new(PathBuf::from("/data/images"), None);
3781        let cfg2 = ServerConfig::new(PathBuf::from("/other/images"), None);
3782        let source = TransformSourcePayload::Path {
3783            path: "/photos/hero.jpg".to_string(),
3784            version: Some("v1".to_string()),
3785        };
3786        assert_ne!(
3787            source.versioned_source_hash(&cfg1).unwrap(),
3788            source.versioned_source_hash(&cfg2).unwrap()
3789        );
3790    }
3791
3792    #[test]
3793    fn versioned_source_hash_differs_by_insecure_flag() {
3794        let mut cfg1 = make_test_config();
3795        cfg1.allow_insecure_url_sources = false;
3796        let mut cfg2 = make_test_config();
3797        cfg2.allow_insecure_url_sources = true;
3798        let source = TransformSourcePayload::Url {
3799            url: "http://example.com/img.jpg".to_string(),
3800            version: Some("v1".to_string()),
3801        };
3802        assert_ne!(
3803            source.versioned_source_hash(&cfg1).unwrap(),
3804            source.versioned_source_hash(&cfg2).unwrap()
3805        );
3806    }
3807
3808    #[test]
3809    #[cfg(feature = "s3")]
3810    fn versioned_source_hash_storage_variant_is_deterministic() {
3811        let cfg = make_test_config();
3812        let source = TransformSourcePayload::Storage {
3813            bucket: Some("my-bucket".to_string()),
3814            key: "photos/hero.jpg".to_string(),
3815            version: Some("v1".to_string()),
3816        };
3817        let hash1 = source.versioned_source_hash(&cfg).unwrap();
3818        let hash2 = source.versioned_source_hash(&cfg).unwrap();
3819        assert_eq!(hash1, hash2);
3820        assert_eq!(hash1.len(), 64);
3821    }
3822
3823    #[test]
3824    #[cfg(feature = "s3")]
3825    fn versioned_source_hash_storage_differs_from_path() {
3826        let cfg = make_test_config();
3827        let path_source = TransformSourcePayload::Path {
3828            path: "photos/hero.jpg".to_string(),
3829            version: Some("v1".to_string()),
3830        };
3831        let storage_source = TransformSourcePayload::Storage {
3832            bucket: Some("my-bucket".to_string()),
3833            key: "photos/hero.jpg".to_string(),
3834            version: Some("v1".to_string()),
3835        };
3836        assert_ne!(
3837            path_source.versioned_source_hash(&cfg).unwrap(),
3838            storage_source.versioned_source_hash(&cfg).unwrap()
3839        );
3840    }
3841
3842    #[test]
3843    #[cfg(feature = "s3")]
3844    fn versioned_source_hash_storage_differs_by_bucket() {
3845        let cfg = make_test_config();
3846        let s1 = TransformSourcePayload::Storage {
3847            bucket: Some("bucket-a".to_string()),
3848            key: "image.jpg".to_string(),
3849            version: Some("v1".to_string()),
3850        };
3851        let s2 = TransformSourcePayload::Storage {
3852            bucket: Some("bucket-b".to_string()),
3853            key: "image.jpg".to_string(),
3854            version: Some("v1".to_string()),
3855        };
3856        assert_ne!(
3857            s1.versioned_source_hash(&cfg).unwrap(),
3858            s2.versioned_source_hash(&cfg).unwrap()
3859        );
3860    }
3861
3862    #[test]
3863    #[cfg(feature = "s3")]
3864    fn versioned_source_hash_differs_by_backend() {
3865        let cfg_fs = make_test_config();
3866        let mut cfg_s3 = make_test_config();
3867        cfg_s3.storage_backend = super::StorageBackend::S3;
3868
3869        let source = TransformSourcePayload::Path {
3870            path: "photos/hero.jpg".to_string(),
3871            version: Some("v1".to_string()),
3872        };
3873        assert_ne!(
3874            source.versioned_source_hash(&cfg_fs).unwrap(),
3875            source.versioned_source_hash(&cfg_s3).unwrap()
3876        );
3877    }
3878
3879    #[test]
3880    #[cfg(feature = "s3")]
3881    fn versioned_source_hash_storage_differs_by_endpoint() {
3882        let mut cfg_a = make_test_config();
3883        cfg_a.storage_backend = super::StorageBackend::S3;
3884        cfg_a.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3885            "shared",
3886            Some("http://minio-a:9000"),
3887        )));
3888
3889        let mut cfg_b = make_test_config();
3890        cfg_b.storage_backend = super::StorageBackend::S3;
3891        cfg_b.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3892            "shared",
3893            Some("http://minio-b:9000"),
3894        )));
3895
3896        let source = TransformSourcePayload::Storage {
3897            bucket: None,
3898            key: "image.jpg".to_string(),
3899            version: Some("v1".to_string()),
3900        };
3901        assert_ne!(
3902            source.versioned_source_hash(&cfg_a).unwrap(),
3903            source.versioned_source_hash(&cfg_b).unwrap(),
3904        );
3905        assert_ne!(cfg_a, cfg_b);
3906    }
3907
3908    #[test]
3909    #[cfg(feature = "s3")]
3910    fn storage_backend_default_is_filesystem() {
3911        let cfg = make_test_config();
3912        assert_eq!(cfg.storage_backend, super::StorageBackend::Filesystem);
3913        assert!(cfg.s3_context.is_none());
3914    }
3915
3916    #[test]
3917    #[cfg(feature = "s3")]
3918    fn storage_payload_deserializes_storage_variant() {
3919        let json = r#"{"source":{"kind":"storage","key":"photos/hero.jpg"},"options":{}}"#;
3920        let payload: super::TransformImageRequestPayload = serde_json::from_str(json).unwrap();
3921        match payload.source {
3922            TransformSourcePayload::Storage {
3923                bucket,
3924                key,
3925                version,
3926            } => {
3927                assert!(bucket.is_none());
3928                assert_eq!(key, "photos/hero.jpg");
3929                assert!(version.is_none());
3930            }
3931            _ => panic!("expected Storage variant"),
3932        }
3933    }
3934
3935    #[test]
3936    #[cfg(feature = "s3")]
3937    fn storage_payload_deserializes_with_bucket() {
3938        let json = r#"{"source":{"kind":"storage","bucket":"my-bucket","key":"img.png","version":"v2"},"options":{}}"#;
3939        let payload: super::TransformImageRequestPayload = serde_json::from_str(json).unwrap();
3940        match payload.source {
3941            TransformSourcePayload::Storage {
3942                bucket,
3943                key,
3944                version,
3945            } => {
3946                assert_eq!(bucket.as_deref(), Some("my-bucket"));
3947                assert_eq!(key, "img.png");
3948                assert_eq!(version.as_deref(), Some("v2"));
3949            }
3950            _ => panic!("expected Storage variant"),
3951        }
3952    }
3953
3954    // -----------------------------------------------------------------------
3955    // S3: default_bucket fallback with bucket: None
3956    // -----------------------------------------------------------------------
3957
3958    #[test]
3959    #[cfg(feature = "s3")]
3960    fn versioned_source_hash_uses_default_bucket_when_bucket_is_none() {
3961        let mut cfg_a = make_test_config();
3962        cfg_a.storage_backend = super::StorageBackend::S3;
3963        cfg_a.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3964            "bucket-a", None,
3965        )));
3966
3967        let mut cfg_b = make_test_config();
3968        cfg_b.storage_backend = super::StorageBackend::S3;
3969        cfg_b.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3970            "bucket-b", None,
3971        )));
3972
3973        let source = TransformSourcePayload::Storage {
3974            bucket: None,
3975            key: "image.jpg".to_string(),
3976            version: Some("v1".to_string()),
3977        };
3978        // Different default_bucket ⇒ different hash
3979        assert_ne!(
3980            source.versioned_source_hash(&cfg_a).unwrap(),
3981            source.versioned_source_hash(&cfg_b).unwrap(),
3982        );
3983        // PartialEq also distinguishes them
3984        assert_ne!(cfg_a, cfg_b);
3985    }
3986
3987    #[test]
3988    #[cfg(feature = "s3")]
3989    fn versioned_source_hash_returns_none_without_bucket_or_context() {
3990        let mut cfg = make_test_config();
3991        cfg.storage_backend = super::StorageBackend::S3;
3992        cfg.s3_context = None;
3993
3994        let source = TransformSourcePayload::Storage {
3995            bucket: None,
3996            key: "image.jpg".to_string(),
3997            version: Some("v1".to_string()),
3998        };
3999        // No bucket available ⇒ None (falls back to content-hash)
4000        assert!(source.versioned_source_hash(&cfg).is_none());
4001    }
4002
4003    // -----------------------------------------------------------------------
4004    // S3: from_env branches
4005    //
4006    // These tests mutate process-global environment variables. A mutex
4007    // serializes them so that parallel test threads cannot interfere, and
4008    // each test saves/restores the variables it touches.
4009    // -----------------------------------------------------------------------
4010
4011    #[cfg(feature = "s3")]
4012    static FROM_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
4013
4014    #[cfg(feature = "s3")]
4015    const S3_ENV_VARS: &[&str] = &[
4016        "TRUSS_STORAGE_ROOT",
4017        "TRUSS_STORAGE_BACKEND",
4018        "TRUSS_S3_BUCKET",
4019    ];
4020
4021    /// Save current values, run `f`, then restore originals regardless of
4022    /// panics. Holds `FROM_ENV_MUTEX` for the duration.
4023    #[cfg(feature = "s3")]
4024    fn with_s3_env<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
4025        let _guard = FROM_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
4026        let saved: Vec<(&str, Option<String>)> = S3_ENV_VARS
4027            .iter()
4028            .map(|k| (*k, std::env::var(k).ok()))
4029            .collect();
4030        // Apply requested overrides
4031        for &(key, value) in vars {
4032            match value {
4033                Some(v) => unsafe { std::env::set_var(key, v) },
4034                None => unsafe { std::env::remove_var(key) },
4035            }
4036        }
4037        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
4038        // Restore originals
4039        for (key, original) in saved {
4040            match original {
4041                Some(v) => unsafe { std::env::set_var(key, v) },
4042                None => unsafe { std::env::remove_var(key) },
4043            }
4044        }
4045        if let Err(payload) = result {
4046            std::panic::resume_unwind(payload);
4047        }
4048    }
4049
4050    #[test]
4051    #[cfg(feature = "s3")]
4052    fn from_env_rejects_invalid_storage_backend() {
4053        let storage = temp_dir("env-bad-backend");
4054        let storage_str = storage.to_str().unwrap().to_string();
4055        with_s3_env(
4056            &[
4057                ("TRUSS_STORAGE_ROOT", Some(&storage_str)),
4058                ("TRUSS_STORAGE_BACKEND", Some("nosuchbackend")),
4059                ("TRUSS_S3_BUCKET", None),
4060            ],
4061            || {
4062                let result = ServerConfig::from_env();
4063                assert!(result.is_err());
4064                let msg = result.unwrap_err().to_string();
4065                assert!(msg.contains("unknown storage backend"), "got: {msg}");
4066            },
4067        );
4068        let _ = std::fs::remove_dir_all(storage);
4069    }
4070
4071    #[test]
4072    #[cfg(feature = "s3")]
4073    fn from_env_rejects_s3_without_bucket() {
4074        let storage = temp_dir("env-no-bucket");
4075        let storage_str = storage.to_str().unwrap().to_string();
4076        with_s3_env(
4077            &[
4078                ("TRUSS_STORAGE_ROOT", Some(&storage_str)),
4079                ("TRUSS_STORAGE_BACKEND", Some("s3")),
4080                ("TRUSS_S3_BUCKET", None),
4081            ],
4082            || {
4083                let result = ServerConfig::from_env();
4084                assert!(result.is_err());
4085                let msg = result.unwrap_err().to_string();
4086                assert!(msg.contains("TRUSS_S3_BUCKET"), "got: {msg}");
4087            },
4088        );
4089        let _ = std::fs::remove_dir_all(storage);
4090    }
4091
4092    #[test]
4093    #[cfg(feature = "s3")]
4094    fn from_env_accepts_s3_with_bucket() {
4095        let storage = temp_dir("env-s3-ok");
4096        let storage_str = storage.to_str().unwrap().to_string();
4097        with_s3_env(
4098            &[
4099                ("TRUSS_STORAGE_ROOT", Some(&storage_str)),
4100                ("TRUSS_STORAGE_BACKEND", Some("s3")),
4101                ("TRUSS_S3_BUCKET", Some("my-images")),
4102            ],
4103            || {
4104                let cfg =
4105                    ServerConfig::from_env().expect("from_env should succeed with s3 + bucket");
4106                assert_eq!(cfg.storage_backend, super::StorageBackend::S3);
4107                let ctx = cfg.s3_context.expect("s3_context should be Some");
4108                assert_eq!(ctx.default_bucket, "my-images");
4109            },
4110        );
4111        let _ = std::fs::remove_dir_all(storage);
4112    }
4113
4114    // -----------------------------------------------------------------------
4115    // S3: health endpoint
4116    // -----------------------------------------------------------------------
4117
4118    #[test]
4119    #[cfg(feature = "s3")]
4120    fn health_ready_s3_returns_503_when_context_missing() {
4121        let storage = temp_dir("health-s3-no-ctx");
4122        let mut config = ServerConfig::new(storage.clone(), None);
4123        config.storage_backend = super::StorageBackend::S3;
4124        config.s3_context = None;
4125
4126        let request = super::http_parse::HttpRequest {
4127            method: "GET".to_string(),
4128            target: "/health/ready".to_string(),
4129            version: "HTTP/1.1".to_string(),
4130            headers: Vec::new(),
4131            body: Vec::new(),
4132        };
4133        let response = route_request(request, &config);
4134        let _ = std::fs::remove_dir_all(storage);
4135
4136        assert_eq!(response.status, "503 Service Unavailable");
4137        let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
4138        let checks = body["checks"].as_array().expect("checks array");
4139        assert!(
4140            checks
4141                .iter()
4142                .any(|c| c["name"] == "storageBackend" && c["status"] == "fail"),
4143            "expected s3Client fail check in {body}",
4144        );
4145    }
4146
4147    #[test]
4148    #[cfg(feature = "s3")]
4149    fn health_ready_s3_includes_s3_client_check() {
4150        let storage = temp_dir("health-s3-ok");
4151        let mut config = ServerConfig::new(storage.clone(), None);
4152        config.storage_backend = super::StorageBackend::S3;
4153        config.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
4154            "test-bucket",
4155            None,
4156        )));
4157
4158        let request = super::http_parse::HttpRequest {
4159            method: "GET".to_string(),
4160            target: "/health/ready".to_string(),
4161            version: "HTTP/1.1".to_string(),
4162            headers: Vec::new(),
4163            body: Vec::new(),
4164        };
4165        let response = route_request(request, &config);
4166        let _ = std::fs::remove_dir_all(storage);
4167
4168        // The s3Client check will report "fail" because there is no real S3
4169        // endpoint, but the important thing is that the check is present.
4170        let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
4171        let checks = body["checks"].as_array().expect("checks array");
4172        assert!(
4173            checks.iter().any(|c| c["name"] == "storageBackend"),
4174            "expected s3Client check in {body}",
4175        );
4176    }
4177
4178    // -----------------------------------------------------------------------
4179    // S3: public by-path remap (leading slash trimmed, Storage variant used)
4180    // -----------------------------------------------------------------------
4181
4182    /// Replicates the Path→Storage remap that `handle_public_get_request`
4183    /// performs when `storage_backend == S3`, so we can inspect the resulting
4184    /// key without issuing a real S3 request.
4185    #[cfg(feature = "s3")]
4186    fn remap_path_to_storage(path: &str, version: Option<&str>) -> TransformSourcePayload {
4187        let source = TransformSourcePayload::Path {
4188            path: path.to_string(),
4189            version: version.map(|v| v.to_string()),
4190        };
4191        match source {
4192            TransformSourcePayload::Path { path, version } => TransformSourcePayload::Storage {
4193                bucket: None,
4194                key: path.trim_start_matches('/').to_string(),
4195                version,
4196            },
4197            other => other,
4198        }
4199    }
4200
4201    #[test]
4202    #[cfg(feature = "s3")]
4203    fn public_by_path_s3_remap_trims_leading_slash() {
4204        // Paths with a leading slash (the common case from signed URLs like
4205        // `path=/image.png`) must have the slash stripped so that the S3 key
4206        // is `image.png`, not `/image.png`.
4207        let source = remap_path_to_storage("/photos/hero.jpg", Some("v1"));
4208        match &source {
4209            TransformSourcePayload::Storage { key, .. } => {
4210                assert_eq!(key, "photos/hero.jpg", "leading / must be trimmed");
4211            }
4212            _ => panic!("expected Storage variant after remap"),
4213        }
4214
4215        // Without a leading slash the key must be unchanged.
4216        let source2 = remap_path_to_storage("photos/hero.jpg", Some("v1"));
4217        match &source2 {
4218            TransformSourcePayload::Storage { key, .. } => {
4219                assert_eq!(key, "photos/hero.jpg");
4220            }
4221            _ => panic!("expected Storage variant after remap"),
4222        }
4223
4224        // Both must produce the same versioned hash (same effective key).
4225        let mut cfg = make_test_config();
4226        cfg.storage_backend = super::StorageBackend::S3;
4227        cfg.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
4228            "my-bucket",
4229            None,
4230        )));
4231        assert_eq!(
4232            source.versioned_source_hash(&cfg),
4233            source2.versioned_source_hash(&cfg),
4234            "leading-slash and no-leading-slash paths must hash identically after trim",
4235        );
4236    }
4237
4238    #[test]
4239    #[cfg(feature = "s3")]
4240    fn public_by_path_s3_remap_produces_storage_variant() {
4241        // Verify the remap converts Path to Storage with bucket: None.
4242        let source = remap_path_to_storage("/image.png", None);
4243        match source {
4244            TransformSourcePayload::Storage {
4245                bucket,
4246                key,
4247                version,
4248            } => {
4249                assert!(bucket.is_none(), "bucket must be None (use default)");
4250                assert_eq!(key, "image.png");
4251                assert!(version.is_none());
4252            }
4253            _ => panic!("expected Storage variant"),
4254        }
4255    }
4256
4257    // -----------------------------------------------------------------------
4258    // GCS: health endpoint
4259    // -----------------------------------------------------------------------
4260
4261    #[test]
4262    #[cfg(feature = "gcs")]
4263    fn health_ready_gcs_returns_503_when_context_missing() {
4264        let storage = temp_dir("health-gcs-no-ctx");
4265        let mut config = ServerConfig::new(storage.clone(), None);
4266        config.storage_backend = super::StorageBackend::Gcs;
4267        config.gcs_context = None;
4268
4269        let request = super::http_parse::HttpRequest {
4270            method: "GET".to_string(),
4271            target: "/health/ready".to_string(),
4272            version: "HTTP/1.1".to_string(),
4273            headers: Vec::new(),
4274            body: Vec::new(),
4275        };
4276        let response = route_request(request, &config);
4277        let _ = std::fs::remove_dir_all(storage);
4278
4279        assert_eq!(response.status, "503 Service Unavailable");
4280        let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
4281        let checks = body["checks"].as_array().expect("checks array");
4282        assert!(
4283            checks
4284                .iter()
4285                .any(|c| c["name"] == "storageBackend" && c["status"] == "fail"),
4286            "expected gcsClient fail check in {body}",
4287        );
4288    }
4289
4290    #[test]
4291    #[cfg(feature = "gcs")]
4292    fn health_ready_gcs_includes_gcs_client_check() {
4293        let storage = temp_dir("health-gcs-ok");
4294        let mut config = ServerConfig::new(storage.clone(), None);
4295        config.storage_backend = super::StorageBackend::Gcs;
4296        config.gcs_context = Some(std::sync::Arc::new(super::gcs::GcsContext::for_test(
4297            "test-bucket",
4298            None,
4299        )));
4300
4301        let request = super::http_parse::HttpRequest {
4302            method: "GET".to_string(),
4303            target: "/health/ready".to_string(),
4304            version: "HTTP/1.1".to_string(),
4305            headers: Vec::new(),
4306            body: Vec::new(),
4307        };
4308        let response = route_request(request, &config);
4309        let _ = std::fs::remove_dir_all(storage);
4310
4311        // The gcsClient check will report "fail" because there is no real GCS
4312        // endpoint, but the important thing is that the check is present.
4313        let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
4314        let checks = body["checks"].as_array().expect("checks array");
4315        assert!(
4316            checks.iter().any(|c| c["name"] == "storageBackend"),
4317            "expected gcsClient check in {body}",
4318        );
4319    }
4320
4321    // -----------------------------------------------------------------------
4322    // GCS: public by-path remap (leading slash trimmed, Storage variant used)
4323    // -----------------------------------------------------------------------
4324
4325    #[cfg(feature = "gcs")]
4326    fn remap_path_to_storage_gcs(path: &str, version: Option<&str>) -> TransformSourcePayload {
4327        let source = TransformSourcePayload::Path {
4328            path: path.to_string(),
4329            version: version.map(|v| v.to_string()),
4330        };
4331        match source {
4332            TransformSourcePayload::Path { path, version } => TransformSourcePayload::Storage {
4333                bucket: None,
4334                key: path.trim_start_matches('/').to_string(),
4335                version,
4336            },
4337            other => other,
4338        }
4339    }
4340
4341    #[test]
4342    #[cfg(feature = "gcs")]
4343    fn public_by_path_gcs_remap_trims_leading_slash() {
4344        let source = remap_path_to_storage_gcs("/photos/hero.jpg", Some("v1"));
4345        match &source {
4346            TransformSourcePayload::Storage { key, .. } => {
4347                assert_eq!(key, "photos/hero.jpg", "leading / must be trimmed");
4348            }
4349            _ => panic!("expected Storage variant after remap"),
4350        }
4351
4352        let source2 = remap_path_to_storage_gcs("photos/hero.jpg", Some("v1"));
4353        match &source2 {
4354            TransformSourcePayload::Storage { key, .. } => {
4355                assert_eq!(key, "photos/hero.jpg");
4356            }
4357            _ => panic!("expected Storage variant after remap"),
4358        }
4359
4360        let mut cfg = make_test_config();
4361        cfg.storage_backend = super::StorageBackend::Gcs;
4362        cfg.gcs_context = Some(std::sync::Arc::new(super::gcs::GcsContext::for_test(
4363            "my-bucket",
4364            None,
4365        )));
4366        assert_eq!(
4367            source.versioned_source_hash(&cfg),
4368            source2.versioned_source_hash(&cfg),
4369            "leading-slash and no-leading-slash paths must hash identically after trim",
4370        );
4371    }
4372
4373    #[test]
4374    #[cfg(feature = "gcs")]
4375    fn public_by_path_gcs_remap_produces_storage_variant() {
4376        let source = remap_path_to_storage_gcs("/image.png", None);
4377        match source {
4378            TransformSourcePayload::Storage {
4379                bucket,
4380                key,
4381                version,
4382            } => {
4383                assert!(bucket.is_none(), "bucket must be None (use default)");
4384                assert_eq!(key, "image.png");
4385                assert!(version.is_none());
4386            }
4387            _ => panic!("expected Storage variant"),
4388        }
4389    }
4390
4391    // -----------------------------------------------------------------------
4392    // Azure: health endpoint
4393    // -----------------------------------------------------------------------
4394
4395    #[test]
4396    #[cfg(feature = "azure")]
4397    fn health_ready_azure_returns_503_when_context_missing() {
4398        let storage = temp_dir("health-azure-no-ctx");
4399        let mut config = ServerConfig::new(storage.clone(), None);
4400        config.storage_backend = super::StorageBackend::Azure;
4401        config.azure_context = None;
4402
4403        let request = super::http_parse::HttpRequest {
4404            method: "GET".to_string(),
4405            target: "/health/ready".to_string(),
4406            version: "HTTP/1.1".to_string(),
4407            headers: Vec::new(),
4408            body: Vec::new(),
4409        };
4410        let response = route_request(request, &config);
4411        let _ = std::fs::remove_dir_all(storage);
4412
4413        assert_eq!(response.status, "503 Service Unavailable");
4414        let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
4415        let checks = body["checks"].as_array().expect("checks array");
4416        assert!(
4417            checks
4418                .iter()
4419                .any(|c| c["name"] == "storageBackend" && c["status"] == "fail"),
4420            "expected azureClient fail check in {body}",
4421        );
4422    }
4423
4424    #[test]
4425    #[cfg(feature = "azure")]
4426    fn health_ready_azure_includes_azure_client_check() {
4427        let storage = temp_dir("health-azure-ok");
4428        let mut config = ServerConfig::new(storage.clone(), None);
4429        config.storage_backend = super::StorageBackend::Azure;
4430        config.azure_context = Some(std::sync::Arc::new(super::azure::AzureContext::for_test(
4431            "test-bucket",
4432            "http://localhost:10000/devstoreaccount1",
4433        )));
4434
4435        let request = super::http_parse::HttpRequest {
4436            method: "GET".to_string(),
4437            target: "/health/ready".to_string(),
4438            version: "HTTP/1.1".to_string(),
4439            headers: Vec::new(),
4440            body: Vec::new(),
4441        };
4442        let response = route_request(request, &config);
4443        let _ = std::fs::remove_dir_all(storage);
4444
4445        let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
4446        let checks = body["checks"].as_array().expect("checks array");
4447        assert!(
4448            checks.iter().any(|c| c["name"] == "storageBackend"),
4449            "expected azureClient check in {body}",
4450        );
4451    }
4452
4453    #[test]
4454    fn read_request_rejects_json_body_over_1mib() {
4455        let body = vec![b'x'; super::http_parse::MAX_REQUEST_BODY_BYTES + 1];
4456        let content_length = body.len();
4457        let raw = format!(
4458            "POST /images:transform HTTP/1.1\r\n\
4459             Content-Type: application/json\r\n\
4460             Content-Length: {content_length}\r\n\r\n"
4461        );
4462        let mut data = raw.into_bytes();
4463        data.extend_from_slice(&body);
4464        let result = read_request(&mut data.as_slice());
4465        assert!(result.is_err());
4466    }
4467
4468    #[test]
4469    fn read_request_accepts_multipart_body_over_1mib() {
4470        let payload_size = super::http_parse::MAX_REQUEST_BODY_BYTES + 100;
4471        let body_content = vec![b'A'; payload_size];
4472        let boundary = "test-boundary-123";
4473        let mut body = Vec::new();
4474        body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"big.jpg\"\r\n\r\n").as_bytes());
4475        body.extend_from_slice(&body_content);
4476        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
4477        let content_length = body.len();
4478        let raw = format!(
4479            "POST /images HTTP/1.1\r\n\
4480             Content-Type: multipart/form-data; boundary={boundary}\r\n\
4481             Content-Length: {content_length}\r\n\r\n"
4482        );
4483        let mut data = raw.into_bytes();
4484        data.extend_from_slice(&body);
4485        let result = read_request(&mut data.as_slice());
4486        assert!(
4487            result.is_ok(),
4488            "multipart upload over 1 MiB should be accepted"
4489        );
4490    }
4491
4492    #[test]
4493    fn multipart_boundary_in_payload_does_not_split_part() {
4494        let boundary = "abc123";
4495        let fake_boundary_in_payload = format!("\r\n--{boundary}NOTREAL");
4496        let part_body = format!("before{fake_boundary_in_payload}after");
4497        let body = format!(
4498            "--{boundary}\r\n\
4499             Content-Disposition: form-data; name=\"file\"\r\n\
4500             Content-Type: application/octet-stream\r\n\r\n\
4501             {part_body}\r\n\
4502             --{boundary}--\r\n"
4503        );
4504
4505        let parts = parse_multipart_form_data(body.as_bytes(), boundary)
4506            .expect("should parse despite boundary-like string in payload");
4507        assert_eq!(parts.len(), 1, "should have exactly one part");
4508
4509        let part_data = &body.as_bytes()[parts[0].body_range.clone()];
4510        let part_text = std::str::from_utf8(part_data).unwrap();
4511        assert!(
4512            part_text.contains("NOTREAL"),
4513            "part body should contain the full fake boundary string"
4514        );
4515    }
4516
4517    #[test]
4518    fn multipart_normal_two_parts_still_works() {
4519        let boundary = "testboundary";
4520        let body = format!(
4521            "--{boundary}\r\n\
4522             Content-Disposition: form-data; name=\"field1\"\r\n\r\n\
4523             value1\r\n\
4524             --{boundary}\r\n\
4525             Content-Disposition: form-data; name=\"field2\"\r\n\r\n\
4526             value2\r\n\
4527             --{boundary}--\r\n"
4528        );
4529
4530        let parts = parse_multipart_form_data(body.as_bytes(), boundary)
4531            .expect("should parse two normal parts");
4532        assert_eq!(parts.len(), 2);
4533        assert_eq!(parts[0].name, "field1");
4534        assert_eq!(parts[1].name, "field2");
4535    }
4536
4537    #[test]
4538    #[serial]
4539    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4540    fn test_storage_timeout_default() {
4541        unsafe {
4542            std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4543        }
4544        let config = ServerConfig::from_env().unwrap();
4545        assert_eq!(config.storage_timeout_secs, 30);
4546    }
4547
4548    #[test]
4549    #[serial]
4550    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4551    fn test_storage_timeout_custom() {
4552        unsafe {
4553            std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "60");
4554        }
4555        let config = ServerConfig::from_env().unwrap();
4556        assert_eq!(config.storage_timeout_secs, 60);
4557        unsafe {
4558            std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4559        }
4560    }
4561
4562    #[test]
4563    #[serial]
4564    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4565    fn test_storage_timeout_min_boundary() {
4566        unsafe {
4567            std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "1");
4568        }
4569        let config = ServerConfig::from_env().unwrap();
4570        assert_eq!(config.storage_timeout_secs, 1);
4571        unsafe {
4572            std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4573        }
4574    }
4575
4576    #[test]
4577    #[serial]
4578    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4579    fn test_storage_timeout_max_boundary() {
4580        unsafe {
4581            std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "300");
4582        }
4583        let config = ServerConfig::from_env().unwrap();
4584        assert_eq!(config.storage_timeout_secs, 300);
4585        unsafe {
4586            std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4587        }
4588    }
4589
4590    #[test]
4591    #[serial]
4592    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4593    fn test_storage_timeout_empty_string_uses_default() {
4594        unsafe {
4595            std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "");
4596        }
4597        let config = ServerConfig::from_env().unwrap();
4598        assert_eq!(config.storage_timeout_secs, 30);
4599        unsafe {
4600            std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4601        }
4602    }
4603
4604    #[test]
4605    #[serial]
4606    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4607    fn test_storage_timeout_zero_rejected() {
4608        unsafe {
4609            std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "0");
4610        }
4611        let err = ServerConfig::from_env().unwrap_err();
4612        assert!(
4613            err.to_string().contains("between 1 and 300"),
4614            "error should mention valid range: {err}"
4615        );
4616        unsafe {
4617            std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4618        }
4619    }
4620
4621    #[test]
4622    #[serial]
4623    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4624    fn test_storage_timeout_over_max_rejected() {
4625        unsafe {
4626            std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "301");
4627        }
4628        let err = ServerConfig::from_env().unwrap_err();
4629        assert!(
4630            err.to_string().contains("between 1 and 300"),
4631            "error should mention valid range: {err}"
4632        );
4633        unsafe {
4634            std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4635        }
4636    }
4637
4638    #[test]
4639    #[serial]
4640    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4641    fn test_storage_timeout_non_numeric_rejected() {
4642        unsafe {
4643            std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "abc");
4644        }
4645        let err = ServerConfig::from_env().unwrap_err();
4646        assert!(
4647            err.to_string().contains("positive integer"),
4648            "error should mention positive integer: {err}"
4649        );
4650        unsafe {
4651            std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4652        }
4653    }
4654
4655    #[test]
4656    #[serial]
4657    fn test_max_concurrent_transforms_default() {
4658        unsafe {
4659            std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4660        }
4661        let config = ServerConfig::from_env().unwrap();
4662        assert_eq!(config.max_concurrent_transforms, 64);
4663    }
4664
4665    #[test]
4666    #[serial]
4667    fn test_max_concurrent_transforms_custom() {
4668        unsafe {
4669            std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "128");
4670        }
4671        let config = ServerConfig::from_env().unwrap();
4672        assert_eq!(config.max_concurrent_transforms, 128);
4673        unsafe {
4674            std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4675        }
4676    }
4677
4678    #[test]
4679    #[serial]
4680    fn test_max_concurrent_transforms_min_boundary() {
4681        unsafe {
4682            std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "1");
4683        }
4684        let config = ServerConfig::from_env().unwrap();
4685        assert_eq!(config.max_concurrent_transforms, 1);
4686        unsafe {
4687            std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4688        }
4689    }
4690
4691    #[test]
4692    #[serial]
4693    fn test_max_concurrent_transforms_max_boundary() {
4694        unsafe {
4695            std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "1024");
4696        }
4697        let config = ServerConfig::from_env().unwrap();
4698        assert_eq!(config.max_concurrent_transforms, 1024);
4699        unsafe {
4700            std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4701        }
4702    }
4703
4704    #[test]
4705    #[serial]
4706    fn test_max_concurrent_transforms_empty_uses_default() {
4707        unsafe {
4708            std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "");
4709        }
4710        let config = ServerConfig::from_env().unwrap();
4711        assert_eq!(config.max_concurrent_transforms, 64);
4712        unsafe {
4713            std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4714        }
4715    }
4716
4717    #[test]
4718    #[serial]
4719    fn test_max_concurrent_transforms_zero_rejected() {
4720        unsafe {
4721            std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "0");
4722        }
4723        let err = ServerConfig::from_env().unwrap_err();
4724        assert!(
4725            err.to_string().contains("between 1 and 1024"),
4726            "error should mention valid range: {err}"
4727        );
4728        unsafe {
4729            std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4730        }
4731    }
4732
4733    #[test]
4734    #[serial]
4735    fn test_max_concurrent_transforms_over_max_rejected() {
4736        unsafe {
4737            std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "1025");
4738        }
4739        let err = ServerConfig::from_env().unwrap_err();
4740        assert!(
4741            err.to_string().contains("between 1 and 1024"),
4742            "error should mention valid range: {err}"
4743        );
4744        unsafe {
4745            std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4746        }
4747    }
4748
4749    #[test]
4750    #[serial]
4751    fn test_max_concurrent_transforms_non_numeric_rejected() {
4752        unsafe {
4753            std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "abc");
4754        }
4755        let err = ServerConfig::from_env().unwrap_err();
4756        assert!(
4757            err.to_string().contains("positive integer"),
4758            "error should mention positive integer: {err}"
4759        );
4760        unsafe {
4761            std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4762        }
4763    }
4764
4765    #[test]
4766    #[serial]
4767    fn test_transform_deadline_default() {
4768        unsafe {
4769            std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4770        }
4771        let config = ServerConfig::from_env().unwrap();
4772        assert_eq!(config.transform_deadline_secs, 30);
4773    }
4774
4775    #[test]
4776    #[serial]
4777    fn test_transform_deadline_custom() {
4778        unsafe {
4779            std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "60");
4780        }
4781        let config = ServerConfig::from_env().unwrap();
4782        assert_eq!(config.transform_deadline_secs, 60);
4783        unsafe {
4784            std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4785        }
4786    }
4787
4788    #[test]
4789    #[serial]
4790    fn test_transform_deadline_min_boundary() {
4791        unsafe {
4792            std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "1");
4793        }
4794        let config = ServerConfig::from_env().unwrap();
4795        assert_eq!(config.transform_deadline_secs, 1);
4796        unsafe {
4797            std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4798        }
4799    }
4800
4801    #[test]
4802    #[serial]
4803    fn test_transform_deadline_max_boundary() {
4804        unsafe {
4805            std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "300");
4806        }
4807        let config = ServerConfig::from_env().unwrap();
4808        assert_eq!(config.transform_deadline_secs, 300);
4809        unsafe {
4810            std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4811        }
4812    }
4813
4814    #[test]
4815    #[serial]
4816    fn test_transform_deadline_empty_uses_default() {
4817        unsafe {
4818            std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "");
4819        }
4820        let config = ServerConfig::from_env().unwrap();
4821        assert_eq!(config.transform_deadline_secs, 30);
4822        unsafe {
4823            std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4824        }
4825    }
4826
4827    #[test]
4828    #[serial]
4829    fn test_transform_deadline_zero_rejected() {
4830        unsafe {
4831            std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "0");
4832        }
4833        let err = ServerConfig::from_env().unwrap_err();
4834        assert!(
4835            err.to_string().contains("between 1 and 300"),
4836            "error should mention valid range: {err}"
4837        );
4838        unsafe {
4839            std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4840        }
4841    }
4842
4843    #[test]
4844    #[serial]
4845    fn test_transform_deadline_over_max_rejected() {
4846        unsafe {
4847            std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "301");
4848        }
4849        let err = ServerConfig::from_env().unwrap_err();
4850        assert!(
4851            err.to_string().contains("between 1 and 300"),
4852            "error should mention valid range: {err}"
4853        );
4854        unsafe {
4855            std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4856        }
4857    }
4858
4859    #[test]
4860    #[serial]
4861    fn test_transform_deadline_non_numeric_rejected() {
4862        unsafe {
4863            std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "abc");
4864        }
4865        let err = ServerConfig::from_env().unwrap_err();
4866        assert!(
4867            err.to_string().contains("positive integer"),
4868            "error should mention positive integer: {err}"
4869        );
4870        unsafe {
4871            std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4872        }
4873    }
4874
4875    #[test]
4876    #[serial]
4877    #[cfg(feature = "azure")]
4878    fn test_azure_container_env_var_required() {
4879        unsafe {
4880            std::env::set_var("TRUSS_STORAGE_BACKEND", "azure");
4881            std::env::remove_var("TRUSS_AZURE_CONTAINER");
4882        }
4883        let err = ServerConfig::from_env().unwrap_err();
4884        assert!(
4885            err.to_string().contains("TRUSS_AZURE_CONTAINER"),
4886            "error should mention TRUSS_AZURE_CONTAINER: {err}"
4887        );
4888        unsafe {
4889            std::env::remove_var("TRUSS_STORAGE_BACKEND");
4890        }
4891    }
4892
4893    #[test]
4894    fn server_config_debug_redacts_bearer_token_and_signed_url_secret() {
4895        let mut config = ServerConfig::new(
4896            temp_dir("debug-redact"),
4897            Some("super-secret-token-12345".to_string()),
4898        );
4899        config.signed_url_key_id = Some("visible-key-id".to_string());
4900        config.signed_url_secret = Some("super-secret-hmac-key".to_string());
4901        let debug = format!("{config:?}");
4902        assert!(
4903            !debug.contains("super-secret-token-12345"),
4904            "bearer_token leaked in Debug output: {debug}"
4905        );
4906        assert!(
4907            !debug.contains("super-secret-hmac-key"),
4908            "signed_url_secret leaked in Debug output: {debug}"
4909        );
4910        assert!(
4911            debug.contains("[REDACTED]"),
4912            "expected [REDACTED] in Debug output: {debug}"
4913        );
4914        assert!(
4915            debug.contains("visible-key-id"),
4916            "signed_url_key_id should be visible: {debug}"
4917        );
4918    }
4919
4920    #[test]
4921    fn authorize_headers_accepts_correct_bearer_token() {
4922        let config = ServerConfig::new(temp_dir("auth-ok"), Some("correct-token".to_string()));
4923        let headers = vec![(
4924            "authorization".to_string(),
4925            "Bearer correct-token".to_string(),
4926        )];
4927        assert!(super::authorize_request_headers(&headers, &config).is_ok());
4928    }
4929
4930    #[test]
4931    fn authorize_headers_rejects_wrong_bearer_token() {
4932        let config = ServerConfig::new(temp_dir("auth-wrong"), Some("correct-token".to_string()));
4933        let headers = vec![(
4934            "authorization".to_string(),
4935            "Bearer wrong-token".to_string(),
4936        )];
4937        let err = super::authorize_request_headers(&headers, &config).unwrap_err();
4938        assert_eq!(err.status, "401 Unauthorized");
4939    }
4940
4941    #[test]
4942    fn authorize_headers_rejects_missing_header() {
4943        let config = ServerConfig::new(temp_dir("auth-missing"), Some("correct-token".to_string()));
4944        let headers: Vec<(String, String)> = vec![];
4945        let err = super::authorize_request_headers(&headers, &config).unwrap_err();
4946        assert_eq!(err.status, "401 Unauthorized");
4947    }
4948
4949    // ── TransformSlot RAII guard ──────────────────────────────────────
4950
4951    #[test]
4952    fn transform_slot_acquire_succeeds_under_limit() {
4953        use std::sync::Arc;
4954        use std::sync::atomic::AtomicU64;
4955
4956        let counter = Arc::new(AtomicU64::new(0));
4957        let slot = super::TransformSlot::try_acquire(&counter, 2);
4958        assert!(slot.is_some());
4959        assert_eq!(counter.load(Ordering::Relaxed), 1);
4960    }
4961
4962    #[test]
4963    fn transform_slot_acquire_returns_none_at_limit() {
4964        use std::sync::Arc;
4965        use std::sync::atomic::AtomicU64;
4966
4967        let counter = Arc::new(AtomicU64::new(0));
4968        let _s1 = super::TransformSlot::try_acquire(&counter, 1).unwrap();
4969        let s2 = super::TransformSlot::try_acquire(&counter, 1);
4970        assert!(s2.is_none());
4971        // Counter must still be 1 (failed acquire must not leak).
4972        assert_eq!(counter.load(Ordering::Relaxed), 1);
4973    }
4974
4975    #[test]
4976    fn transform_slot_drop_decrements_counter() {
4977        use std::sync::Arc;
4978        use std::sync::atomic::AtomicU64;
4979
4980        let counter = Arc::new(AtomicU64::new(0));
4981        {
4982            let _slot = super::TransformSlot::try_acquire(&counter, 4).unwrap();
4983            assert_eq!(counter.load(Ordering::Relaxed), 1);
4984        }
4985        // After drop the counter must return to zero.
4986        assert_eq!(counter.load(Ordering::Relaxed), 0);
4987    }
4988
4989    #[test]
4990    fn transform_slot_multiple_acquires_up_to_limit() {
4991        use std::sync::Arc;
4992        use std::sync::atomic::AtomicU64;
4993
4994        let counter = Arc::new(AtomicU64::new(0));
4995        let limit = 3u64;
4996        let mut slots = Vec::new();
4997        for _ in 0..limit {
4998            slots.push(super::TransformSlot::try_acquire(&counter, limit).unwrap());
4999        }
5000        assert_eq!(counter.load(Ordering::Relaxed), limit);
5001        // One more must fail.
5002        assert!(super::TransformSlot::try_acquire(&counter, limit).is_none());
5003        assert_eq!(counter.load(Ordering::Relaxed), limit);
5004        // Drop all slots.
5005        slots.clear();
5006        assert_eq!(counter.load(Ordering::Relaxed), 0);
5007    }
5008
5009    // ── Access log via emit_access_log ────────────────────────────────
5010
5011    #[test]
5012    fn emit_access_log_produces_json_with_expected_fields() {
5013        use std::sync::{Arc, Mutex};
5014        use std::time::Instant;
5015
5016        let captured = Arc::new(Mutex::new(String::new()));
5017        let captured_clone = Arc::clone(&captured);
5018        let handler: super::LogHandler =
5019            Arc::new(move |msg: &str| *captured_clone.lock().unwrap() = msg.to_owned());
5020
5021        let mut config = ServerConfig::new(temp_dir("access-log"), None);
5022        config.log_handler = Some(handler);
5023
5024        let start = Instant::now();
5025        super::emit_access_log(
5026            &config,
5027            &super::AccessLogEntry {
5028                request_id: "req-123",
5029                method: "GET",
5030                path: "/image.png",
5031                route: "transform",
5032                status: "200",
5033                start,
5034                cache_status: Some("hit"),
5035            },
5036        );
5037
5038        let output = captured.lock().unwrap().clone();
5039        let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
5040        assert_eq!(parsed["kind"], "access_log");
5041        assert_eq!(parsed["request_id"], "req-123");
5042        assert_eq!(parsed["method"], "GET");
5043        assert_eq!(parsed["path"], "/image.png");
5044        assert_eq!(parsed["route"], "transform");
5045        assert_eq!(parsed["status"], "200");
5046        assert_eq!(parsed["cache_status"], "hit");
5047        assert!(parsed["latency_ms"].is_u64());
5048    }
5049
5050    #[test]
5051    fn emit_access_log_null_cache_status_when_none() {
5052        use std::sync::{Arc, Mutex};
5053        use std::time::Instant;
5054
5055        let captured = Arc::new(Mutex::new(String::new()));
5056        let captured_clone = Arc::clone(&captured);
5057        let handler: super::LogHandler =
5058            Arc::new(move |msg: &str| *captured_clone.lock().unwrap() = msg.to_owned());
5059
5060        let mut config = ServerConfig::new(temp_dir("access-log-none"), None);
5061        config.log_handler = Some(handler);
5062
5063        super::emit_access_log(
5064            &config,
5065            &super::AccessLogEntry {
5066                request_id: "req-456",
5067                method: "POST",
5068                path: "/upload",
5069                route: "upload",
5070                status: "201",
5071                start: Instant::now(),
5072                cache_status: None,
5073            },
5074        );
5075
5076        let output = captured.lock().unwrap().clone();
5077        let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
5078        assert!(parsed["cache_status"].is_null());
5079    }
5080
5081    // ── X-Request-Id header ───────────────────────────────────────────
5082
5083    #[test]
5084    fn x_request_id_is_extracted_from_incoming_headers() {
5085        let headers = vec![
5086            ("host".to_string(), "localhost".to_string()),
5087            ("x-request-id".to_string(), "custom-id-abc".to_string()),
5088        ];
5089        assert_eq!(
5090            super::extract_request_id(&headers),
5091            Some("custom-id-abc".to_string())
5092        );
5093    }
5094
5095    #[test]
5096    fn x_request_id_not_extracted_when_empty() {
5097        let headers = vec![("x-request-id".to_string(), "".to_string())];
5098        assert!(super::extract_request_id(&headers).is_none());
5099    }
5100
5101    #[test]
5102    fn x_request_id_not_extracted_when_absent() {
5103        let headers = vec![("host".to_string(), "localhost".to_string())];
5104        assert!(super::extract_request_id(&headers).is_none());
5105    }
5106
5107    // ── Cache status extraction ───────────────────────────────────────
5108
5109    #[test]
5110    fn cache_status_hit_detected() {
5111        let headers: Vec<(&str, String)> = vec![("Cache-Status", "\"truss\"; hit".to_string())];
5112        assert_eq!(super::extract_cache_status(&headers), Some("hit"));
5113    }
5114
5115    #[test]
5116    fn cache_status_miss_detected() {
5117        let headers: Vec<(&str, String)> =
5118            vec![("Cache-Status", "\"truss\"; fwd=miss".to_string())];
5119        assert_eq!(super::extract_cache_status(&headers), Some("miss"));
5120    }
5121
5122    #[test]
5123    fn cache_status_none_when_header_absent() {
5124        let headers: Vec<(&str, String)> = vec![("Content-Type", "image/png".to_string())];
5125        assert!(super::extract_cache_status(&headers).is_none());
5126    }
5127}