Skip to main content

truss/adapters/
server.rs

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