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