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