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