Skip to main content

truss/adapters/server/
mod.rs

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