Skip to main content

truss/adapters/server/
mod.rs

1mod auth;
2mod cache;
3mod http_parse;
4mod metrics;
5mod multipart;
6mod negotiate;
7mod remote;
8mod response;
9
10use auth::{
11    authorize_request, authorize_request_headers, authorize_signed_request,
12    canonical_query_without_signature, extend_transform_query, parse_optional_bool_query,
13    parse_optional_float_query, parse_optional_integer_query, parse_optional_u8_query,
14    parse_query_params, required_query_param, signed_source_query, url_authority,
15    validate_public_query_names,
16};
17use cache::{CacheLookup, TransformCache, compute_cache_key, try_versioned_cache_lookup};
18use http_parse::{
19    HttpRequest, parse_named, parse_optional_named, read_request_body, read_request_headers,
20    request_has_json_content_type,
21};
22use metrics::{
23    CACHE_HITS_TOTAL, CACHE_MISSES_TOTAL, MAX_CONCURRENT_TRANSFORMS, RouteMetric,
24    TRANSFORMS_IN_FLIGHT, record_http_metrics, render_metrics_text, uptime_seconds,
25};
26use multipart::{parse_multipart_boundary, parse_upload_request};
27use negotiate::{
28    CacheHitStatus, ImageResponsePolicy, PublicSourceKind, build_image_etag,
29    build_image_response_headers, if_none_match_matches, negotiate_output_format,
30};
31use remote::resolve_source_bytes;
32use response::{
33    HttpResponse, NOT_FOUND_BODY, bad_request_response, service_unavailable_response,
34    transform_error_response, unsupported_media_type_response, write_response,
35};
36
37use crate::{
38    Fit, MediaType, Position, RawArtifact, Rgba8, Rotation, TransformOptions, TransformRequest,
39    sniff_artifact, transform_raster, transform_svg,
40};
41use hmac::{Hmac, Mac};
42use serde::Deserialize;
43use serde_json::json;
44use sha2::{Digest, Sha256};
45use std::collections::BTreeMap;
46use std::env;
47use std::fmt;
48use std::io;
49use std::net::{TcpListener, TcpStream};
50use std::path::PathBuf;
51use std::str::FromStr;
52use std::sync::Arc;
53use std::sync::atomic::Ordering;
54use std::time::Duration;
55use url::Url;
56
57/// The default bind address for the development HTTP server.
58pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
59
60/// The default storage root used by the server adapter.
61pub const DEFAULT_STORAGE_ROOT: &str = ".";
62
63const DEFAULT_PUBLIC_MAX_AGE_SECONDS: u32 = 3600;
64const DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS: u32 = 60;
65const SOCKET_READ_TIMEOUT: Duration = Duration::from_secs(60);
66const SOCKET_WRITE_TIMEOUT: Duration = Duration::from_secs(60);
67/// Number of worker threads for handling incoming connections concurrently.
68const WORKER_THREADS: usize = 8;
69type HmacSha256 = Hmac<Sha256>;
70
71/// Maximum number of requests served over a single keep-alive connection before
72/// the server closes it.  This prevents a single client from monopolising a
73/// worker thread indefinitely.
74const KEEP_ALIVE_MAX_REQUESTS: usize = 100;
75
76/// Default wall-clock deadline for server-side transforms.
77///
78/// The server injects this deadline into every transform request to prevent individual
79/// requests from consuming unbounded wall-clock time. Library and CLI consumers are not subject
80/// to this limit by default.
81const SERVER_TRANSFORM_DEADLINE: Duration = Duration::from_secs(30);
82
83#[derive(Clone, Copy)]
84struct PublicCacheControl {
85    max_age: u32,
86    stale_while_revalidate: u32,
87}
88
89#[derive(Clone, Copy)]
90struct ImageResponseConfig {
91    disable_accept_negotiation: bool,
92    public_cache_control: PublicCacheControl,
93}
94
95/// Runtime configuration for the HTTP server adapter.
96///
97/// The HTTP adapter keeps environment-specific concerns, such as the storage root and
98/// authentication secret, outside the Core transformation API. Tests and embedding runtimes
99/// can construct this value directly, while the CLI entry point typically uses
100/// [`ServerConfig::from_env`] to load the same fields from process environment variables.
101/// A logging callback invoked by the server for diagnostic messages.
102///
103/// Adapters that embed the server can supply a custom handler to route
104/// messages to their preferred logging infrastructure instead of stderr.
105pub type LogHandler = Arc<dyn Fn(&str) + Send + Sync>;
106
107pub struct ServerConfig {
108    /// The storage root used for `source.kind=path` lookups.
109    pub storage_root: PathBuf,
110    /// The expected Bearer token for private endpoints.
111    pub bearer_token: Option<String>,
112    /// The externally visible base URL used for public signed-URL authority.
113    ///
114    /// When this value is set, public signed GET requests use its authority component when
115    /// reconstructing the canonical signature payload. This is primarily useful when the server
116    /// runs behind a reverse proxy and the incoming `Host` header is not the externally visible
117    /// authority that clients sign.
118    pub public_base_url: Option<String>,
119    /// The expected key identifier for public signed GET requests.
120    pub signed_url_key_id: Option<String>,
121    /// The shared secret used to verify public signed GET requests.
122    pub signed_url_secret: Option<String>,
123    /// Whether server-side URL sources may bypass private-network and port restrictions.
124    ///
125    /// This flag is intended for local development and automated tests where fixture servers
126    /// commonly run on loopback addresses and non-standard ports. Production-like configurations
127    /// should keep this disabled.
128    pub allow_insecure_url_sources: bool,
129    /// Optional directory for the on-disk transform cache.
130    ///
131    /// When set, transformed image bytes are cached on disk using a sharded directory layout
132    /// (`ab/cd/ef/<sha256_hex>`). Repeated requests with the same source and transform options
133    /// are served from the cache instead of re-transforming. When `None`, caching is disabled
134    /// and every request performs a fresh transform.
135    pub cache_root: Option<PathBuf>,
136    /// `Cache-Control: max-age` value (in seconds) for public GET image responses.
137    ///
138    /// Defaults to `3600`. Operators can tune this
139    /// via the `TRUSS_PUBLIC_MAX_AGE` environment variable when running behind a CDN.
140    pub public_max_age_seconds: u32,
141    /// `Cache-Control: stale-while-revalidate` value (in seconds) for public GET image responses.
142    ///
143    /// Defaults to `60`. Configurable
144    /// via `TRUSS_PUBLIC_STALE_WHILE_REVALIDATE`.
145    pub public_stale_while_revalidate_seconds: u32,
146    /// Whether Accept-based content negotiation is disabled for public GET endpoints.
147    ///
148    /// When running behind a CDN such as CloudFront, Accept negotiation combined with
149    /// `Vary: Accept` can cause cache key mismatches or mis-served responses if the CDN
150    /// cache policy does not forward the `Accept` header.  Setting this flag to `true`
151    /// disables Accept negotiation entirely: public GET requests that omit the `format`
152    /// query parameter will preserve the input format instead of negotiating via Accept.
153    pub disable_accept_negotiation: bool,
154    /// Optional logging callback for diagnostic messages.
155    ///
156    /// When set, the server routes all diagnostic messages (cache errors, connection
157    /// failures, transform warnings) through this handler. When `None`, messages are
158    /// written to stderr via `eprintln!`.
159    pub log_handler: Option<LogHandler>,
160}
161
162impl Clone for ServerConfig {
163    fn clone(&self) -> Self {
164        Self {
165            storage_root: self.storage_root.clone(),
166            bearer_token: self.bearer_token.clone(),
167            public_base_url: self.public_base_url.clone(),
168            signed_url_key_id: self.signed_url_key_id.clone(),
169            signed_url_secret: self.signed_url_secret.clone(),
170            allow_insecure_url_sources: self.allow_insecure_url_sources,
171            cache_root: self.cache_root.clone(),
172            public_max_age_seconds: self.public_max_age_seconds,
173            public_stale_while_revalidate_seconds: self.public_stale_while_revalidate_seconds,
174            disable_accept_negotiation: self.disable_accept_negotiation,
175            log_handler: self.log_handler.clone(),
176        }
177    }
178}
179
180impl fmt::Debug for ServerConfig {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        f.debug_struct("ServerConfig")
183            .field("storage_root", &self.storage_root)
184            .field("bearer_token", &self.bearer_token)
185            .field("public_base_url", &self.public_base_url)
186            .field("signed_url_key_id", &self.signed_url_key_id)
187            .field("signed_url_secret", &self.signed_url_secret)
188            .field(
189                "allow_insecure_url_sources",
190                &self.allow_insecure_url_sources,
191            )
192            .field("cache_root", &self.cache_root)
193            .field("public_max_age_seconds", &self.public_max_age_seconds)
194            .field(
195                "public_stale_while_revalidate_seconds",
196                &self.public_stale_while_revalidate_seconds,
197            )
198            .field(
199                "disable_accept_negotiation",
200                &self.disable_accept_negotiation,
201            )
202            .field("log_handler", &self.log_handler.as_ref().map(|_| ".."))
203            .finish()
204    }
205}
206
207impl PartialEq for ServerConfig {
208    fn eq(&self, other: &Self) -> bool {
209        self.storage_root == other.storage_root
210            && self.bearer_token == other.bearer_token
211            && self.public_base_url == other.public_base_url
212            && self.signed_url_key_id == other.signed_url_key_id
213            && self.signed_url_secret == other.signed_url_secret
214            && self.allow_insecure_url_sources == other.allow_insecure_url_sources
215            && self.cache_root == other.cache_root
216            && self.public_max_age_seconds == other.public_max_age_seconds
217            && self.public_stale_while_revalidate_seconds
218                == other.public_stale_while_revalidate_seconds
219            && self.disable_accept_negotiation == other.disable_accept_negotiation
220    }
221}
222
223impl Eq for ServerConfig {}
224
225impl ServerConfig {
226    /// Creates a server configuration from explicit values.
227    ///
228    /// This constructor does not canonicalize the storage root. It is primarily intended for
229    /// tests and embedding scenarios where the caller already controls the filesystem layout.
230    ///
231    /// # Examples
232    ///
233    /// ```
234    /// use truss::adapters::server::ServerConfig;
235    ///
236    /// let config = ServerConfig::new(std::env::temp_dir(), Some("secret".to_string()));
237    ///
238    /// assert_eq!(config.bearer_token.as_deref(), Some("secret"));
239    /// ```
240    pub fn new(storage_root: PathBuf, bearer_token: Option<String>) -> Self {
241        Self {
242            storage_root,
243            bearer_token,
244            public_base_url: None,
245            signed_url_key_id: None,
246            signed_url_secret: None,
247            allow_insecure_url_sources: false,
248            cache_root: None,
249            public_max_age_seconds: DEFAULT_PUBLIC_MAX_AGE_SECONDS,
250            public_stale_while_revalidate_seconds: DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
251            disable_accept_negotiation: false,
252            log_handler: None,
253        }
254    }
255
256    /// Emits a diagnostic message through the configured log handler, or falls
257    /// back to stderr when no handler is set.
258    fn log(&self, msg: &str) {
259        if let Some(handler) = &self.log_handler {
260            handler(msg);
261        } else {
262            eprintln!("{msg}");
263        }
264    }
265
266    /// Returns a copy of the configuration with signed-URL verification credentials attached.
267    ///
268    /// Public GET endpoints require both a key identifier and a shared secret. Tests and local
269    /// development setups can use this helper to attach those values directly without going
270    /// through environment variables.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use truss::adapters::server::ServerConfig;
276    ///
277    /// let config = ServerConfig::new(std::env::temp_dir(), None)
278    ///     .with_signed_url_credentials("public-dev", "top-secret");
279    ///
280    /// assert_eq!(config.signed_url_key_id.as_deref(), Some("public-dev"));
281    /// assert_eq!(config.signed_url_secret.as_deref(), Some("top-secret"));
282    /// ```
283    pub fn with_signed_url_credentials(
284        mut self,
285        key_id: impl Into<String>,
286        secret: impl Into<String>,
287    ) -> Self {
288        self.signed_url_key_id = Some(key_id.into());
289        self.signed_url_secret = Some(secret.into());
290        self
291    }
292
293    /// Returns a copy of the configuration with insecure URL source allowances toggled.
294    ///
295    /// Enabling this flag allows URL sources that target loopback or private-network addresses
296    /// and permits non-standard ports. This is useful for local integration tests but weakens
297    /// the default SSRF protections of the server adapter.
298    ///
299    /// # Examples
300    ///
301    /// ```
302    /// use truss::adapters::server::ServerConfig;
303    ///
304    /// let config = ServerConfig::new(std::env::temp_dir(), Some("secret".to_string()))
305    ///     .with_insecure_url_sources(true);
306    ///
307    /// assert!(config.allow_insecure_url_sources);
308    /// ```
309    pub fn with_insecure_url_sources(mut self, allow_insecure_url_sources: bool) -> Self {
310        self.allow_insecure_url_sources = allow_insecure_url_sources;
311        self
312    }
313
314    /// Returns a copy of the configuration with a transform cache directory set.
315    ///
316    /// When a cache root is configured, the server stores transformed images on disk using a
317    /// sharded directory layout and serves subsequent identical requests from the cache.
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// use truss::adapters::server::ServerConfig;
323    ///
324    /// let config = ServerConfig::new(std::env::temp_dir(), None)
325    ///     .with_cache_root(std::env::temp_dir().join("truss-cache"));
326    ///
327    /// assert!(config.cache_root.is_some());
328    /// ```
329    pub fn with_cache_root(mut self, cache_root: impl Into<PathBuf>) -> Self {
330        self.cache_root = Some(cache_root.into());
331        self
332    }
333
334    /// Loads server configuration from environment variables.
335    ///
336    /// The adapter currently reads:
337    ///
338    /// - `TRUSS_STORAGE_ROOT`: filesystem root for `source.kind=path` inputs. Defaults to the
339    ///   current directory and is canonicalized before use.
340    /// - `TRUSS_BEARER_TOKEN`: private API Bearer token. When this value is missing, private
341    ///   endpoints remain unavailable and return `503 Service Unavailable`.
342    /// - `TRUSS_PUBLIC_BASE_URL`: externally visible base URL reserved for future public endpoint
343    ///   signing. When set, it must parse as an absolute `http` or `https` URL.
344    /// - `TRUSS_SIGNED_URL_KEY_ID`: key identifier accepted by public signed GET endpoints.
345    /// - `TRUSS_SIGNED_URL_SECRET`: shared secret used to verify public signed GET signatures.
346    /// - `TRUSS_ALLOW_INSECURE_URL_SOURCES`: when set to `1`, `true`, `yes`, or `on`, URL
347    ///   sources may target loopback or private-network addresses and non-standard ports.
348    /// - `TRUSS_CACHE_ROOT`: directory for the on-disk transform cache. When set, transformed
349    ///   images are cached using a sharded `ab/cd/ef/<sha256>` layout. When absent, caching is
350    ///   disabled.
351    /// - `TRUSS_PUBLIC_MAX_AGE`: `Cache-Control: max-age` value (in seconds) for public GET
352    ///   image responses. Defaults to 3600.
353    /// - `TRUSS_PUBLIC_STALE_WHILE_REVALIDATE`: `Cache-Control: stale-while-revalidate` value
354    ///   (in seconds) for public GET image responses. Defaults to 60.
355    /// - `TRUSS_DISABLE_ACCEPT_NEGOTIATION`: when set to `1`, `true`, `yes`, or `on`, disables
356    ///   Accept-based content negotiation on public GET endpoints. This is recommended when running
357    ///   behind a CDN that does not forward the `Accept` header in its cache key.
358    ///
359    /// # Errors
360    ///
361    /// Returns an [`io::Error`] when the configured storage root does not exist or cannot be
362    /// canonicalized.
363    ///
364    /// # Examples
365    ///
366    /// ```no_run
367    /// // SAFETY: This example runs single-threaded; no concurrent env access.
368    /// unsafe {
369    ///     std::env::set_var("TRUSS_STORAGE_ROOT", ".");
370    ///     std::env::set_var("TRUSS_ALLOW_INSECURE_URL_SOURCES", "true");
371    /// }
372    ///
373    /// let config = truss::adapters::server::ServerConfig::from_env().unwrap();
374    ///
375    /// assert!(config.storage_root.is_absolute());
376    /// assert!(config.allow_insecure_url_sources);
377    /// ```
378    pub fn from_env() -> io::Result<Self> {
379        let storage_root =
380            env::var("TRUSS_STORAGE_ROOT").unwrap_or_else(|_| DEFAULT_STORAGE_ROOT.to_string());
381        let storage_root = PathBuf::from(storage_root).canonicalize()?;
382        let bearer_token = env::var("TRUSS_BEARER_TOKEN")
383            .ok()
384            .filter(|value| !value.is_empty());
385        let public_base_url = env::var("TRUSS_PUBLIC_BASE_URL")
386            .ok()
387            .filter(|value| !value.is_empty())
388            .map(validate_public_base_url)
389            .transpose()?;
390        let signed_url_key_id = env::var("TRUSS_SIGNED_URL_KEY_ID")
391            .ok()
392            .filter(|value| !value.is_empty());
393        let signed_url_secret = env::var("TRUSS_SIGNED_URL_SECRET")
394            .ok()
395            .filter(|value| !value.is_empty());
396
397        if signed_url_key_id.is_some() != signed_url_secret.is_some() {
398            return Err(io::Error::new(
399                io::ErrorKind::InvalidInput,
400                "TRUSS_SIGNED_URL_KEY_ID and TRUSS_SIGNED_URL_SECRET must be set together",
401            ));
402        }
403
404        if signed_url_key_id.is_some() && public_base_url.is_none() {
405            eprintln!(
406                "truss: warning: TRUSS_SIGNED_URL_KEY_ID is set but TRUSS_PUBLIC_BASE_URL is not. \
407                 Behind a reverse proxy or CDN the Host header may differ from the externally \
408                 visible authority, causing signed URL verification to fail. Consider setting \
409                 TRUSS_PUBLIC_BASE_URL to the canonical external origin."
410            );
411        }
412
413        let cache_root = env::var("TRUSS_CACHE_ROOT")
414            .ok()
415            .filter(|value| !value.is_empty())
416            .map(PathBuf::from);
417
418        let public_max_age_seconds = parse_optional_env_u32("TRUSS_PUBLIC_MAX_AGE")?
419            .unwrap_or(DEFAULT_PUBLIC_MAX_AGE_SECONDS);
420        let public_stale_while_revalidate_seconds =
421            parse_optional_env_u32("TRUSS_PUBLIC_STALE_WHILE_REVALIDATE")?
422                .unwrap_or(DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS);
423
424        Ok(Self {
425            storage_root,
426            bearer_token,
427            public_base_url,
428            signed_url_key_id,
429            signed_url_secret,
430            allow_insecure_url_sources: env_flag("TRUSS_ALLOW_INSECURE_URL_SOURCES"),
431            cache_root,
432            public_max_age_seconds,
433            public_stale_while_revalidate_seconds,
434            disable_accept_negotiation: env_flag("TRUSS_DISABLE_ACCEPT_NEGOTIATION"),
435            log_handler: None,
436        })
437    }
438}
439
440/// Source selector used when generating a signed public transform URL.
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub enum SignedUrlSource {
443    /// Generates a signed `GET /images/by-path` URL.
444    Path {
445        /// The storage-relative source path.
446        path: String,
447        /// An optional source version token.
448        version: Option<String>,
449    },
450    /// Generates a signed `GET /images/by-url` URL.
451    Url {
452        /// The remote source URL.
453        url: String,
454        /// An optional source version token.
455        version: Option<String>,
456    },
457}
458
459/// Builds a signed public transform URL for the server adapter.
460///
461/// The resulting URL targets either `GET /images/by-path` or `GET /images/by-url` depending on
462/// `source`. `base_url` must be an absolute `http` or `https` URL that points at the externally
463/// visible server origin. The helper applies the same canonical query and HMAC-SHA256 signature
464/// scheme that the server adapter verifies at request time.
465///
466/// The helper serializes only explicitly requested transform options and omits fields that would
467/// resolve to the documented defaults on the server side.
468///
469/// # Errors
470///
471/// Returns an error string when `base_url` is not an absolute `http` or `https` URL, when the
472/// visible authority cannot be determined, or when the HMAC state cannot be initialized.
473///
474/// # Examples
475///
476/// ```
477/// use truss::adapters::server::{sign_public_url, SignedUrlSource};
478/// use truss::{MediaType, TransformOptions};
479///
480/// let url = sign_public_url(
481///     "https://cdn.example.com",
482///     SignedUrlSource::Path {
483///         path: "/image.png".to_string(),
484///         version: None,
485///     },
486///     &TransformOptions {
487///         format: Some(MediaType::Jpeg),
488///         ..TransformOptions::default()
489///     },
490///     "public-dev",
491///     "secret-value",
492///     4_102_444_800,
493/// )
494/// .unwrap();
495///
496/// assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
497/// assert!(url.contains("keyId=public-dev"));
498/// assert!(url.contains("signature="));
499/// ```
500pub fn sign_public_url(
501    base_url: &str,
502    source: SignedUrlSource,
503    options: &TransformOptions,
504    key_id: &str,
505    secret: &str,
506    expires: u64,
507) -> Result<String, String> {
508    let base_url = Url::parse(base_url).map_err(|error| format!("base URL is invalid: {error}"))?;
509    match base_url.scheme() {
510        "http" | "https" => {}
511        _ => return Err("base URL must use the http or https scheme".to_string()),
512    }
513
514    let route_path = match source {
515        SignedUrlSource::Path { .. } => "/images/by-path",
516        SignedUrlSource::Url { .. } => "/images/by-url",
517    };
518    let mut endpoint = base_url
519        .join(route_path)
520        .map_err(|error| format!("failed to resolve the public endpoint URL: {error}"))?;
521    let authority = url_authority(&endpoint)?;
522    let mut query = signed_source_query(source);
523    extend_transform_query(&mut query, options);
524    query.insert("keyId".to_string(), key_id.to_string());
525    query.insert("expires".to_string(), expires.to_string());
526
527    let canonical = format!(
528        "GET\n{}\n{}\n{}",
529        authority,
530        endpoint.path(),
531        canonical_query_without_signature(&query)
532    );
533    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
534        .map_err(|error| format!("failed to initialize signed URL HMAC: {error}"))?;
535    mac.update(canonical.as_bytes());
536    query.insert(
537        "signature".to_string(),
538        hex::encode(mac.finalize().into_bytes()),
539    );
540
541    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
542    for (name, value) in query {
543        serializer.append_pair(&name, &value);
544    }
545    endpoint.set_query(Some(&serializer.finish()));
546    Ok(endpoint.into())
547}
548
549/// Returns the bind address for the HTTP server adapter.
550///
551/// The adapter reads `TRUSS_BIND_ADDR` when it is present. Otherwise it falls back to
552/// [`DEFAULT_BIND_ADDR`].
553pub fn bind_addr() -> String {
554    env::var("TRUSS_BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string())
555}
556
557/// Serves requests until the listener stops producing connections.
558///
559/// This helper loads [`ServerConfig`] from the process environment and then delegates to
560/// [`serve_with_config`]. Health endpoints remain available even when the private API is not
561/// configured, but authenticated transform requests will return `503 Service Unavailable`
562/// unless `TRUSS_BEARER_TOKEN` is set.
563///
564/// # Errors
565///
566/// Returns an [`io::Error`] when the storage root cannot be resolved, when accepting the next
567/// connection fails, or when a response cannot be written to the socket.
568pub fn serve(listener: TcpListener) -> io::Result<()> {
569    let config = ServerConfig::from_env()?;
570    serve_with_config(listener, &config)
571}
572
573/// Serves requests with an explicit server configuration.
574///
575/// This is the adapter entry point for tests and embedding scenarios that want deterministic
576/// configuration instead of environment-variable lookup.
577///
578/// # Errors
579///
580/// Returns an [`io::Error`] when accepting the next connection fails or when a response cannot
581/// be written to the socket.
582pub fn serve_with_config(listener: TcpListener, config: &ServerConfig) -> io::Result<()> {
583    let config = Arc::new(config.clone());
584    let (sender, receiver) = std::sync::mpsc::channel::<TcpStream>();
585
586    // Spawn a fixed-size pool of worker threads. Each thread pulls connections
587    // from the shared channel and handles them independently, so a slow request
588    // no longer blocks all other clients.
589    let receiver = Arc::new(std::sync::Mutex::new(receiver));
590    let mut workers = Vec::with_capacity(WORKER_THREADS);
591    for _ in 0..WORKER_THREADS {
592        let rx = Arc::clone(&receiver);
593        let cfg = Arc::clone(&config);
594        workers.push(std::thread::spawn(move || {
595            while let Ok(stream) = rx.lock().expect("worker lock poisoned").recv() {
596                if let Err(err) = handle_stream(stream, &cfg) {
597                    cfg.log(&format!("failed to handle connection: {err}"));
598                }
599            }
600        }));
601    }
602
603    for stream in listener.incoming() {
604        match stream {
605            Ok(stream) => {
606                if sender.send(stream).is_err() {
607                    break;
608                }
609            }
610            Err(err) => return Err(err),
611        }
612    }
613
614    drop(sender);
615    for worker in workers {
616        let _ = worker.join();
617    }
618
619    Ok(())
620}
621
622/// Serves exactly one request using configuration loaded from the environment.
623///
624/// This helper is primarily useful in tests that want to drive the server over a real TCP
625/// socket but do not need a long-running loop.
626///
627/// # Errors
628///
629/// Returns an [`io::Error`] when the storage root cannot be resolved, when accepting the next
630/// connection fails, or when a response cannot be written to the socket.
631pub fn serve_once(listener: TcpListener) -> io::Result<()> {
632    let config = ServerConfig::from_env()?;
633    serve_once_with_config(listener, &config)
634}
635
636/// Serves exactly one request with an explicit server configuration.
637///
638/// # Errors
639///
640/// Returns an [`io::Error`] when accepting the next connection fails or when a response cannot
641/// be written to the socket.
642pub fn serve_once_with_config(listener: TcpListener, config: &ServerConfig) -> io::Result<()> {
643    let (stream, _) = listener.accept()?;
644    handle_stream(stream, config)
645}
646
647#[derive(Debug, Deserialize)]
648#[serde(deny_unknown_fields)]
649struct TransformImageRequestPayload {
650    source: TransformSourcePayload,
651    #[serde(default)]
652    options: TransformOptionsPayload,
653}
654
655#[derive(Debug, Deserialize)]
656#[serde(tag = "kind", rename_all = "lowercase")]
657enum TransformSourcePayload {
658    Path {
659        path: String,
660        version: Option<String>,
661    },
662    Url {
663        url: String,
664        version: Option<String>,
665    },
666}
667
668impl TransformSourcePayload {
669    /// Computes a stable source hash from the reference and version, avoiding the
670    /// need to read the full source bytes when a version tag is present. Returns
671    /// `None` when no version is available, in which case the caller must fall back
672    /// to the content-hash approach.
673    /// Computes a stable source hash that includes the instance configuration
674    /// boundaries (storage root, allow_insecure_url_sources) so that cache entries
675    /// cannot be reused across instances with different security settings sharing
676    /// the same cache directory.
677    fn versioned_source_hash(&self, config: &ServerConfig) -> Option<String> {
678        let (kind, reference, version) = match self {
679            Self::Path { path, version } => ("path", path.as_str(), version.as_deref()),
680            Self::Url { url, version } => ("url", url.as_str(), version.as_deref()),
681        };
682        let version = version?;
683        // Use newline separators so that values containing colons cannot collide
684        // with different (reference, version) pairs. Include configuration boundaries
685        // to prevent cross-instance cache poisoning.
686        let mut id = String::new();
687        id.push_str(kind);
688        id.push('\n');
689        id.push_str(reference);
690        id.push('\n');
691        id.push_str(version);
692        id.push('\n');
693        id.push_str(config.storage_root.to_string_lossy().as_ref());
694        id.push('\n');
695        id.push_str(if config.allow_insecure_url_sources {
696            "insecure"
697        } else {
698            "strict"
699        });
700        Some(hex::encode(Sha256::digest(id.as_bytes())))
701    }
702}
703
704#[derive(Debug, Default, Deserialize)]
705#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
706struct TransformOptionsPayload {
707    width: Option<u32>,
708    height: Option<u32>,
709    fit: Option<String>,
710    position: Option<String>,
711    format: Option<String>,
712    quality: Option<u8>,
713    background: Option<String>,
714    rotate: Option<u16>,
715    auto_orient: Option<bool>,
716    strip_metadata: Option<bool>,
717    preserve_exif: Option<bool>,
718    blur: Option<f32>,
719}
720
721impl TransformOptionsPayload {
722    fn into_options(self) -> Result<TransformOptions, HttpResponse> {
723        let defaults = TransformOptions::default();
724
725        Ok(TransformOptions {
726            width: self.width,
727            height: self.height,
728            fit: parse_optional_named(self.fit.as_deref(), "fit", Fit::from_str)?,
729            position: parse_optional_named(
730                self.position.as_deref(),
731                "position",
732                Position::from_str,
733            )?,
734            format: parse_optional_named(self.format.as_deref(), "format", MediaType::from_str)?,
735            quality: self.quality,
736            background: parse_optional_named(
737                self.background.as_deref(),
738                "background",
739                Rgba8::from_hex,
740            )?,
741            rotate: match self.rotate {
742                Some(value) => parse_named(&value.to_string(), "rotate", Rotation::from_str)?,
743                None => defaults.rotate,
744            },
745            auto_orient: self.auto_orient.unwrap_or(defaults.auto_orient),
746            strip_metadata: self.strip_metadata.unwrap_or(defaults.strip_metadata),
747            preserve_exif: self.preserve_exif.unwrap_or(defaults.preserve_exif),
748            blur: self.blur,
749            deadline: defaults.deadline,
750        })
751    }
752}
753
754fn handle_stream(mut stream: TcpStream, config: &ServerConfig) -> io::Result<()> {
755    // Prevent slow or stalled clients from blocking the accept loop indefinitely.
756    if let Err(err) = stream.set_read_timeout(Some(SOCKET_READ_TIMEOUT)) {
757        config.log(&format!("failed to set socket read timeout: {err}"));
758    }
759    if let Err(err) = stream.set_write_timeout(Some(SOCKET_WRITE_TIMEOUT)) {
760        config.log(&format!("failed to set socket write timeout: {err}"));
761    }
762
763    let mut requests_served: usize = 0;
764
765    loop {
766        let partial = match read_request_headers(&mut stream) {
767            Ok(partial) => partial,
768            Err(response) => {
769                if requests_served > 0 {
770                    return Ok(());
771                }
772                let _ = write_response(&mut stream, response, true);
773                return Ok(());
774            }
775        };
776
777        let client_wants_close = partial
778            .headers
779            .iter()
780            .any(|(name, value)| name == "connection" && value.eq_ignore_ascii_case("close"));
781
782        let is_head = partial.method == "HEAD";
783
784        let requires_auth = matches!(
785            (partial.method.as_str(), partial.path()),
786            ("POST", "/images:transform") | ("POST", "/images")
787        );
788        if requires_auth && let Err(response) = authorize_request_headers(&partial.headers, config)
789        {
790            let _ = write_response(&mut stream, response, true);
791            return Ok(());
792        }
793
794        let request = match read_request_body(&mut stream, partial) {
795            Ok(request) => request,
796            Err(response) => {
797                let _ = write_response(&mut stream, response, true);
798                return Ok(());
799            }
800        };
801        let route = classify_route(&request);
802        let mut response = route_request(request, config);
803        record_http_metrics(route, response.status);
804
805        if is_head {
806            response.body = Vec::new();
807        }
808
809        requests_served += 1;
810        let close_after = client_wants_close || requests_served >= KEEP_ALIVE_MAX_REQUESTS;
811
812        write_response(&mut stream, response, close_after)?;
813
814        if close_after {
815            return Ok(());
816        }
817    }
818}
819
820fn route_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
821    let method = request.method.clone();
822    let path = request.path().to_string();
823
824    match (method.as_str(), path.as_str()) {
825        ("GET" | "HEAD", "/health") => handle_health(config),
826        ("GET" | "HEAD", "/health/live") => handle_health_live(),
827        ("GET" | "HEAD", "/health/ready") => handle_health_ready(config),
828        ("GET" | "HEAD", "/images/by-path") => handle_public_path_request(request, config),
829        ("GET" | "HEAD", "/images/by-url") => handle_public_url_request(request, config),
830        ("POST", "/images:transform") => handle_transform_request(request, config),
831        ("POST", "/images") => handle_upload_request(request, config),
832        ("GET" | "HEAD", "/metrics") => handle_metrics_request(request, config),
833        _ => HttpResponse::problem("404 Not Found", NOT_FOUND_BODY.as_bytes().to_vec()),
834    }
835}
836
837fn classify_route(request: &HttpRequest) -> RouteMetric {
838    match (request.method.as_str(), request.path()) {
839        ("GET" | "HEAD", "/health") => RouteMetric::Health,
840        ("GET" | "HEAD", "/health/live") => RouteMetric::HealthLive,
841        ("GET" | "HEAD", "/health/ready") => RouteMetric::HealthReady,
842        ("GET" | "HEAD", "/images/by-path") => RouteMetric::PublicByPath,
843        ("GET" | "HEAD", "/images/by-url") => RouteMetric::PublicByUrl,
844        ("POST", "/images:transform") => RouteMetric::Transform,
845        ("POST", "/images") => RouteMetric::Upload,
846        ("GET" | "HEAD", "/metrics") => RouteMetric::Metrics,
847        _ => RouteMetric::Unknown,
848    }
849}
850
851fn handle_transform_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
852    if let Err(response) = authorize_request(&request, config) {
853        return response;
854    }
855
856    if !request_has_json_content_type(&request) {
857        return unsupported_media_type_response("content-type must be application/json");
858    }
859
860    let payload: TransformImageRequestPayload = match serde_json::from_slice(&request.body) {
861        Ok(payload) => payload,
862        Err(error) => {
863            return bad_request_response(&format!("request body must be valid JSON: {error}"));
864        }
865    };
866    let options = match payload.options.into_options() {
867        Ok(options) => options,
868        Err(response) => return response,
869    };
870
871    let versioned_hash = payload.source.versioned_source_hash(config);
872    if let Some(response) = try_versioned_cache_lookup(
873        versioned_hash.as_deref(),
874        &options,
875        &request,
876        ImageResponsePolicy::PrivateTransform,
877        config,
878    ) {
879        return response;
880    }
881
882    let source_bytes = match resolve_source_bytes(payload.source, config) {
883        Ok(bytes) => bytes,
884        Err(response) => return response,
885    };
886    transform_source_bytes(
887        source_bytes,
888        options,
889        versioned_hash.as_deref(),
890        &request,
891        ImageResponsePolicy::PrivateTransform,
892        config,
893    )
894}
895
896fn handle_public_path_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
897    handle_public_get_request(request, config, PublicSourceKind::Path)
898}
899
900fn handle_public_url_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
901    handle_public_get_request(request, config, PublicSourceKind::Url)
902}
903
904fn handle_public_get_request(
905    request: HttpRequest,
906    config: &ServerConfig,
907    source_kind: PublicSourceKind,
908) -> HttpResponse {
909    let query = match parse_query_params(&request) {
910        Ok(query) => query,
911        Err(response) => return response,
912    };
913    if let Err(response) = authorize_signed_request(&request, &query, config) {
914        return response;
915    }
916    let (source, options) = match parse_public_get_request(&query, source_kind) {
917        Ok(parsed) => parsed,
918        Err(response) => return response,
919    };
920
921    let versioned_hash = source.versioned_source_hash(config);
922    if let Some(response) = try_versioned_cache_lookup(
923        versioned_hash.as_deref(),
924        &options,
925        &request,
926        ImageResponsePolicy::PublicGet,
927        config,
928    ) {
929        return response;
930    }
931
932    let source_bytes = match resolve_source_bytes(source, config) {
933        Ok(bytes) => bytes,
934        Err(response) => return response,
935    };
936
937    transform_source_bytes(
938        source_bytes,
939        options,
940        versioned_hash.as_deref(),
941        &request,
942        ImageResponsePolicy::PublicGet,
943        config,
944    )
945}
946
947fn handle_upload_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
948    if let Err(response) = authorize_request(&request, config) {
949        return response;
950    }
951
952    let boundary = match parse_multipart_boundary(&request) {
953        Ok(boundary) => boundary,
954        Err(response) => return response,
955    };
956    let (file_bytes, options) = match parse_upload_request(&request.body, &boundary) {
957        Ok(parts) => parts,
958        Err(response) => return response,
959    };
960    transform_source_bytes(
961        file_bytes,
962        options,
963        None,
964        &request,
965        ImageResponsePolicy::PrivateTransform,
966        config,
967    )
968}
969
970/// Returns a minimal liveness response confirming the process is running.
971fn handle_health_live() -> HttpResponse {
972    let body = serde_json::to_vec(&json!({
973        "status": "ok",
974        "service": "truss",
975        "version": env!("CARGO_PKG_VERSION"),
976    }))
977    .expect("serialize liveness");
978    let mut body = body;
979    body.push(b'\n');
980    HttpResponse::json("200 OK", body)
981}
982
983/// Returns a readiness response after checking that critical dependencies are
984/// available (storage root, cache root if configured, and transform capacity).
985fn handle_health_ready(config: &ServerConfig) -> HttpResponse {
986    let mut checks: Vec<serde_json::Value> = Vec::new();
987    let mut all_ok = true;
988
989    let storage_ok = config.storage_root.is_dir();
990    checks.push(json!({
991        "name": "storageRoot",
992        "status": if storage_ok { "ok" } else { "fail" },
993    }));
994    if !storage_ok {
995        all_ok = false;
996    }
997
998    if let Some(cache_root) = &config.cache_root {
999        let cache_ok = cache_root.is_dir();
1000        checks.push(json!({
1001            "name": "cacheRoot",
1002            "status": if cache_ok { "ok" } else { "fail" },
1003        }));
1004        if !cache_ok {
1005            all_ok = false;
1006        }
1007    }
1008
1009    let in_flight = TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed);
1010    let overloaded = in_flight >= MAX_CONCURRENT_TRANSFORMS;
1011    checks.push(json!({
1012        "name": "transformCapacity",
1013        "status": if overloaded { "fail" } else { "ok" },
1014    }));
1015    if overloaded {
1016        all_ok = false;
1017    }
1018
1019    let status_str = if all_ok { "ok" } else { "fail" };
1020    let mut body = serde_json::to_vec(&json!({
1021        "status": status_str,
1022        "checks": checks,
1023    }))
1024    .expect("serialize readiness");
1025    body.push(b'\n');
1026
1027    if all_ok {
1028        HttpResponse::json("200 OK", body)
1029    } else {
1030        HttpResponse::json("503 Service Unavailable", body)
1031    }
1032}
1033
1034/// Returns a comprehensive diagnostic health response.
1035fn handle_health(config: &ServerConfig) -> HttpResponse {
1036    let mut checks: Vec<serde_json::Value> = Vec::new();
1037    let mut all_ok = true;
1038
1039    let storage_ok = config.storage_root.is_dir();
1040    checks.push(json!({
1041        "name": "storageRoot",
1042        "status": if storage_ok { "ok" } else { "fail" },
1043    }));
1044    if !storage_ok {
1045        all_ok = false;
1046    }
1047
1048    if let Some(cache_root) = &config.cache_root {
1049        let cache_ok = cache_root.is_dir();
1050        checks.push(json!({
1051            "name": "cacheRoot",
1052            "status": if cache_ok { "ok" } else { "fail" },
1053        }));
1054        if !cache_ok {
1055            all_ok = false;
1056        }
1057    }
1058
1059    let in_flight = TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed);
1060    let overloaded = in_flight >= MAX_CONCURRENT_TRANSFORMS;
1061    checks.push(json!({
1062        "name": "transformCapacity",
1063        "status": if overloaded { "fail" } else { "ok" },
1064    }));
1065    if overloaded {
1066        all_ok = false;
1067    }
1068
1069    let status_str = if all_ok { "ok" } else { "fail" };
1070    let mut body = serde_json::to_vec(&json!({
1071        "status": status_str,
1072        "service": "truss",
1073        "version": env!("CARGO_PKG_VERSION"),
1074        "uptimeSeconds": uptime_seconds(),
1075        "checks": checks,
1076    }))
1077    .expect("serialize health");
1078    body.push(b'\n');
1079
1080    HttpResponse::json("200 OK", body)
1081}
1082
1083fn handle_metrics_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1084    if let Err(response) = authorize_request(&request, config) {
1085        return response;
1086    }
1087
1088    HttpResponse::text(
1089        "200 OK",
1090        "text/plain; version=0.0.4; charset=utf-8",
1091        render_metrics_text().into_bytes(),
1092    )
1093}
1094
1095fn parse_public_get_request(
1096    query: &BTreeMap<String, String>,
1097    source_kind: PublicSourceKind,
1098) -> Result<(TransformSourcePayload, TransformOptions), HttpResponse> {
1099    validate_public_query_names(query, source_kind)?;
1100
1101    let source = match source_kind {
1102        PublicSourceKind::Path => TransformSourcePayload::Path {
1103            path: required_query_param(query, "path")?.to_string(),
1104            version: query.get("version").cloned(),
1105        },
1106        PublicSourceKind::Url => TransformSourcePayload::Url {
1107            url: required_query_param(query, "url")?.to_string(),
1108            version: query.get("version").cloned(),
1109        },
1110    };
1111
1112    let defaults = TransformOptions::default();
1113    let options = TransformOptions {
1114        width: parse_optional_integer_query(query, "width")?,
1115        height: parse_optional_integer_query(query, "height")?,
1116        fit: parse_optional_named(query.get("fit").map(String::as_str), "fit", Fit::from_str)?,
1117        position: parse_optional_named(
1118            query.get("position").map(String::as_str),
1119            "position",
1120            Position::from_str,
1121        )?,
1122        format: parse_optional_named(
1123            query.get("format").map(String::as_str),
1124            "format",
1125            MediaType::from_str,
1126        )?,
1127        quality: parse_optional_u8_query(query, "quality")?,
1128        background: parse_optional_named(
1129            query.get("background").map(String::as_str),
1130            "background",
1131            Rgba8::from_hex,
1132        )?,
1133        rotate: match query.get("rotate") {
1134            Some(value) => parse_named(value, "rotate", Rotation::from_str)?,
1135            None => defaults.rotate,
1136        },
1137        auto_orient: parse_optional_bool_query(query, "autoOrient")?
1138            .unwrap_or(defaults.auto_orient),
1139        strip_metadata: parse_optional_bool_query(query, "stripMetadata")?
1140            .unwrap_or(defaults.strip_metadata),
1141        preserve_exif: parse_optional_bool_query(query, "preserveExif")?
1142            .unwrap_or(defaults.preserve_exif),
1143        blur: parse_optional_float_query(query, "blur")?,
1144        deadline: defaults.deadline,
1145    };
1146
1147    Ok((source, options))
1148}
1149
1150fn transform_source_bytes(
1151    source_bytes: Vec<u8>,
1152    options: TransformOptions,
1153    versioned_hash: Option<&str>,
1154    request: &HttpRequest,
1155    response_policy: ImageResponsePolicy,
1156    config: &ServerConfig,
1157) -> HttpResponse {
1158    let content_hash;
1159    let source_hash = match versioned_hash {
1160        Some(hash) => hash,
1161        None => {
1162            content_hash = hex::encode(Sha256::digest(&source_bytes));
1163            &content_hash
1164        }
1165    };
1166
1167    let cache = config
1168        .cache_root
1169        .as_ref()
1170        .map(|root| TransformCache::new(root.clone()).with_log_handler(config.log_handler.clone()));
1171
1172    if let Some(ref cache) = cache
1173        && options.format.is_some()
1174    {
1175        let cache_key = compute_cache_key(source_hash, &options, None);
1176        if let CacheLookup::Hit {
1177            media_type,
1178            body,
1179            age,
1180        } = cache.get(&cache_key)
1181        {
1182            CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
1183            let etag = build_image_etag(&body);
1184            let mut headers = build_image_response_headers(
1185                media_type,
1186                &etag,
1187                response_policy,
1188                false,
1189                CacheHitStatus::Hit,
1190                config.public_max_age_seconds,
1191                config.public_stale_while_revalidate_seconds,
1192            );
1193            headers.push(("Age".to_string(), age.as_secs().to_string()));
1194            if matches!(response_policy, ImageResponsePolicy::PublicGet)
1195                && if_none_match_matches(request.header("if-none-match"), &etag)
1196            {
1197                return HttpResponse::empty("304 Not Modified", headers);
1198            }
1199            return HttpResponse::binary_with_headers(
1200                "200 OK",
1201                media_type.as_mime(),
1202                headers,
1203                body,
1204            );
1205        }
1206    }
1207
1208    let in_flight = TRANSFORMS_IN_FLIGHT.fetch_add(1, Ordering::Relaxed);
1209    if in_flight >= MAX_CONCURRENT_TRANSFORMS {
1210        TRANSFORMS_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
1211        return service_unavailable_response("too many concurrent transforms; retry later");
1212    }
1213    let response = transform_source_bytes_inner(
1214        source_bytes,
1215        options,
1216        request,
1217        response_policy,
1218        cache.as_ref(),
1219        source_hash,
1220        ImageResponseConfig {
1221            disable_accept_negotiation: config.disable_accept_negotiation,
1222            public_cache_control: PublicCacheControl {
1223                max_age: config.public_max_age_seconds,
1224                stale_while_revalidate: config.public_stale_while_revalidate_seconds,
1225            },
1226        },
1227    );
1228    TRANSFORMS_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
1229    response
1230}
1231
1232fn transform_source_bytes_inner(
1233    source_bytes: Vec<u8>,
1234    mut options: TransformOptions,
1235    request: &HttpRequest,
1236    response_policy: ImageResponsePolicy,
1237    cache: Option<&TransformCache>,
1238    source_hash: &str,
1239    response_config: ImageResponseConfig,
1240) -> HttpResponse {
1241    if options.deadline.is_none() {
1242        options.deadline = Some(SERVER_TRANSFORM_DEADLINE);
1243    }
1244    let artifact = match sniff_artifact(RawArtifact::new(source_bytes, None)) {
1245        Ok(artifact) => artifact,
1246        Err(error) => return transform_error_response(error),
1247    };
1248    let negotiation_used =
1249        if options.format.is_none() && !response_config.disable_accept_negotiation {
1250            match negotiate_output_format(request.header("accept"), &artifact) {
1251                Ok(Some(format)) => {
1252                    options.format = Some(format);
1253                    true
1254                }
1255                Ok(None) => false,
1256                Err(response) => return response,
1257            }
1258        } else {
1259            false
1260        };
1261
1262    let negotiated_accept = if negotiation_used {
1263        request.header("accept")
1264    } else {
1265        None
1266    };
1267    let cache_key = compute_cache_key(source_hash, &options, negotiated_accept);
1268
1269    if let Some(cache) = cache
1270        && let CacheLookup::Hit {
1271            media_type,
1272            body,
1273            age,
1274        } = cache.get(&cache_key)
1275    {
1276        CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
1277        let etag = build_image_etag(&body);
1278        let mut headers = build_image_response_headers(
1279            media_type,
1280            &etag,
1281            response_policy,
1282            negotiation_used,
1283            CacheHitStatus::Hit,
1284            response_config.public_cache_control.max_age,
1285            response_config.public_cache_control.stale_while_revalidate,
1286        );
1287        headers.push(("Age".to_string(), age.as_secs().to_string()));
1288        if matches!(response_policy, ImageResponsePolicy::PublicGet)
1289            && if_none_match_matches(request.header("if-none-match"), &etag)
1290        {
1291            return HttpResponse::empty("304 Not Modified", headers);
1292        }
1293        return HttpResponse::binary_with_headers("200 OK", media_type.as_mime(), headers, body);
1294    }
1295
1296    if cache.is_some() {
1297        CACHE_MISSES_TOTAL.fetch_add(1, Ordering::Relaxed);
1298    }
1299
1300    let is_svg = artifact.media_type == MediaType::Svg;
1301    let result = if is_svg {
1302        match transform_svg(TransformRequest::new(artifact, options)) {
1303            Ok(result) => result,
1304            Err(error) => return transform_error_response(error),
1305        }
1306    } else {
1307        match transform_raster(TransformRequest::new(artifact, options)) {
1308            Ok(result) => result,
1309            Err(error) => return transform_error_response(error),
1310        }
1311    };
1312
1313    for warning in &result.warnings {
1314        let msg = format!("truss: {warning}");
1315        if let Some(c) = cache
1316            && let Some(handler) = &c.log_handler
1317        {
1318            handler(&msg);
1319        } else {
1320            eprintln!("{msg}");
1321        }
1322    }
1323
1324    let output = result.artifact;
1325
1326    if let Some(cache) = cache {
1327        cache.put(&cache_key, output.media_type, &output.bytes);
1328    }
1329
1330    let cache_hit_status = if cache.is_some() {
1331        CacheHitStatus::Miss
1332    } else {
1333        CacheHitStatus::Disabled
1334    };
1335
1336    let etag = build_image_etag(&output.bytes);
1337    let headers = build_image_response_headers(
1338        output.media_type,
1339        &etag,
1340        response_policy,
1341        negotiation_used,
1342        cache_hit_status,
1343        response_config.public_cache_control.max_age,
1344        response_config.public_cache_control.stale_while_revalidate,
1345    );
1346
1347    if matches!(response_policy, ImageResponsePolicy::PublicGet)
1348        && if_none_match_matches(request.header("if-none-match"), &etag)
1349    {
1350        return HttpResponse::empty("304 Not Modified", headers);
1351    }
1352
1353    HttpResponse::binary_with_headers("200 OK", output.media_type.as_mime(), headers, output.bytes)
1354}
1355
1356fn env_flag(name: &str) -> bool {
1357    env::var(name)
1358        .map(|value| {
1359            matches!(
1360                value.as_str(),
1361                "1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON"
1362            )
1363        })
1364        .unwrap_or(false)
1365}
1366
1367fn parse_optional_env_u32(name: &str) -> io::Result<Option<u32>> {
1368    match env::var(name) {
1369        Ok(value) if !value.is_empty() => value.parse::<u32>().map(Some).map_err(|_| {
1370            io::Error::new(
1371                io::ErrorKind::InvalidInput,
1372                format!("{name} must be a non-negative integer"),
1373            )
1374        }),
1375        _ => Ok(None),
1376    }
1377}
1378
1379fn validate_public_base_url(value: String) -> io::Result<String> {
1380    let parsed = Url::parse(&value).map_err(|error| {
1381        io::Error::new(
1382            io::ErrorKind::InvalidInput,
1383            format!("TRUSS_PUBLIC_BASE_URL must be a valid URL: {error}"),
1384        )
1385    })?;
1386
1387    match parsed.scheme() {
1388        "http" | "https" => Ok(parsed.to_string()),
1389        _ => Err(io::Error::new(
1390            io::ErrorKind::InvalidInput,
1391            "TRUSS_PUBLIC_BASE_URL must use http or https",
1392        )),
1393    }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398    use super::http_parse::{
1399        HttpRequest, find_header_terminator, read_request_body, read_request_headers,
1400        resolve_storage_path,
1401    };
1402    use super::multipart::parse_multipart_form_data;
1403    use super::remote::{PinnedResolver, prepare_remote_fetch_target};
1404    use super::response::auth_required_response;
1405    use super::response::{HttpResponse, bad_request_response};
1406    use super::{
1407        CacheHitStatus, DEFAULT_BIND_ADDR, DEFAULT_PUBLIC_MAX_AGE_SECONDS,
1408        DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS, ImageResponsePolicy,
1409        MAX_CONCURRENT_TRANSFORMS, PublicSourceKind, ServerConfig, SignedUrlSource,
1410        TRANSFORMS_IN_FLIGHT, TransformSourcePayload, authorize_signed_request, bind_addr,
1411        build_image_etag, build_image_response_headers, canonical_query_without_signature,
1412        negotiate_output_format, parse_public_get_request, route_request, serve_once_with_config,
1413        sign_public_url, transform_source_bytes,
1414    };
1415    use crate::{
1416        Artifact, ArtifactMetadata, MediaType, RawArtifact, TransformOptions, sniff_artifact,
1417    };
1418    use hmac::{Hmac, Mac};
1419    use image::codecs::png::PngEncoder;
1420    use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
1421    use sha2::Sha256;
1422    use std::collections::BTreeMap;
1423    use std::fs;
1424    use std::io::{Cursor, Read, Write};
1425    use std::net::{SocketAddr, TcpListener, TcpStream};
1426    use std::path::{Path, PathBuf};
1427    use std::sync::atomic::Ordering;
1428    use std::thread;
1429    use std::time::{Duration, SystemTime, UNIX_EPOCH};
1430
1431    /// Test-only convenience wrapper that reads headers + body in one shot,
1432    /// preserving the original `read_request` semantics for existing tests.
1433    fn read_request<R: Read>(stream: &mut R) -> Result<HttpRequest, HttpResponse> {
1434        let partial = read_request_headers(stream)?;
1435        read_request_body(stream, partial)
1436    }
1437
1438    fn png_bytes() -> Vec<u8> {
1439        let image = RgbaImage::from_pixel(4, 3, Rgba([10, 20, 30, 255]));
1440        let mut bytes = Vec::new();
1441        PngEncoder::new(&mut bytes)
1442            .write_image(&image, 4, 3, ColorType::Rgba8.into())
1443            .expect("encode png");
1444        bytes
1445    }
1446
1447    fn temp_dir(name: &str) -> PathBuf {
1448        let unique = SystemTime::now()
1449            .duration_since(UNIX_EPOCH)
1450            .expect("current time")
1451            .as_nanos();
1452        let path = std::env::temp_dir().join(format!("truss-server-{name}-{unique}"));
1453        fs::create_dir_all(&path).expect("create temp dir");
1454        path
1455    }
1456
1457    fn write_png(path: &Path) {
1458        fs::write(path, png_bytes()).expect("write png fixture");
1459    }
1460
1461    fn artifact_with_alpha(has_alpha: bool) -> Artifact {
1462        Artifact::new(
1463            png_bytes(),
1464            MediaType::Png,
1465            ArtifactMetadata {
1466                width: Some(4),
1467                height: Some(3),
1468                frame_count: 1,
1469                duration: None,
1470                has_alpha: Some(has_alpha),
1471            },
1472        )
1473    }
1474
1475    fn sign_public_query(
1476        method: &str,
1477        authority: &str,
1478        path: &str,
1479        query: &BTreeMap<String, String>,
1480        secret: &str,
1481    ) -> String {
1482        let canonical = format!(
1483            "{method}\n{authority}\n{path}\n{}",
1484            canonical_query_without_signature(query)
1485        );
1486        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("create hmac");
1487        mac.update(canonical.as_bytes());
1488        hex::encode(mac.finalize().into_bytes())
1489    }
1490
1491    type FixtureResponse = (String, Vec<(String, String)>, Vec<u8>);
1492
1493    fn read_fixture_request(stream: &mut TcpStream) {
1494        stream
1495            .set_nonblocking(false)
1496            .expect("configure fixture stream blocking mode");
1497        stream
1498            .set_read_timeout(Some(Duration::from_millis(100)))
1499            .expect("configure fixture stream timeout");
1500
1501        let deadline = std::time::Instant::now() + Duration::from_secs(2);
1502        let mut buffer = Vec::new();
1503        let mut chunk = [0_u8; 1024];
1504        let header_end = loop {
1505            let read = match stream.read(&mut chunk) {
1506                Ok(read) => read,
1507                Err(error)
1508                    if matches!(
1509                        error.kind(),
1510                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
1511                    ) && std::time::Instant::now() < deadline =>
1512                {
1513                    thread::sleep(Duration::from_millis(10));
1514                    continue;
1515                }
1516                Err(error) => panic!("read fixture request headers: {error}"),
1517            };
1518            if read == 0 {
1519                panic!("fixture request ended before headers were complete");
1520            }
1521            buffer.extend_from_slice(&chunk[..read]);
1522            if let Some(index) = find_header_terminator(&buffer) {
1523                break index;
1524            }
1525        };
1526
1527        let header_text = std::str::from_utf8(&buffer[..header_end]).expect("fixture request utf8");
1528        let content_length = header_text
1529            .split("\r\n")
1530            .filter_map(|line| line.split_once(':'))
1531            .find_map(|(name, value)| {
1532                name.trim()
1533                    .eq_ignore_ascii_case("content-length")
1534                    .then_some(value.trim())
1535            })
1536            .map(|value| {
1537                value
1538                    .parse::<usize>()
1539                    .expect("fixture content-length should be numeric")
1540            })
1541            .unwrap_or(0);
1542
1543        let mut body = buffer.len().saturating_sub(header_end + 4);
1544        while body < content_length {
1545            let read = match stream.read(&mut chunk) {
1546                Ok(read) => read,
1547                Err(error)
1548                    if matches!(
1549                        error.kind(),
1550                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
1551                    ) && std::time::Instant::now() < deadline =>
1552                {
1553                    thread::sleep(Duration::from_millis(10));
1554                    continue;
1555                }
1556                Err(error) => panic!("read fixture request body: {error}"),
1557            };
1558            if read == 0 {
1559                panic!("fixture request body was truncated");
1560            }
1561            body += read;
1562        }
1563    }
1564
1565    fn spawn_http_server(responses: Vec<FixtureResponse>) -> (String, thread::JoinHandle<()>) {
1566        let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture server");
1567        listener
1568            .set_nonblocking(true)
1569            .expect("configure fixture server");
1570        let addr = listener.local_addr().expect("fixture server addr");
1571        let url = format!("http://{addr}/image");
1572
1573        let handle = thread::spawn(move || {
1574            for (status, headers, body) in responses {
1575                let deadline = std::time::Instant::now() + Duration::from_secs(10);
1576                let mut accepted = None;
1577                while std::time::Instant::now() < deadline {
1578                    match listener.accept() {
1579                        Ok(stream) => {
1580                            accepted = Some(stream);
1581                            break;
1582                        }
1583                        Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1584                            thread::sleep(Duration::from_millis(10));
1585                        }
1586                        Err(error) => panic!("accept fixture request: {error}"),
1587                    }
1588                }
1589
1590                let Some((mut stream, _)) = accepted else {
1591                    break;
1592                };
1593                read_fixture_request(&mut stream);
1594                let mut header = format!(
1595                    "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
1596                    body.len()
1597                );
1598                for (name, value) in headers {
1599                    header.push_str(&format!("{name}: {value}\r\n"));
1600                }
1601                header.push_str("\r\n");
1602                stream
1603                    .write_all(header.as_bytes())
1604                    .expect("write fixture headers");
1605                stream.write_all(&body).expect("write fixture body");
1606                stream.flush().expect("flush fixture response");
1607            }
1608        });
1609
1610        (url, handle)
1611    }
1612
1613    fn transform_request(path: &str) -> HttpRequest {
1614        HttpRequest {
1615            method: "POST".to_string(),
1616            target: "/images:transform".to_string(),
1617            version: "HTTP/1.1".to_string(),
1618            headers: vec![
1619                ("authorization".to_string(), "Bearer secret".to_string()),
1620                ("content-type".to_string(), "application/json".to_string()),
1621            ],
1622            body: format!(
1623                "{{\"source\":{{\"kind\":\"path\",\"path\":\"{path}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
1624            )
1625            .into_bytes(),
1626        }
1627    }
1628
1629    fn transform_url_request(url: &str) -> HttpRequest {
1630        HttpRequest {
1631            method: "POST".to_string(),
1632            target: "/images:transform".to_string(),
1633            version: "HTTP/1.1".to_string(),
1634            headers: vec![
1635                ("authorization".to_string(), "Bearer secret".to_string()),
1636                ("content-type".to_string(), "application/json".to_string()),
1637            ],
1638            body: format!(
1639                "{{\"source\":{{\"kind\":\"url\",\"url\":\"{url}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
1640            )
1641            .into_bytes(),
1642        }
1643    }
1644
1645    fn upload_request(file_bytes: &[u8], options_json: Option<&str>) -> HttpRequest {
1646        let boundary = "truss-test-boundary";
1647        let mut body = Vec::new();
1648        body.extend_from_slice(
1649            format!(
1650                "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"image.png\"\r\nContent-Type: image/png\r\n\r\n"
1651            )
1652            .as_bytes(),
1653        );
1654        body.extend_from_slice(file_bytes);
1655        body.extend_from_slice(b"\r\n");
1656
1657        if let Some(options_json) = options_json {
1658            body.extend_from_slice(
1659                format!(
1660                    "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{options_json}\r\n"
1661                )
1662                .as_bytes(),
1663            );
1664        }
1665
1666        body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
1667
1668        HttpRequest {
1669            method: "POST".to_string(),
1670            target: "/images".to_string(),
1671            version: "HTTP/1.1".to_string(),
1672            headers: vec![
1673                ("authorization".to_string(), "Bearer secret".to_string()),
1674                (
1675                    "content-type".to_string(),
1676                    format!("multipart/form-data; boundary={boundary}"),
1677                ),
1678            ],
1679            body,
1680        }
1681    }
1682
1683    fn metrics_request(with_auth: bool) -> HttpRequest {
1684        let mut headers = Vec::new();
1685        if with_auth {
1686            headers.push(("authorization".to_string(), "Bearer secret".to_string()));
1687        }
1688
1689        HttpRequest {
1690            method: "GET".to_string(),
1691            target: "/metrics".to_string(),
1692            version: "HTTP/1.1".to_string(),
1693            headers,
1694            body: Vec::new(),
1695        }
1696    }
1697
1698    fn response_body(response: &HttpResponse) -> String {
1699        String::from_utf8(response.body.clone()).expect("utf8 response body")
1700    }
1701
1702    fn signed_public_request(target: &str, host: &str, secret: &str) -> HttpRequest {
1703        let (path, query) = target.split_once('?').expect("target has query");
1704        let mut query = url::form_urlencoded::parse(query.as_bytes())
1705            .into_owned()
1706            .collect::<BTreeMap<_, _>>();
1707        let signature = sign_public_query("GET", host, path, &query, secret);
1708        query.insert("signature".to_string(), signature);
1709        let final_query = url::form_urlencoded::Serializer::new(String::new())
1710            .extend_pairs(
1711                query
1712                    .iter()
1713                    .map(|(name, value)| (name.as_str(), value.as_str())),
1714            )
1715            .finish();
1716
1717        HttpRequest {
1718            method: "GET".to_string(),
1719            target: format!("{path}?{final_query}"),
1720            version: "HTTP/1.1".to_string(),
1721            headers: vec![("host".to_string(), host.to_string())],
1722            body: Vec::new(),
1723        }
1724    }
1725
1726    #[test]
1727    fn uses_default_bind_addr_when_env_is_missing() {
1728        unsafe { std::env::remove_var("TRUSS_BIND_ADDR") };
1729        assert_eq!(bind_addr(), DEFAULT_BIND_ADDR);
1730    }
1731
1732    #[test]
1733    fn authorize_signed_request_accepts_a_valid_signature() {
1734        let request = signed_public_request(
1735            "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
1736            "assets.example.com",
1737            "secret-value",
1738        );
1739        let query = super::auth::parse_query_params(&request).expect("parse query");
1740        let config = ServerConfig::new(temp_dir("public-auth"), None)
1741            .with_signed_url_credentials("public-dev", "secret-value");
1742
1743        authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
1744    }
1745
1746    #[test]
1747    fn authorize_signed_request_uses_public_base_url_authority() {
1748        let request = signed_public_request(
1749            "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
1750            "cdn.example.com",
1751            "secret-value",
1752        );
1753        let query = super::auth::parse_query_params(&request).expect("parse query");
1754        let mut config = ServerConfig::new(temp_dir("public-authority"), None)
1755            .with_signed_url_credentials("public-dev", "secret-value");
1756        config.public_base_url = Some("https://cdn.example.com".to_string());
1757
1758        authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
1759    }
1760
1761    #[test]
1762    fn negotiate_output_format_prefers_alpha_safe_formats_for_transparent_inputs() {
1763        let format =
1764            negotiate_output_format(Some("image/jpeg,image/png"), &artifact_with_alpha(true))
1765                .expect("negotiate output format")
1766                .expect("resolved output format");
1767
1768        assert_eq!(format, MediaType::Png);
1769    }
1770
1771    #[test]
1772    fn negotiate_output_format_prefers_avif_for_wildcard_accept() {
1773        let format = negotiate_output_format(Some("image/*"), &artifact_with_alpha(false))
1774            .expect("negotiate output format")
1775            .expect("resolved output format");
1776
1777        assert_eq!(format, MediaType::Avif);
1778    }
1779
1780    #[test]
1781    fn build_image_response_headers_include_cache_and_safety_metadata() {
1782        let headers = build_image_response_headers(
1783            MediaType::Webp,
1784            &build_image_etag(b"demo"),
1785            ImageResponsePolicy::PublicGet,
1786            true,
1787            CacheHitStatus::Disabled,
1788            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
1789            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
1790        );
1791
1792        assert!(headers.contains(&(
1793            "Cache-Control".to_string(),
1794            "public, max-age=3600, stale-while-revalidate=60".to_string()
1795        )));
1796        assert!(headers.contains(&("Vary".to_string(), "Accept".to_string())));
1797        assert!(headers.contains(&("X-Content-Type-Options".to_string(), "nosniff".to_string())));
1798        assert!(headers.contains(&(
1799            "Content-Disposition".to_string(),
1800            "inline; filename=\"truss.webp\"".to_string()
1801        )));
1802        assert!(headers.contains(&(
1803            "Cache-Status".to_string(),
1804            "\"truss\"; fwd=miss".to_string()
1805        )));
1806    }
1807
1808    #[test]
1809    fn build_image_response_headers_include_csp_sandbox_for_svg() {
1810        let headers = build_image_response_headers(
1811            MediaType::Svg,
1812            &build_image_etag(b"svg-data"),
1813            ImageResponsePolicy::PublicGet,
1814            true,
1815            CacheHitStatus::Disabled,
1816            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
1817            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
1818        );
1819
1820        assert!(headers.contains(&("Content-Security-Policy".to_string(), "sandbox".to_string())));
1821    }
1822
1823    #[test]
1824    fn build_image_response_headers_omit_csp_sandbox_for_raster() {
1825        let headers = build_image_response_headers(
1826            MediaType::Png,
1827            &build_image_etag(b"png-data"),
1828            ImageResponsePolicy::PublicGet,
1829            true,
1830            CacheHitStatus::Disabled,
1831            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
1832            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
1833        );
1834
1835        assert!(!headers.iter().any(|(k, _)| k == "Content-Security-Policy"));
1836    }
1837
1838    /// RAII guard that restores `TRANSFORMS_IN_FLIGHT` to its previous value
1839    /// on drop, even if the test panics.
1840    struct InFlightGuard {
1841        previous: u64,
1842    }
1843
1844    impl InFlightGuard {
1845        fn set(value: u64) -> Self {
1846            let previous = TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed);
1847            TRANSFORMS_IN_FLIGHT.store(value, Ordering::Relaxed);
1848            Self { previous }
1849        }
1850    }
1851
1852    impl Drop for InFlightGuard {
1853        fn drop(&mut self) {
1854            TRANSFORMS_IN_FLIGHT.store(self.previous, Ordering::Relaxed);
1855        }
1856    }
1857
1858    #[test]
1859    fn backpressure_rejects_when_at_capacity() {
1860        let _guard = InFlightGuard::set(MAX_CONCURRENT_TRANSFORMS);
1861
1862        let request = HttpRequest {
1863            method: "POST".to_string(),
1864            target: "/transform".to_string(),
1865            version: "HTTP/1.1".to_string(),
1866            headers: Vec::new(),
1867            body: Vec::new(),
1868        };
1869
1870        let png_bytes = {
1871            let mut buf = Vec::new();
1872            let encoder = image::codecs::png::PngEncoder::new(&mut buf);
1873            encoder
1874                .write_image(&[255, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8)
1875                .unwrap();
1876            buf
1877        };
1878
1879        let config = ServerConfig::new(std::env::temp_dir(), None);
1880        let response = transform_source_bytes(
1881            png_bytes,
1882            TransformOptions::default(),
1883            None,
1884            &request,
1885            ImageResponsePolicy::PrivateTransform,
1886            &config,
1887        );
1888
1889        assert!(response.status.contains("503"));
1890
1891        assert_eq!(
1892            TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed),
1893            MAX_CONCURRENT_TRANSFORMS
1894        );
1895    }
1896
1897    #[test]
1898    fn compute_cache_key_is_deterministic() {
1899        let opts = TransformOptions {
1900            width: Some(300),
1901            height: Some(200),
1902            format: Some(MediaType::Webp),
1903            ..TransformOptions::default()
1904        };
1905        let key1 = super::cache::compute_cache_key("source-abc", &opts, None);
1906        let key2 = super::cache::compute_cache_key("source-abc", &opts, None);
1907        assert_eq!(key1, key2);
1908        assert_eq!(key1.len(), 64);
1909    }
1910
1911    #[test]
1912    fn compute_cache_key_differs_for_different_options() {
1913        let opts1 = TransformOptions {
1914            width: Some(300),
1915            format: Some(MediaType::Webp),
1916            ..TransformOptions::default()
1917        };
1918        let opts2 = TransformOptions {
1919            width: Some(400),
1920            format: Some(MediaType::Webp),
1921            ..TransformOptions::default()
1922        };
1923        let key1 = super::cache::compute_cache_key("same-source", &opts1, None);
1924        let key2 = super::cache::compute_cache_key("same-source", &opts2, None);
1925        assert_ne!(key1, key2);
1926    }
1927
1928    #[test]
1929    fn compute_cache_key_includes_accept_when_present() {
1930        let opts = TransformOptions::default();
1931        let key_no_accept = super::cache::compute_cache_key("src", &opts, None);
1932        let key_with_accept = super::cache::compute_cache_key("src", &opts, Some("image/webp"));
1933        assert_ne!(key_no_accept, key_with_accept);
1934    }
1935
1936    #[test]
1937    fn transform_cache_put_and_get_round_trips() {
1938        let dir = tempfile::tempdir().expect("create tempdir");
1939        let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
1940
1941        cache.put(
1942            "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
1943            MediaType::Png,
1944            b"png-data",
1945        );
1946        let result = cache.get("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
1947
1948        match result {
1949            super::cache::CacheLookup::Hit {
1950                media_type, body, ..
1951            } => {
1952                assert_eq!(media_type, MediaType::Png);
1953                assert_eq!(body, b"png-data");
1954            }
1955            super::cache::CacheLookup::Miss => panic!("expected cache hit"),
1956        }
1957    }
1958
1959    #[test]
1960    fn transform_cache_miss_for_unknown_key() {
1961        let dir = tempfile::tempdir().expect("create tempdir");
1962        let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
1963
1964        let result = cache.get("0000001234567890abcdef1234567890abcdef1234567890abcdef1234567890");
1965        assert!(matches!(result, super::cache::CacheLookup::Miss));
1966    }
1967
1968    #[test]
1969    fn transform_cache_uses_sharded_layout() {
1970        let dir = tempfile::tempdir().expect("create tempdir");
1971        let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
1972
1973        let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
1974        cache.put(key, MediaType::Jpeg, b"jpeg-data");
1975
1976        let expected = dir.path().join("ab").join("cd").join("ef").join(key);
1977        assert!(
1978            expected.exists(),
1979            "sharded file should exist at {expected:?}"
1980        );
1981    }
1982
1983    #[test]
1984    fn transform_cache_expired_entry_is_miss() {
1985        let dir = tempfile::tempdir().expect("create tempdir");
1986        let mut cache = super::cache::TransformCache::new(dir.path().to_path_buf());
1987        cache.ttl = Duration::from_secs(0);
1988
1989        let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
1990        cache.put(key, MediaType::Png, b"data");
1991
1992        std::thread::sleep(Duration::from_millis(10));
1993
1994        let result = cache.get(key);
1995        assert!(matches!(result, super::cache::CacheLookup::Miss));
1996    }
1997
1998    #[test]
1999    fn transform_cache_handles_corrupted_entry_as_miss() {
2000        let dir = tempfile::tempdir().expect("create tempdir");
2001        let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2002
2003        let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2004        let path = cache.entry_path(key);
2005        fs::create_dir_all(path.parent().unwrap()).unwrap();
2006        fs::write(&path, b"corrupted-data-without-header").unwrap();
2007
2008        let result = cache.get(key);
2009        assert!(matches!(result, super::cache::CacheLookup::Miss));
2010    }
2011
2012    #[test]
2013    fn cache_status_header_reflects_hit() {
2014        let headers = build_image_response_headers(
2015            MediaType::Png,
2016            &build_image_etag(b"data"),
2017            ImageResponsePolicy::PublicGet,
2018            false,
2019            CacheHitStatus::Hit,
2020            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2021            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2022        );
2023        assert!(headers.contains(&("Cache-Status".to_string(), "\"truss\"; hit".to_string())));
2024    }
2025
2026    #[test]
2027    fn cache_status_header_reflects_miss() {
2028        let headers = build_image_response_headers(
2029            MediaType::Png,
2030            &build_image_etag(b"data"),
2031            ImageResponsePolicy::PublicGet,
2032            false,
2033            CacheHitStatus::Miss,
2034            DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2035            DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2036        );
2037        assert!(headers.contains(&(
2038            "Cache-Status".to_string(),
2039            "\"truss\"; fwd=miss".to_string()
2040        )));
2041    }
2042
2043    #[test]
2044    fn origin_cache_put_and_get_round_trips() {
2045        let dir = tempfile::tempdir().expect("create tempdir");
2046        let cache = super::cache::OriginCache::new(dir.path());
2047
2048        cache.put("https://example.com/image.png", b"raw-source-bytes");
2049        let result = cache.get("https://example.com/image.png");
2050
2051        assert_eq!(result.as_deref(), Some(b"raw-source-bytes".as_ref()));
2052    }
2053
2054    #[test]
2055    fn origin_cache_miss_for_unknown_url() {
2056        let dir = tempfile::tempdir().expect("create tempdir");
2057        let cache = super::cache::OriginCache::new(dir.path());
2058
2059        assert!(
2060            cache
2061                .get("https://unknown.example.com/missing.png")
2062                .is_none()
2063        );
2064    }
2065
2066    #[test]
2067    fn origin_cache_expired_entry_is_none() {
2068        let dir = tempfile::tempdir().expect("create tempdir");
2069        let mut cache = super::cache::OriginCache::new(dir.path());
2070        cache.ttl = Duration::from_secs(0);
2071
2072        cache.put("https://example.com/img.png", b"data");
2073        std::thread::sleep(Duration::from_millis(10));
2074
2075        assert!(cache.get("https://example.com/img.png").is_none());
2076    }
2077
2078    #[test]
2079    fn origin_cache_uses_origin_subdirectory() {
2080        let dir = tempfile::tempdir().expect("create tempdir");
2081        let cache = super::cache::OriginCache::new(dir.path());
2082
2083        cache.put("https://example.com/test.png", b"bytes");
2084
2085        let origin_dir = dir.path().join("origin");
2086        assert!(origin_dir.exists(), "origin subdirectory should exist");
2087    }
2088
2089    #[test]
2090    fn sign_public_url_builds_a_signed_path_url() {
2091        let url = sign_public_url(
2092            "https://cdn.example.com",
2093            SignedUrlSource::Path {
2094                path: "/image.png".to_string(),
2095                version: Some("v1".to_string()),
2096            },
2097            &crate::TransformOptions {
2098                format: Some(MediaType::Jpeg),
2099                width: Some(320),
2100                ..crate::TransformOptions::default()
2101            },
2102            "public-dev",
2103            "secret-value",
2104            4_102_444_800,
2105        )
2106        .expect("sign public URL");
2107
2108        assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
2109        assert!(url.contains("path=%2Fimage.png"));
2110        assert!(url.contains("version=v1"));
2111        assert!(url.contains("width=320"));
2112        assert!(url.contains("format=jpeg"));
2113        assert!(url.contains("keyId=public-dev"));
2114        assert!(url.contains("expires=4102444800"));
2115        assert!(url.contains("signature="));
2116    }
2117
2118    #[test]
2119    fn parse_public_get_request_rejects_unknown_query_parameters() {
2120        let query = BTreeMap::from([
2121            ("path".to_string(), "/image.png".to_string()),
2122            ("keyId".to_string(), "public-dev".to_string()),
2123            ("expires".to_string(), "4102444800".to_string()),
2124            ("signature".to_string(), "deadbeef".to_string()),
2125            ("unexpected".to_string(), "value".to_string()),
2126        ]);
2127
2128        let response = parse_public_get_request(&query, PublicSourceKind::Path)
2129            .expect_err("unknown query should fail");
2130
2131        assert_eq!(response.status, "400 Bad Request");
2132        assert!(response_body(&response).contains("is not supported"));
2133    }
2134
2135    #[test]
2136    fn prepare_remote_fetch_target_pins_the_validated_netloc() {
2137        let target = prepare_remote_fetch_target(
2138            "http://1.1.1.1/image.png",
2139            &ServerConfig::new(temp_dir("pin"), Some("secret".to_string())),
2140        )
2141        .expect("prepare remote target");
2142
2143        assert_eq!(target.netloc, "1.1.1.1:80");
2144        assert_eq!(target.addrs, vec![SocketAddr::from(([1, 1, 1, 1], 80))]);
2145    }
2146
2147    #[test]
2148    fn pinned_resolver_rejects_unexpected_netlocs() {
2149        use ureq::unversioned::resolver::Resolver;
2150
2151        let resolver = PinnedResolver {
2152            expected_netloc: "example.com:443".to_string(),
2153            addrs: vec![SocketAddr::from(([93, 184, 216, 34], 443))],
2154        };
2155
2156        let config = ureq::config::Config::builder().build();
2157        let timeout = ureq::unversioned::transport::NextTimeout {
2158            after: ureq::unversioned::transport::time::Duration::Exact(
2159                std::time::Duration::from_secs(30),
2160            ),
2161            reason: ureq::Timeout::Resolve,
2162        };
2163
2164        let uri: ureq::http::Uri = "https://example.com/path".parse().unwrap();
2165        let result = resolver
2166            .resolve(&uri, &config, timeout)
2167            .expect("resolve expected netloc");
2168        assert_eq!(&result[..], &[SocketAddr::from(([93, 184, 216, 34], 443))]);
2169
2170        let bad_uri: ureq::http::Uri = "https://proxy.example:8080/path".parse().unwrap();
2171        let timeout2 = ureq::unversioned::transport::NextTimeout {
2172            after: ureq::unversioned::transport::time::Duration::Exact(
2173                std::time::Duration::from_secs(30),
2174            ),
2175            reason: ureq::Timeout::Resolve,
2176        };
2177        let error = resolver
2178            .resolve(&bad_uri, &config, timeout2)
2179            .expect_err("unexpected netloc should fail");
2180        assert!(matches!(error, ureq::Error::HostNotFound));
2181    }
2182
2183    #[test]
2184    fn health_live_returns_status_service_version() {
2185        let request = HttpRequest {
2186            method: "GET".to_string(),
2187            target: "/health/live".to_string(),
2188            version: "HTTP/1.1".to_string(),
2189            headers: Vec::new(),
2190            body: Vec::new(),
2191        };
2192
2193        let response = route_request(request, &ServerConfig::new(temp_dir("live"), None));
2194
2195        assert_eq!(response.status, "200 OK");
2196        let body: serde_json::Value =
2197            serde_json::from_slice(&response.body).expect("parse live body");
2198        assert_eq!(body["status"], "ok");
2199        assert_eq!(body["service"], "truss");
2200        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2201    }
2202
2203    #[test]
2204    fn health_ready_returns_ok_when_storage_exists() {
2205        let storage = temp_dir("ready-ok");
2206        let request = HttpRequest {
2207            method: "GET".to_string(),
2208            target: "/health/ready".to_string(),
2209            version: "HTTP/1.1".to_string(),
2210            headers: Vec::new(),
2211            body: Vec::new(),
2212        };
2213
2214        let response = route_request(request, &ServerConfig::new(storage, None));
2215
2216        assert_eq!(response.status, "200 OK");
2217        let body: serde_json::Value =
2218            serde_json::from_slice(&response.body).expect("parse ready body");
2219        assert_eq!(body["status"], "ok");
2220        let checks = body["checks"].as_array().expect("checks array");
2221        assert!(
2222            checks
2223                .iter()
2224                .any(|c| c["name"] == "storageRoot" && c["status"] == "ok")
2225        );
2226    }
2227
2228    #[test]
2229    fn health_ready_returns_503_when_storage_missing() {
2230        let request = HttpRequest {
2231            method: "GET".to_string(),
2232            target: "/health/ready".to_string(),
2233            version: "HTTP/1.1".to_string(),
2234            headers: Vec::new(),
2235            body: Vec::new(),
2236        };
2237
2238        let config = ServerConfig::new(PathBuf::from("/nonexistent-truss-test-dir"), None);
2239        let response = route_request(request, &config);
2240
2241        assert_eq!(response.status, "503 Service Unavailable");
2242        let body: serde_json::Value =
2243            serde_json::from_slice(&response.body).expect("parse ready fail body");
2244        assert_eq!(body["status"], "fail");
2245        let checks = body["checks"].as_array().expect("checks array");
2246        assert!(
2247            checks
2248                .iter()
2249                .any(|c| c["name"] == "storageRoot" && c["status"] == "fail")
2250        );
2251    }
2252
2253    #[test]
2254    fn health_ready_returns_503_when_cache_root_missing() {
2255        let storage = temp_dir("ready-cache-fail");
2256        let mut config = ServerConfig::new(storage, None);
2257        config.cache_root = Some(PathBuf::from("/nonexistent-truss-cache-dir"));
2258
2259        let request = HttpRequest {
2260            method: "GET".to_string(),
2261            target: "/health/ready".to_string(),
2262            version: "HTTP/1.1".to_string(),
2263            headers: Vec::new(),
2264            body: Vec::new(),
2265        };
2266
2267        let response = route_request(request, &config);
2268
2269        assert_eq!(response.status, "503 Service Unavailable");
2270        let body: serde_json::Value =
2271            serde_json::from_slice(&response.body).expect("parse ready cache body");
2272        assert_eq!(body["status"], "fail");
2273        let checks = body["checks"].as_array().expect("checks array");
2274        assert!(
2275            checks
2276                .iter()
2277                .any(|c| c["name"] == "cacheRoot" && c["status"] == "fail")
2278        );
2279    }
2280
2281    #[test]
2282    fn health_returns_comprehensive_diagnostic() {
2283        let storage = temp_dir("health-diag");
2284        let request = HttpRequest {
2285            method: "GET".to_string(),
2286            target: "/health".to_string(),
2287            version: "HTTP/1.1".to_string(),
2288            headers: Vec::new(),
2289            body: Vec::new(),
2290        };
2291
2292        let response = route_request(request, &ServerConfig::new(storage, None));
2293
2294        assert_eq!(response.status, "200 OK");
2295        let body: serde_json::Value =
2296            serde_json::from_slice(&response.body).expect("parse health body");
2297        assert_eq!(body["status"], "ok");
2298        assert_eq!(body["service"], "truss");
2299        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2300        assert!(body["uptimeSeconds"].is_u64());
2301        assert!(body["checks"].is_array());
2302    }
2303
2304    #[test]
2305    fn unknown_path_returns_not_found() {
2306        let request = HttpRequest {
2307            method: "GET".to_string(),
2308            target: "/unknown".to_string(),
2309            version: "HTTP/1.1".to_string(),
2310            headers: Vec::new(),
2311            body: Vec::new(),
2312        };
2313
2314        let response = route_request(request, &ServerConfig::new(temp_dir("not-found"), None));
2315
2316        assert_eq!(response.status, "404 Not Found");
2317        assert_eq!(
2318            response.content_type.as_deref(),
2319            Some("application/problem+json")
2320        );
2321        let body = response_body(&response);
2322        assert!(body.contains("\"type\":\"about:blank\""));
2323        assert!(body.contains("\"title\":\"Not Found\""));
2324        assert!(body.contains("\"status\":404"));
2325        assert!(body.contains("not found"));
2326    }
2327
2328    #[test]
2329    fn transform_endpoint_requires_authentication() {
2330        let storage_root = temp_dir("auth");
2331        write_png(&storage_root.join("image.png"));
2332        let mut request = transform_request("/image.png");
2333        request.headers.retain(|(name, _)| name != "authorization");
2334
2335        let response = route_request(
2336            request,
2337            &ServerConfig::new(storage_root, Some("secret".to_string())),
2338        );
2339
2340        assert_eq!(response.status, "401 Unauthorized");
2341        assert!(response_body(&response).contains("authorization required"));
2342    }
2343
2344    #[test]
2345    fn transform_endpoint_returns_service_unavailable_without_configured_token() {
2346        let storage_root = temp_dir("token");
2347        write_png(&storage_root.join("image.png"));
2348
2349        let response = route_request(
2350            transform_request("/image.png"),
2351            &ServerConfig::new(storage_root, None),
2352        );
2353
2354        assert_eq!(response.status, "503 Service Unavailable");
2355        assert!(response_body(&response).contains("bearer token is not configured"));
2356    }
2357
2358    #[test]
2359    fn transform_endpoint_transforms_a_path_source() {
2360        let storage_root = temp_dir("transform");
2361        write_png(&storage_root.join("image.png"));
2362
2363        let response = route_request(
2364            transform_request("/image.png"),
2365            &ServerConfig::new(storage_root, Some("secret".to_string())),
2366        );
2367
2368        assert_eq!(response.status, "200 OK");
2369        assert_eq!(response.content_type.as_deref(), Some("image/jpeg"));
2370
2371        let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2372        assert_eq!(artifact.media_type, MediaType::Jpeg);
2373        assert_eq!(artifact.metadata.width, Some(4));
2374        assert_eq!(artifact.metadata.height, Some(3));
2375    }
2376
2377    #[test]
2378    fn transform_endpoint_rejects_private_url_sources_by_default() {
2379        let response = route_request(
2380            transform_url_request("http://127.0.0.1:8080/image.png"),
2381            &ServerConfig::new(temp_dir("url-blocked"), Some("secret".to_string())),
2382        );
2383
2384        assert_eq!(response.status, "403 Forbidden");
2385        assert!(response_body(&response).contains("port is not allowed"));
2386    }
2387
2388    #[test]
2389    fn transform_endpoint_transforms_a_url_source_when_insecure_allowance_is_enabled() {
2390        let (url, handle) = spawn_http_server(vec![(
2391            "200 OK".to_string(),
2392            vec![("Content-Type".to_string(), "image/png".to_string())],
2393            png_bytes(),
2394        )]);
2395
2396        let response = route_request(
2397            transform_url_request(&url),
2398            &ServerConfig::new(temp_dir("url"), Some("secret".to_string()))
2399                .with_insecure_url_sources(true),
2400        );
2401
2402        handle.join().expect("join fixture server");
2403
2404        assert_eq!(response.status, "200 OK");
2405        assert_eq!(response.content_type.as_deref(), Some("image/jpeg"));
2406
2407        let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2408        assert_eq!(artifact.media_type, MediaType::Jpeg);
2409    }
2410
2411    #[test]
2412    fn transform_endpoint_follows_remote_redirects() {
2413        let (redirect_url, handle) = spawn_http_server(vec![
2414            (
2415                "302 Found".to_string(),
2416                vec![("Location".to_string(), "/final-image".to_string())],
2417                Vec::new(),
2418            ),
2419            (
2420                "200 OK".to_string(),
2421                vec![("Content-Type".to_string(), "image/png".to_string())],
2422                png_bytes(),
2423            ),
2424        ]);
2425
2426        let response = route_request(
2427            transform_url_request(&redirect_url),
2428            &ServerConfig::new(temp_dir("redirect"), Some("secret".to_string()))
2429                .with_insecure_url_sources(true),
2430        );
2431
2432        handle.join().expect("join fixture server");
2433
2434        assert_eq!(response.status, "200 OK");
2435        let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2436        assert_eq!(artifact.media_type, MediaType::Jpeg);
2437    }
2438
2439    #[test]
2440    fn upload_endpoint_transforms_uploaded_file() {
2441        let response = route_request(
2442            upload_request(&png_bytes(), Some(r#"{"format":"jpeg"}"#)),
2443            &ServerConfig::new(temp_dir("upload"), Some("secret".to_string())),
2444        );
2445
2446        assert_eq!(response.status, "200 OK");
2447        assert_eq!(response.content_type.as_deref(), Some("image/jpeg"));
2448
2449        let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2450        assert_eq!(artifact.media_type, MediaType::Jpeg);
2451    }
2452
2453    #[test]
2454    fn upload_endpoint_requires_a_file_field() {
2455        let boundary = "truss-test-boundary";
2456        let request = HttpRequest {
2457            method: "POST".to_string(),
2458            target: "/images".to_string(),
2459            version: "HTTP/1.1".to_string(),
2460            headers: vec![
2461                ("authorization".to_string(), "Bearer secret".to_string()),
2462                (
2463                    "content-type".to_string(),
2464                    format!("multipart/form-data; boundary={boundary}"),
2465                ),
2466            ],
2467            body: format!(
2468                "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{{\"format\":\"jpeg\"}}\r\n--{boundary}--\r\n"
2469            )
2470            .into_bytes(),
2471        };
2472
2473        let response = route_request(
2474            request,
2475            &ServerConfig::new(temp_dir("upload-missing-file"), Some("secret".to_string())),
2476        );
2477
2478        assert_eq!(response.status, "400 Bad Request");
2479        assert!(response_body(&response).contains("requires a `file` field"));
2480    }
2481
2482    #[test]
2483    fn upload_endpoint_rejects_non_multipart_content_type() {
2484        let request = HttpRequest {
2485            method: "POST".to_string(),
2486            target: "/images".to_string(),
2487            version: "HTTP/1.1".to_string(),
2488            headers: vec![
2489                ("authorization".to_string(), "Bearer secret".to_string()),
2490                ("content-type".to_string(), "application/json".to_string()),
2491            ],
2492            body: br#"{"file":"not-really-json"}"#.to_vec(),
2493        };
2494
2495        let response = route_request(
2496            request,
2497            &ServerConfig::new(temp_dir("upload-content-type"), Some("secret".to_string())),
2498        );
2499
2500        assert_eq!(response.status, "415 Unsupported Media Type");
2501        assert!(response_body(&response).contains("multipart/form-data"));
2502    }
2503
2504    #[test]
2505    fn parse_upload_request_extracts_file_and_options() {
2506        let request = upload_request(&png_bytes(), Some(r#"{"width":8,"format":"jpeg"}"#));
2507        let boundary =
2508            super::multipart::parse_multipart_boundary(&request).expect("parse boundary");
2509        let (file_bytes, options) =
2510            super::multipart::parse_upload_request(&request.body, &boundary)
2511                .expect("parse upload body");
2512
2513        assert_eq!(file_bytes, png_bytes());
2514        assert_eq!(options.width, Some(8));
2515        assert_eq!(options.format, Some(MediaType::Jpeg));
2516    }
2517
2518    #[test]
2519    fn metrics_endpoint_requires_authentication() {
2520        let response = route_request(
2521            metrics_request(false),
2522            &ServerConfig::new(temp_dir("metrics-auth"), Some("secret".to_string())),
2523        );
2524
2525        assert_eq!(response.status, "401 Unauthorized");
2526        assert!(response_body(&response).contains("authorization required"));
2527    }
2528
2529    #[test]
2530    fn metrics_endpoint_returns_prometheus_text() {
2531        super::metrics::record_http_metrics(super::metrics::RouteMetric::Health, "200 OK");
2532        let response = route_request(
2533            metrics_request(true),
2534            &ServerConfig::new(temp_dir("metrics"), Some("secret".to_string())),
2535        );
2536        let body = response_body(&response);
2537
2538        assert_eq!(response.status, "200 OK");
2539        assert_eq!(
2540            response.content_type.as_deref(),
2541            Some("text/plain; version=0.0.4; charset=utf-8")
2542        );
2543        assert!(body.contains("truss_http_requests_total"));
2544        assert!(body.contains("truss_http_requests_by_route_total{route=\"/health\"}"));
2545        assert!(body.contains("truss_http_responses_total{status=\"200\"}"));
2546    }
2547
2548    #[test]
2549    fn transform_endpoint_rejects_unsupported_remote_content_encoding() {
2550        let (url, handle) = spawn_http_server(vec![(
2551            "200 OK".to_string(),
2552            vec![
2553                ("Content-Type".to_string(), "image/png".to_string()),
2554                ("Content-Encoding".to_string(), "compress".to_string()),
2555            ],
2556            png_bytes(),
2557        )]);
2558
2559        let response = route_request(
2560            transform_url_request(&url),
2561            &ServerConfig::new(temp_dir("encoding"), Some("secret".to_string()))
2562                .with_insecure_url_sources(true),
2563        );
2564
2565        handle.join().expect("join fixture server");
2566
2567        assert_eq!(response.status, "502 Bad Gateway");
2568        assert!(response_body(&response).contains("unsupported content-encoding"));
2569    }
2570
2571    #[test]
2572    fn resolve_storage_path_rejects_parent_segments() {
2573        let storage_root = temp_dir("resolve");
2574        let response = resolve_storage_path(&storage_root, "../escape.png")
2575            .expect_err("parent segments should be rejected");
2576
2577        assert_eq!(response.status, "400 Bad Request");
2578        assert!(response_body(&response).contains("must not contain root"));
2579    }
2580
2581    #[test]
2582    fn read_request_parses_headers_and_body() {
2583        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{}";
2584        let mut cursor = Cursor::new(request_bytes);
2585        let request = read_request(&mut cursor).expect("parse request");
2586
2587        assert_eq!(request.method, "POST");
2588        assert_eq!(request.target, "/images:transform");
2589        assert_eq!(request.version, "HTTP/1.1");
2590        assert_eq!(request.header("host"), Some("localhost"));
2591        assert_eq!(request.body, b"{}");
2592    }
2593
2594    #[test]
2595    fn read_request_rejects_duplicate_content_length() {
2596        let request_bytes =
2597            b"POST /images:transform HTTP/1.1\r\nContent-Length: 2\r\nContent-Length: 2\r\n\r\n{}";
2598        let mut cursor = Cursor::new(request_bytes);
2599        let response = read_request(&mut cursor).expect_err("duplicate headers should fail");
2600
2601        assert_eq!(response.status, "400 Bad Request");
2602        assert!(response_body(&response).contains("content-length"));
2603    }
2604
2605    #[test]
2606    fn serve_once_handles_a_tcp_request() {
2607        let storage_root = temp_dir("serve-once");
2608        let config = ServerConfig::new(storage_root, None);
2609        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
2610        let addr = listener.local_addr().expect("read local addr");
2611
2612        let server = thread::spawn(move || serve_once_with_config(listener, &config));
2613
2614        let mut stream = TcpStream::connect(addr).expect("connect to test server");
2615        stream
2616            .write_all(b"GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
2617            .expect("write request");
2618
2619        let mut response = String::new();
2620        stream.read_to_string(&mut response).expect("read response");
2621
2622        server
2623            .join()
2624            .expect("join test server thread")
2625            .expect("serve one request");
2626
2627        assert!(response.starts_with("HTTP/1.1 200 OK"));
2628        assert!(response.contains("Content-Type: application/json"));
2629        assert!(response.contains("\"status\":\"ok\""));
2630        assert!(response.contains("\"service\":\"truss\""));
2631        assert!(response.contains("\"version\":"));
2632    }
2633
2634    #[test]
2635    fn helper_error_responses_use_rfc7807_problem_details() {
2636        let response = auth_required_response("authorization required");
2637        let bad_request = bad_request_response("bad input");
2638
2639        assert_eq!(
2640            response.content_type.as_deref(),
2641            Some("application/problem+json"),
2642            "error responses must use application/problem+json"
2643        );
2644        assert_eq!(
2645            bad_request.content_type.as_deref(),
2646            Some("application/problem+json"),
2647        );
2648
2649        let auth_body = response_body(&response);
2650        assert!(auth_body.contains("authorization required"));
2651        assert!(auth_body.contains("\"type\":\"about:blank\""));
2652        assert!(auth_body.contains("\"title\":\"Unauthorized\""));
2653        assert!(auth_body.contains("\"status\":401"));
2654
2655        let bad_body = response_body(&bad_request);
2656        assert!(bad_body.contains("bad input"));
2657        assert!(bad_body.contains("\"type\":\"about:blank\""));
2658        assert!(bad_body.contains("\"title\":\"Bad Request\""));
2659        assert!(bad_body.contains("\"status\":400"));
2660    }
2661
2662    #[test]
2663    fn parse_headers_rejects_duplicate_host() {
2664        let lines = "Host: example.com\r\nHost: evil.com\r\n";
2665        let result = super::http_parse::parse_headers(lines.split("\r\n"));
2666        assert!(result.is_err());
2667    }
2668
2669    #[test]
2670    fn parse_headers_rejects_duplicate_authorization() {
2671        let lines = "Authorization: Bearer a\r\nAuthorization: Bearer b\r\n";
2672        let result = super::http_parse::parse_headers(lines.split("\r\n"));
2673        assert!(result.is_err());
2674    }
2675
2676    #[test]
2677    fn parse_headers_rejects_duplicate_content_type() {
2678        let lines = "Content-Type: application/json\r\nContent-Type: text/plain\r\n";
2679        let result = super::http_parse::parse_headers(lines.split("\r\n"));
2680        assert!(result.is_err());
2681    }
2682
2683    #[test]
2684    fn parse_headers_rejects_duplicate_transfer_encoding() {
2685        let lines = "Transfer-Encoding: chunked\r\nTransfer-Encoding: gzip\r\n";
2686        let result = super::http_parse::parse_headers(lines.split("\r\n"));
2687        assert!(result.is_err());
2688    }
2689
2690    #[test]
2691    fn parse_headers_rejects_single_transfer_encoding() {
2692        let lines = "Host: example.com\r\nTransfer-Encoding: chunked\r\n";
2693        let result = super::http_parse::parse_headers(lines.split("\r\n"));
2694        let err = result.unwrap_err();
2695        assert!(
2696            err.status.starts_with("501"),
2697            "expected 501 status, got: {}",
2698            err.status
2699        );
2700        assert!(
2701            String::from_utf8_lossy(&err.body).contains("Transfer-Encoding"),
2702            "error response should mention Transfer-Encoding"
2703        );
2704    }
2705
2706    #[test]
2707    fn parse_headers_rejects_transfer_encoding_identity() {
2708        let lines = "Transfer-Encoding: identity\r\n";
2709        let result = super::http_parse::parse_headers(lines.split("\r\n"));
2710        assert!(result.is_err());
2711    }
2712
2713    #[test]
2714    fn parse_headers_allows_single_instances_of_singleton_headers() {
2715        let lines =
2716            "Host: example.com\r\nAuthorization: Bearer tok\r\nContent-Type: application/json\r\n";
2717        let result = super::http_parse::parse_headers(lines.split("\r\n"));
2718        assert!(result.is_ok());
2719        assert_eq!(result.unwrap().len(), 3);
2720    }
2721
2722    #[test]
2723    fn max_body_for_multipart_uses_upload_limit() {
2724        let headers = vec![(
2725            "content-type".to_string(),
2726            "multipart/form-data; boundary=abc".to_string(),
2727        )];
2728        assert_eq!(
2729            super::http_parse::max_body_for_headers(&headers),
2730            super::http_parse::MAX_UPLOAD_BODY_BYTES
2731        );
2732    }
2733
2734    #[test]
2735    fn max_body_for_json_uses_default_limit() {
2736        let headers = vec![("content-type".to_string(), "application/json".to_string())];
2737        assert_eq!(
2738            super::http_parse::max_body_for_headers(&headers),
2739            super::http_parse::MAX_REQUEST_BODY_BYTES
2740        );
2741    }
2742
2743    #[test]
2744    fn max_body_for_no_content_type_uses_default_limit() {
2745        let headers: Vec<(String, String)> = vec![];
2746        assert_eq!(
2747            super::http_parse::max_body_for_headers(&headers),
2748            super::http_parse::MAX_REQUEST_BODY_BYTES
2749        );
2750    }
2751
2752    fn make_test_config() -> ServerConfig {
2753        ServerConfig::new(std::env::temp_dir(), None)
2754    }
2755
2756    #[test]
2757    fn versioned_source_hash_returns_none_without_version() {
2758        let source = TransformSourcePayload::Path {
2759            path: "/photos/hero.jpg".to_string(),
2760            version: None,
2761        };
2762        assert!(source.versioned_source_hash(&make_test_config()).is_none());
2763    }
2764
2765    #[test]
2766    fn versioned_source_hash_is_deterministic() {
2767        let cfg = make_test_config();
2768        let source = TransformSourcePayload::Path {
2769            path: "/photos/hero.jpg".to_string(),
2770            version: Some("v1".to_string()),
2771        };
2772        let hash1 = source.versioned_source_hash(&cfg).unwrap();
2773        let hash2 = source.versioned_source_hash(&cfg).unwrap();
2774        assert_eq!(hash1, hash2);
2775        assert_eq!(hash1.len(), 64);
2776    }
2777
2778    #[test]
2779    fn versioned_source_hash_differs_by_version() {
2780        let cfg = make_test_config();
2781        let v1 = TransformSourcePayload::Path {
2782            path: "/photos/hero.jpg".to_string(),
2783            version: Some("v1".to_string()),
2784        };
2785        let v2 = TransformSourcePayload::Path {
2786            path: "/photos/hero.jpg".to_string(),
2787            version: Some("v2".to_string()),
2788        };
2789        assert_ne!(
2790            v1.versioned_source_hash(&cfg).unwrap(),
2791            v2.versioned_source_hash(&cfg).unwrap()
2792        );
2793    }
2794
2795    #[test]
2796    fn versioned_source_hash_differs_by_kind() {
2797        let cfg = make_test_config();
2798        let path = TransformSourcePayload::Path {
2799            path: "example.com/image.jpg".to_string(),
2800            version: Some("v1".to_string()),
2801        };
2802        let url = TransformSourcePayload::Url {
2803            url: "example.com/image.jpg".to_string(),
2804            version: Some("v1".to_string()),
2805        };
2806        assert_ne!(
2807            path.versioned_source_hash(&cfg).unwrap(),
2808            url.versioned_source_hash(&cfg).unwrap()
2809        );
2810    }
2811
2812    #[test]
2813    fn versioned_source_hash_differs_by_storage_root() {
2814        let cfg1 = ServerConfig::new(PathBuf::from("/data/images"), None);
2815        let cfg2 = ServerConfig::new(PathBuf::from("/other/images"), None);
2816        let source = TransformSourcePayload::Path {
2817            path: "/photos/hero.jpg".to_string(),
2818            version: Some("v1".to_string()),
2819        };
2820        assert_ne!(
2821            source.versioned_source_hash(&cfg1).unwrap(),
2822            source.versioned_source_hash(&cfg2).unwrap()
2823        );
2824    }
2825
2826    #[test]
2827    fn versioned_source_hash_differs_by_insecure_flag() {
2828        let mut cfg1 = make_test_config();
2829        cfg1.allow_insecure_url_sources = false;
2830        let mut cfg2 = make_test_config();
2831        cfg2.allow_insecure_url_sources = true;
2832        let source = TransformSourcePayload::Url {
2833            url: "http://example.com/img.jpg".to_string(),
2834            version: Some("v1".to_string()),
2835        };
2836        assert_ne!(
2837            source.versioned_source_hash(&cfg1).unwrap(),
2838            source.versioned_source_hash(&cfg2).unwrap()
2839        );
2840    }
2841
2842    #[test]
2843    fn read_request_rejects_json_body_over_1mib() {
2844        let body = vec![b'x'; super::http_parse::MAX_REQUEST_BODY_BYTES + 1];
2845        let content_length = body.len();
2846        let raw = format!(
2847            "POST /images:transform HTTP/1.1\r\n\
2848             Content-Type: application/json\r\n\
2849             Content-Length: {content_length}\r\n\r\n"
2850        );
2851        let mut data = raw.into_bytes();
2852        data.extend_from_slice(&body);
2853        let result = read_request(&mut data.as_slice());
2854        assert!(result.is_err());
2855    }
2856
2857    #[test]
2858    fn read_request_accepts_multipart_body_over_1mib() {
2859        let payload_size = super::http_parse::MAX_REQUEST_BODY_BYTES + 100;
2860        let body_content = vec![b'A'; payload_size];
2861        let boundary = "test-boundary-123";
2862        let mut body = Vec::new();
2863        body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"big.jpg\"\r\n\r\n").as_bytes());
2864        body.extend_from_slice(&body_content);
2865        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
2866        let content_length = body.len();
2867        let raw = format!(
2868            "POST /images HTTP/1.1\r\n\
2869             Content-Type: multipart/form-data; boundary={boundary}\r\n\
2870             Content-Length: {content_length}\r\n\r\n"
2871        );
2872        let mut data = raw.into_bytes();
2873        data.extend_from_slice(&body);
2874        let result = read_request(&mut data.as_slice());
2875        assert!(
2876            result.is_ok(),
2877            "multipart upload over 1 MiB should be accepted"
2878        );
2879    }
2880
2881    #[test]
2882    fn multipart_boundary_in_payload_does_not_split_part() {
2883        let boundary = "abc123";
2884        let fake_boundary_in_payload = format!("\r\n--{boundary}NOTREAL");
2885        let part_body = format!("before{fake_boundary_in_payload}after");
2886        let body = format!(
2887            "--{boundary}\r\n\
2888             Content-Disposition: form-data; name=\"file\"\r\n\
2889             Content-Type: application/octet-stream\r\n\r\n\
2890             {part_body}\r\n\
2891             --{boundary}--\r\n"
2892        );
2893
2894        let parts = parse_multipart_form_data(body.as_bytes(), boundary)
2895            .expect("should parse despite boundary-like string in payload");
2896        assert_eq!(parts.len(), 1, "should have exactly one part");
2897
2898        let part_data = &body.as_bytes()[parts[0].body_range.clone()];
2899        let part_text = std::str::from_utf8(part_data).unwrap();
2900        assert!(
2901            part_text.contains("NOTREAL"),
2902            "part body should contain the full fake boundary string"
2903        );
2904    }
2905
2906    #[test]
2907    fn multipart_normal_two_parts_still_works() {
2908        let boundary = "testboundary";
2909        let body = format!(
2910            "--{boundary}\r\n\
2911             Content-Disposition: form-data; name=\"field1\"\r\n\r\n\
2912             value1\r\n\
2913             --{boundary}\r\n\
2914             Content-Disposition: form-data; name=\"field2\"\r\n\r\n\
2915             value2\r\n\
2916             --{boundary}--\r\n"
2917        );
2918
2919        let parts = parse_multipart_form_data(body.as_bytes(), boundary)
2920            .expect("should parse two normal parts");
2921        assert_eq!(parts.len(), 2);
2922        assert_eq!(parts[0].name, "field1");
2923        assert_eq!(parts[1].name, "field2");
2924    }
2925}