Skip to main content

truss/adapters/server/
handler.rs

1/// Request handler implementations (transform, health, metrics, public, upload).
2use std::collections::BTreeMap;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
5use std::time::{Duration, Instant};
6
7use serde::Deserialize;
8use serde_json::json;
9use sha2::{Digest, Sha256};
10use subtle::ConstantTimeEq;
11
12#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
13use super::config::StorageBackend;
14use super::config::{ServerConfig, StorageBackendLabel};
15
16use super::auth::{
17    authorize_request, authorize_signed_request, parse_optional_bool_query,
18    parse_optional_float_query, parse_optional_integer_query, parse_optional_u8_query,
19    parse_query_params, required_query_param, validate_public_query_names,
20};
21use super::cache::{
22    TransformCache, compute_cache_key, compute_watermark_identity, try_versioned_cache_lookup,
23};
24use super::http_parse::{
25    HttpRequest, parse_named, parse_optional_named, request_has_json_content_type,
26};
27use super::metrics::{
28    CACHE_HITS_TOTAL, CACHE_MISSES_TOTAL, record_storage_duration, record_transform_duration,
29    record_transform_error, record_watermark_transform, render_metrics_text,
30    storage_backend_index_from_config, uptime_seconds,
31};
32use super::multipart::{parse_multipart_boundary, parse_upload_request};
33use super::negotiate::{
34    CacheHitStatus, ImageResponsePolicy, PublicSourceKind, build_image_etag,
35    build_image_response_headers, if_none_match_matches, negotiate_output_format,
36};
37use super::remote::{read_remote_watermark_bytes, resolve_source_bytes};
38use super::response::{
39    HttpResponse, NOT_FOUND_BODY, bad_request_response, push_warning_headers,
40    service_unavailable_response, transform_error_response, unsupported_media_type_response,
41    unsupported_output_media_type_response, warning_header_value,
42};
43use super::stderr_write;
44
45use crate::{
46    CropRegion, Fit, MediaType, OptimizeMode, Position, RawArtifact, Rgba8, Rotation,
47    TargetQuality, TransformOptions, TransformRequest, WatermarkInput, sniff_artifact, transform,
48};
49use std::str::FromStr;
50
51#[derive(Clone, Copy)]
52pub(super) struct PublicCacheControl {
53    pub(super) max_age: u32,
54    pub(super) stale_while_revalidate: u32,
55}
56
57#[derive(Clone, Copy)]
58pub(super) struct ImageResponseConfig {
59    pub(super) disable_accept_negotiation: bool,
60    pub(super) public_cache_control: PublicCacheControl,
61    pub(super) transform_deadline: Duration,
62}
63
64/// RAII guard that holds a concurrency slot for an in-flight image transform.
65///
66/// The counter is incremented on successful acquisition and decremented when
67/// the guard is dropped, ensuring the slot is always released even if the
68/// caller returns early or panics.
69pub(super) struct TransformSlot {
70    counter: Arc<AtomicU64>,
71}
72
73impl TransformSlot {
74    pub(super) fn try_acquire(counter: &Arc<AtomicU64>, limit: u64) -> Option<Self> {
75        let prev = counter.fetch_add(1, Ordering::Relaxed);
76        if prev >= limit {
77            counter.fetch_sub(1, Ordering::Relaxed);
78            None
79        } else {
80            Some(Self {
81                counter: Arc::clone(counter),
82            })
83        }
84    }
85}
86
87impl Drop for TransformSlot {
88    fn drop(&mut self) {
89        self.counter.fetch_sub(1, Ordering::Relaxed);
90    }
91}
92
93#[derive(Debug, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub(super) struct TransformImageRequestPayload {
96    pub(super) source: TransformSourcePayload,
97    #[serde(default)]
98    pub(super) options: TransformOptionsPayload,
99    #[serde(default)]
100    pub(super) watermark: Option<WatermarkPayload>,
101}
102
103#[derive(Debug, Deserialize)]
104#[serde(tag = "kind", rename_all = "lowercase")]
105pub(super) enum TransformSourcePayload {
106    Path {
107        path: String,
108        version: Option<String>,
109    },
110    Url {
111        url: String,
112        version: Option<String>,
113    },
114    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
115    Storage {
116        bucket: Option<String>,
117        key: String,
118        version: Option<String>,
119    },
120}
121
122/// Appends one length-prefixed field to a cache identifier.
123///
124/// A separator that can occur inside a field is not a separator. Joining the fields with a
125/// newline made `("a.png\nv1", "x")` and `("a.png", "v1\nx")` the same identifier, so the
126/// second request was served the first one's bytes from a cache that reported a hit. Object
127/// keys containing a newline are legal on a filesystem and on S3, GCS, and Azure Blob alike.
128fn push_identifier_field(id: &mut String, field: &str) {
129    id.push_str(&field.len().to_string());
130    id.push(':');
131    id.push_str(field);
132}
133
134impl TransformSourcePayload {
135    /// Computes a stable source hash from the reference and version, avoiding the
136    /// need to read the full source bytes when a version tag is present. Returns
137    /// `None` when no version is available, in which case the caller must fall back
138    /// to the content-hash approach.
139    /// Computes a stable source hash that includes the instance configuration
140    /// boundaries (storage root, allow_insecure_url_sources) so that cache entries
141    /// cannot be reused across instances with different security settings sharing
142    /// the same cache directory.
143    pub(super) fn versioned_source_hash(&self, config: &ServerConfig) -> Option<String> {
144        let (kind, reference, version): (&str, std::borrow::Cow<'_, str>, Option<&str>) = match self
145        {
146            Self::Path { path, version } => ("path", path.as_str().into(), version.as_deref()),
147            Self::Url { url, version } => ("url", url.as_str().into(), version.as_deref()),
148            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
149            Self::Storage {
150                bucket,
151                key,
152                version,
153            } => {
154                let (scheme, effective_bucket) =
155                    storage_scheme_and_bucket(bucket.as_deref(), config);
156                let effective_bucket = effective_bucket?;
157                // Length-prefixed for the same reason the outer identifier is: a key
158                // containing a slash would otherwise reach across the bucket boundary.
159                let mut reference = String::new();
160                push_identifier_field(&mut reference, scheme);
161                push_identifier_field(&mut reference, effective_bucket);
162                push_identifier_field(&mut reference, key);
163                ("storage", reference.into(), version.as_deref())
164            }
165        };
166        let version = version?;
167        // Every field is length-prefixed, so no value can forge a delimiter and reach into
168        // the next field. Configuration boundaries are included to prevent cross-instance
169        // cache poisoning.
170        let mut id = String::new();
171        push_identifier_field(&mut id, kind);
172        push_identifier_field(&mut id, &reference);
173        push_identifier_field(&mut id, version);
174        push_identifier_field(&mut id, config.storage_root.to_string_lossy().as_ref());
175        push_identifier_field(
176            &mut id,
177            if config.allow_insecure_url_sources {
178                "insecure"
179            } else {
180                "strict"
181            },
182        );
183        #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
184        {
185            push_identifier_field(&mut id, storage_backend_label(config));
186            #[cfg(feature = "s3")]
187            if let Some(ref ctx) = config.s3_context
188                && let Some(ref endpoint) = ctx.endpoint_url
189            {
190                push_identifier_field(&mut id, endpoint);
191            }
192            #[cfg(feature = "gcs")]
193            if let Some(ref ctx) = config.gcs_context
194                && let Some(ref endpoint) = ctx.endpoint_url
195            {
196                push_identifier_field(&mut id, endpoint);
197            }
198            #[cfg(feature = "azure")]
199            if let Some(ref ctx) = config.azure_context {
200                push_identifier_field(&mut id, &ctx.endpoint_url);
201            }
202        }
203        Some(hex::encode(Sha256::digest(id.as_bytes())))
204    }
205
206    /// Returns the storage backend label for metrics based on the source kind,
207    /// rather than the server config default.  Path → Filesystem, Storage →
208    /// whatever the config backend is, Url → None (no storage backend).
209    pub(super) fn metrics_backend_label(
210        &self,
211        _config: &ServerConfig,
212    ) -> Option<StorageBackendLabel> {
213        match self {
214            Self::Path { .. } => Some(StorageBackendLabel::Filesystem),
215            Self::Url { .. } => None,
216            #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
217            Self::Storage { .. } => Some(_config.storage_backend_label()),
218        }
219    }
220}
221
222#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
223pub(super) fn storage_scheme_and_bucket<'a>(
224    explicit_bucket: Option<&'a str>,
225    config: &'a ServerConfig,
226) -> (&'static str, Option<&'a str>) {
227    match config.storage_backend {
228        #[cfg(feature = "s3")]
229        StorageBackend::S3 => {
230            let bucket = explicit_bucket.or(config
231                .s3_context
232                .as_ref()
233                .map(|ctx| ctx.default_bucket.as_str()));
234            ("s3", bucket)
235        }
236        #[cfg(feature = "gcs")]
237        StorageBackend::Gcs => {
238            let bucket = explicit_bucket.or(config
239                .gcs_context
240                .as_ref()
241                .map(|ctx| ctx.default_bucket.as_str()));
242            ("gcs", bucket)
243        }
244        StorageBackend::Filesystem => ("fs", explicit_bucket),
245        #[cfg(feature = "azure")]
246        StorageBackend::Azure => {
247            let bucket = explicit_bucket.or(config
248                .azure_context
249                .as_ref()
250                .map(|ctx| ctx.default_container.as_str()));
251            ("azure", bucket)
252        }
253    }
254}
255
256#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
257pub(super) fn is_object_storage_backend(config: &ServerConfig) -> bool {
258    match config.storage_backend {
259        StorageBackend::Filesystem => false,
260        #[cfg(feature = "s3")]
261        StorageBackend::S3 => true,
262        #[cfg(feature = "gcs")]
263        StorageBackend::Gcs => true,
264        #[cfg(feature = "azure")]
265        StorageBackend::Azure => true,
266    }
267}
268
269#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
270pub(super) fn storage_backend_label(config: &ServerConfig) -> &'static str {
271    match config.storage_backend {
272        StorageBackend::Filesystem => "fs-backend",
273        #[cfg(feature = "s3")]
274        StorageBackend::S3 => "s3-backend",
275        #[cfg(feature = "gcs")]
276        StorageBackend::Gcs => "gcs-backend",
277        #[cfg(feature = "azure")]
278        StorageBackend::Azure => "azure-backend",
279    }
280}
281
282#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
283#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
284#[non_exhaustive]
285pub struct TransformOptionsPayload {
286    #[serde(default, deserialize_with = "crate::core::deserialize_width")]
287    pub width: Option<u32>,
288    #[serde(default, deserialize_with = "crate::core::deserialize_height")]
289    pub height: Option<u32>,
290    pub fit: Option<String>,
291    pub position: Option<String>,
292    pub format: Option<String>,
293    #[serde(default, deserialize_with = "crate::core::deserialize_quality")]
294    pub quality: Option<u8>,
295    pub optimize: Option<String>,
296    pub target_quality: Option<String>,
297    pub background: Option<String>,
298    /// Clockwise rotation in whole degrees. Negatives turn counter-clockwise and values
299    /// past a full turn wrap, which is what `Rotation` accepts and what the CLI and the
300    /// Wasm package take.
301    #[serde(
302        default,
303        deserialize_with = "crate::core::deserialize_rotation_degrees"
304    )]
305    pub rotate: Option<i32>,
306    pub auto_orient: Option<bool>,
307    pub strip_metadata: Option<bool>,
308    pub preserve_exif: Option<bool>,
309    pub crop: Option<String>,
310    pub blur: Option<f32>,
311    pub sharpen: Option<f32>,
312    pub grayscale: Option<bool>,
313    pub without_enlargement: Option<bool>,
314    /// Name of a server-side transform preset, whose fields this request's own fields
315    /// override.
316    ///
317    /// Every route that takes transform options takes this one, which is what
318    /// `docs/openapi.yaml` declares and what presets are for: the vocabulary lives on the
319    /// server rather than in each caller. It is not a field a preset may itself set —
320    /// `parse_presets` refuses it — so a preset cannot name another one.
321    pub preset: Option<String>,
322}
323
324impl TransformOptionsPayload {
325    /// Merges per-request overrides on top of preset defaults.
326    /// Each field in `overrides` takes precedence when set (`Some`).
327    pub(super) fn with_overrides(self, overrides: &TransformOptionsPayload) -> Self {
328        Self {
329            width: overrides.width.or(self.width),
330            height: overrides.height.or(self.height),
331            fit: overrides.fit.clone().or(self.fit),
332            position: overrides.position.clone().or(self.position),
333            format: overrides.format.clone().or(self.format),
334            quality: overrides.quality.or(self.quality),
335            optimize: overrides.optimize.clone().or(self.optimize),
336            target_quality: overrides.target_quality.clone().or(self.target_quality),
337            background: overrides.background.clone().or(self.background),
338            rotate: overrides.rotate.or(self.rotate),
339            auto_orient: overrides.auto_orient.or(self.auto_orient),
340            strip_metadata: overrides.strip_metadata.or(self.strip_metadata),
341            preserve_exif: overrides.preserve_exif.or(self.preserve_exif),
342            crop: overrides.crop.clone().or(self.crop),
343            blur: overrides.blur.or(self.blur),
344            sharpen: overrides.sharpen.or(self.sharpen),
345            grayscale: overrides.grayscale.or(self.grayscale),
346            without_enlargement: overrides.without_enlargement.or(self.without_enlargement),
347            // The preset has been resolved by the time the merge runs, so the name has
348            // nothing left to say.
349            preset: None,
350        }
351    }
352
353    /// Resolves a named preset into the fields it stands for, with this request's own
354    /// fields on top.
355    ///
356    /// One copy for every route, because it used to live inside the query parser and the
357    /// JSON body therefore reached no preset at all: `POST /images:transform` answered
358    /// `unknown field \`preset\`` for the field `docs/openapi.yaml` declares on the schema
359    /// its own body points at. The precedence is the one the document states and
360    /// `with_overrides` already implemented, and the resolved options are what the cache key
361    /// is computed from, so a request naming a preset and one naming the same values shares
362    /// an entry.
363    pub(super) fn resolve_preset(self, config: &ServerConfig) -> Result<Self, HttpResponse> {
364        let Some(name) = self.preset.clone() else {
365            return Ok(self);
366        };
367        let presets = config.presets.read().expect("presets lock poisoned");
368        let preset = presets
369            .get(&name)
370            .ok_or_else(|| bad_request_response(&format!("unknown preset `{name}`")))?
371            .clone();
372        drop(presets);
373        Ok(preset.with_overrides(&self))
374    }
375
376    /// Resolves the requested output format, refusing one truss reads but cannot write.
377    ///
378    /// The refusal is the same class the pipeline gives it, 415 with
379    /// `unsupported-output-media-type`, and the same sentence the CLI and the Wasm package
380    /// print; only the moment moves. It used to be raised by the encoder, which meant the
381    /// server had already fetched the source and decoded the picture for a request it was
382    /// always going to refuse.
383    fn output_format(&self) -> Result<Option<MediaType>, HttpResponse> {
384        let Some(value) = self.format.as_deref() else {
385            return Ok(None);
386        };
387        let media_type = parse_named(value, "format", MediaType::from_str)?;
388        match media_type.unencodable_reason() {
389            Some(reason) => Err(unsupported_output_media_type_response(&reason)),
390            None => Ok(Some(media_type)),
391        }
392    }
393
394    pub(super) fn into_options(self) -> Result<TransformOptions, HttpResponse> {
395        let defaults = TransformOptions::default();
396
397        // `preserveExif` implies "do not strip", the same way it does on the CLI and in
398        // the WASM build. Reading the two fields independently made this adapter the one
399        // that answered 400 for `preserveExif=true` on its own — including when a
400        // server-side preset was the thing that set it — while every other caller of
401        // `resolve_metadata_flags` accepted it.
402        let (strip_metadata, preserve_exif) = crate::core::resolve_metadata_flags(
403            self.strip_metadata,
404            None,
405            self.preserve_exif.or(Some(defaults.preserve_exif)),
406        )
407        .map_err(|error| bad_request_response(&error.to_string()))?;
408
409        let options = TransformOptions {
410            width: self.width,
411            height: self.height,
412            fit: parse_optional_named(self.fit.as_deref(), "fit", Fit::from_str)?,
413            position: parse_optional_named(
414                self.position.as_deref(),
415                "position",
416                Position::from_str,
417            )?,
418            format: self.output_format()?,
419            quality: self.quality,
420            optimize: parse_optional_named(
421                self.optimize.as_deref(),
422                "optimize",
423                OptimizeMode::from_str,
424            )?
425            .unwrap_or(defaults.optimize),
426            target_quality: parse_optional_named(
427                self.target_quality.as_deref(),
428                "targetQuality",
429                TargetQuality::from_str,
430            )?,
431            background: parse_optional_named(
432                self.background.as_deref(),
433                "background",
434                Rgba8::from_hex,
435            )?,
436            rotate: match self.rotate {
437                Some(value) => parse_named(&value.to_string(), "rotate", Rotation::from_str)?,
438                None => defaults.rotate,
439            },
440            auto_orient: self.auto_orient.unwrap_or(defaults.auto_orient),
441            strip_metadata,
442            preserve_exif,
443            crop: parse_optional_named(self.crop.as_deref(), "crop", CropRegion::from_str)?,
444            blur: self.blur,
445            sharpen: self.sharpen,
446            grayscale: self.grayscale.unwrap_or(defaults.grayscale),
447            without_enlargement: self
448                .without_enlargement
449                .unwrap_or(defaults.without_enlargement),
450            deadline: defaults.deadline,
451        };
452
453        // The rules that need no image are settled here, before the cache is asked
454        // anything and before the source is fetched. Leaving them to `normalize`, which
455        // runs inside the transform, meant a request that was wrong whatever the input
456        // still cost an outbound fetch or a billed storage read, and was then reported as
457        // the origin's failure; it also meant `fit` and `position`, the two options the
458        // cache key drops when the resize is unbounded, could be answered from an entry a
459        // valid request had written.
460        options
461            .validate_without_input()
462            .map_err(transform_error_response)?;
463
464        Ok(options)
465    }
466}
467
468/// Overall request deadline for outbound fetches (source + watermark combined).
469const REQUEST_DEADLINE_SECS: u64 = 60;
470
471use crate::core::{
472    WATERMARK_DEFAULT_MARGIN, WATERMARK_DEFAULT_OPACITY, WATERMARK_DEFAULT_POSITION,
473};
474
475#[derive(Debug, Default, Deserialize)]
476#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
477pub(super) struct WatermarkPayload {
478    pub(super) url: Option<String>,
479    pub(super) position: Option<String>,
480    pub(super) opacity: Option<u8>,
481    pub(super) margin: Option<u32>,
482}
483
484/// Validated watermark parameters ready for fetching. No network I/O performed.
485#[derive(Debug)]
486pub(super) struct ValidatedWatermarkPayload {
487    pub(super) url: String,
488    pub(super) position: Position,
489    pub(super) opacity: u8,
490    pub(super) margin: u32,
491}
492
493impl ValidatedWatermarkPayload {
494    pub(super) fn cache_identity(&self) -> String {
495        compute_watermark_identity(
496            &self.url,
497            self.position.as_name(),
498            self.opacity,
499            self.margin,
500        )
501    }
502}
503
504/// Validates watermark payload fields without performing network I/O.
505///
506/// This serves both the JSON body, where the fields are `watermark.url` and friends, and
507/// the public query string, where they are `watermarkUrl` and friends, so the messages
508/// name the option the way the core and the other adapters do rather than picking one of
509/// the two wire spellings and telling half the callers about a field they cannot have
510/// written.
511pub(super) fn validate_watermark_payload(
512    payload: Option<&WatermarkPayload>,
513) -> Result<Option<ValidatedWatermarkPayload>, HttpResponse> {
514    let Some(wm) = payload else {
515        return Ok(None);
516    };
517    let url = wm.url.as_deref().filter(|u| !u.is_empty()).ok_or_else(|| {
518        bad_request_response("watermark url is required when a watermark is requested")
519    })?;
520
521    let position = parse_optional_named(
522        wm.position.as_deref(),
523        "watermark position",
524        Position::from_str,
525    )?
526    .unwrap_or(WATERMARK_DEFAULT_POSITION);
527
528    let opacity = wm.opacity.unwrap_or(WATERMARK_DEFAULT_OPACITY);
529    crate::core::validate_watermark_opacity(opacity).map_err(bad_request_response)?;
530
531    // No ceiling of its own: `apply_watermark` refuses any margin that leaves the
532    // watermark no room, whatever the sizes involved, and a second bound here only
533    // decided which of two failure classes the caller saw for the same picture.
534    let margin = wm.margin.unwrap_or(WATERMARK_DEFAULT_MARGIN);
535
536    Ok(Some(ValidatedWatermarkPayload {
537        url: url.to_string(),
538        position,
539        opacity,
540        margin,
541    }))
542}
543
544/// Fetches watermark image and builds WatermarkInput. Called after try_acquire.
545pub(super) fn fetch_watermark(
546    validated: ValidatedWatermarkPayload,
547    config: &ServerConfig,
548    deadline: Option<Instant>,
549) -> Result<WatermarkInput, HttpResponse> {
550    let bytes = read_remote_watermark_bytes(&validated.url, config, deadline)?;
551    let artifact = sniff_artifact(RawArtifact::new(bytes, None))
552        .map_err(|error| bad_request_response(&format!("watermark image is invalid: {error}")))?;
553    if !artifact.media_type.is_raster() {
554        return Err(bad_request_response(
555            "watermark image must be a raster format (not SVG)",
556        ));
557    }
558    let mut watermark = WatermarkInput::new(artifact);
559    watermark.position = validated.position;
560    watermark.opacity = validated.opacity;
561    watermark.margin = validated.margin;
562    Ok(watermark)
563}
564
565pub(super) fn resolve_multipart_watermark(
566    bytes: Vec<u8>,
567    position: Option<String>,
568    opacity: Option<u8>,
569    margin: Option<u32>,
570) -> Result<WatermarkInput, HttpResponse> {
571    let artifact = sniff_artifact(RawArtifact::new(bytes, None))
572        .map_err(|error| bad_request_response(&format!("watermark image is invalid: {error}")))?;
573    if !artifact.media_type.is_raster() {
574        return Err(bad_request_response(
575            "watermark image must be a raster format (not SVG)",
576        ));
577    }
578    let position = parse_optional_named(
579        position.as_deref(),
580        "watermark_position",
581        Position::from_str,
582    )?
583    .unwrap_or(WATERMARK_DEFAULT_POSITION);
584    let opacity = opacity.unwrap_or(WATERMARK_DEFAULT_OPACITY);
585    crate::core::validate_watermark_opacity(opacity).map_err(bad_request_response)?;
586    let margin = margin.unwrap_or(WATERMARK_DEFAULT_MARGIN);
587    Ok(WatermarkInput {
588        image: artifact,
589        position,
590        opacity,
591        margin,
592    })
593}
594
595/// Watermark source: either already resolved (multipart upload) or deferred (URL fetch).
596pub(super) enum WatermarkSource {
597    Deferred(ValidatedWatermarkPayload),
598    Ready(WatermarkInput),
599    None,
600}
601
602impl WatermarkSource {
603    pub(super) fn from_validated(validated: Option<ValidatedWatermarkPayload>) -> Self {
604        match validated {
605            Some(v) => Self::Deferred(v),
606            None => Self::None,
607        }
608    }
609
610    pub(super) fn from_ready(input: Option<WatermarkInput>) -> Self {
611        match input {
612            Some(w) => Self::Ready(w),
613            None => Self::None,
614        }
615    }
616
617    pub(super) fn is_some(&self) -> bool {
618        !matches!(self, Self::None)
619    }
620}
621
622// ---------------------------------------------------------------------------
623// Cached syscall helpers for health endpoints (#74)
624// ---------------------------------------------------------------------------
625
626/// Sentinel value representing `None` in atomic storage.
627const CACHED_NONE: u64 = u64::MAX;
628
629/// Default TTL for health-check syscall caching (5 seconds).
630pub(super) const DEFAULT_HEALTH_CACHE_TTL_SECS: u64 = 5;
631
632/// Default recovery margin for hysteresis-based resource checks.
633///
634/// When a resource check transitions to "fail", it must recover past
635/// `threshold * (1 ± margin)` before returning to "ok",
636/// preventing rapid oscillation (flapping) near the boundary.
637///
638/// Configurable via `TRUSS_HEALTH_HYSTERESIS_MARGIN` (0.01–0.50, default 0.05).
639pub(super) const DEFAULT_HYSTERESIS_MARGIN: f64 = 0.05;
640
641/// Directionality for threshold-based resource checks.
642#[derive(Clone, Copy)]
643pub(crate) enum ThresholdDirection {
644    /// Higher values are worse (e.g. memory usage). Fails when `current >= threshold`.
645    HigherIsWorse,
646    /// Lower values are worse (e.g. free disk space). Fails when `current < threshold`.
647    LowerIsWorse,
648}
649
650/// Lock-free cache for expensive syscall results used by health endpoints.
651///
652/// Caches `disk_free_bytes()` and `process_rss_bytes()` with a configurable
653/// TTL so that high-frequency polling does not generate redundant kernel
654/// context switches and file I/O.
655pub(crate) struct HealthCache {
656    disk_free: AtomicU64,
657    disk_free_at: AtomicU64,
658    rss: AtomicU64,
659    rss_at: AtomicU64,
660    pub(super) ttl_nanos: u64,
661    /// Hysteresis recovery margin (0.01–0.50).
662    pub(super) hysteresis_margin: f64,
663    /// Hysteresis state for disk free-space checks (0 = ok, 1 = fail).
664    disk_state: AtomicU8,
665    /// Hysteresis state for RSS memory checks (0 = ok, 1 = fail).
666    rss_state: AtomicU8,
667}
668
669impl HealthCache {
670    /// Creates a new cache with the given TTL in seconds and hysteresis margin.
671    pub(super) fn new(ttl_secs: u64, hysteresis_margin: f64) -> Self {
672        Self {
673            disk_free: AtomicU64::new(CACHED_NONE),
674            disk_free_at: AtomicU64::new(0),
675            rss: AtomicU64::new(CACHED_NONE),
676            rss_at: AtomicU64::new(0),
677            ttl_nanos: ttl_secs.saturating_mul(1_000_000_000),
678            hysteresis_margin,
679            disk_state: AtomicU8::new(0),
680            rss_state: AtomicU8::new(0),
681        }
682    }
683
684    /// Returns the monotonic timestamp in nanoseconds since `START_TIME`.
685    fn now_nanos() -> u64 {
686        super::metrics::START_TIME
687            .get_or_init(Instant::now)
688            .elapsed()
689            .as_nanos() as u64
690    }
691
692    /// Returns the cached disk free bytes, refreshing if the TTL has expired.
693    pub(super) fn disk_free(&self, path: &std::path::Path) -> Option<u64> {
694        let now = Self::now_nanos();
695        let last = self.disk_free_at.load(Ordering::Acquire);
696        if now.wrapping_sub(last) < self.ttl_nanos && last != 0 {
697            let v = self.disk_free.load(Ordering::Relaxed);
698            return if v == CACHED_NONE { None } else { Some(v) };
699        }
700        let fresh = disk_free_bytes(path);
701        self.disk_free
702            .store(fresh.unwrap_or(CACHED_NONE), Ordering::Relaxed);
703        self.disk_free_at.store(now, Ordering::Release);
704        fresh
705    }
706
707    /// Returns the cached process RSS bytes, refreshing if the TTL has expired.
708    pub(super) fn rss(&self) -> Option<u64> {
709        let now = Self::now_nanos();
710        let last = self.rss_at.load(Ordering::Acquire);
711        if now.wrapping_sub(last) < self.ttl_nanos && last != 0 {
712            let v = self.rss.load(Ordering::Relaxed);
713            return if v == CACHED_NONE { None } else { Some(v) };
714        }
715        let fresh = process_rss_bytes();
716        self.rss
717            .store(fresh.unwrap_or(CACHED_NONE), Ordering::Relaxed);
718        self.rss_at.store(now, Ordering::Release);
719        fresh
720    }
721
722    /// Applies hysteresis to a threshold check, preventing flapping when
723    /// values hover near the boundary.
724    ///
725    /// `higher_is_worse` controls directionality:
726    /// - `true` (memory): fail when `current >= threshold`, recover when
727    ///   `current < threshold * (1 - margin)`
728    /// - `false` (disk): fail when `current < threshold`, recover when
729    ///   `current > threshold * (1 + margin)`
730    ///
731    /// Returns `(ok, recovering)` where `recovering` is `true` when the check
732    /// remains in the "fail" state only because the recovery margin has not been
733    /// crossed yet (the value has passed the threshold but not the recovery
734    /// point).
735    pub(crate) fn check_with_hysteresis(
736        &self,
737        state: &AtomicU8,
738        current: u64,
739        threshold: u64,
740        direction: ThresholdDirection,
741    ) -> (bool, bool) {
742        let prev_fail = state.load(Ordering::Relaxed) == 1;
743        let (ok, recovering) = match direction {
744            ThresholdDirection::HigherIsWorse => {
745                if prev_fail {
746                    let recovery = (threshold as f64 * (1.0 - self.hysteresis_margin)) as u64;
747                    let ok = current < recovery;
748                    // Recovering: value has dropped below threshold but not below recovery point
749                    let recovering = !ok && current < threshold;
750                    (ok, recovering)
751                } else {
752                    (current < threshold, false)
753                }
754            }
755            ThresholdDirection::LowerIsWorse => {
756                if prev_fail {
757                    let recovery = (threshold as f64 * (1.0 + self.hysteresis_margin)) as u64;
758                    let ok = current > recovery;
759                    // Recovering: value has risen above threshold but not above recovery point
760                    let recovering = !ok && current >= threshold;
761                    (ok, recovering)
762                } else {
763                    (current >= threshold, false)
764                }
765            }
766        };
767        state.store(if ok { 0 } else { 1 }, Ordering::Relaxed);
768        (ok, recovering)
769    }
770}
771
772/// Returns the number of free bytes on the filesystem containing `path`,
773/// or `None` if the query fails.
774#[cfg(target_os = "linux")]
775pub(super) fn disk_free_bytes(path: &std::path::Path) -> Option<u64> {
776    use std::ffi::CString;
777
778    let c_path = CString::new(path.to_str()?).ok()?;
779    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
780    let ret = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
781    if ret == 0 {
782        stat.f_bavail.checked_mul(stat.f_frsize)
783    } else {
784        None
785    }
786}
787
788#[cfg(not(target_os = "linux"))]
789pub(super) fn disk_free_bytes(_path: &std::path::Path) -> Option<u64> {
790    None
791}
792
793/// Returns the current process RSS (Resident Set Size) in bytes by reading
794/// `/proc/self/status`. Returns `None` on non-Linux platforms or on read failure.
795#[cfg(target_os = "linux")]
796pub(super) fn process_rss_bytes() -> Option<u64> {
797    let status = std::fs::read_to_string("/proc/self/status").ok()?;
798    for line in status.lines() {
799        if let Some(value) = line.strip_prefix("VmRSS:") {
800            let value = value.trim();
801            // Format: "123456 kB"
802            let kb_str = value.strip_suffix(" kB")?.trim();
803            let kb: u64 = kb_str.parse().ok()?;
804            return kb.checked_mul(1024);
805        }
806    }
807    None
808}
809
810#[cfg(not(target_os = "linux"))]
811pub(super) fn process_rss_bytes() -> Option<u64> {
812    None
813}
814
815// ---------------------------------------------------------------------------
816// Health handlers
817// ---------------------------------------------------------------------------
818
819/// Returns a minimal liveness response confirming the process is running.
820pub(super) fn handle_health_live() -> HttpResponse {
821    let body = serde_json::to_vec(&json!({
822        "status": "ok",
823        "service": "truss",
824        "version": env!("CARGO_PKG_VERSION"),
825    }))
826    .expect("serialize liveness");
827    let mut body = body;
828    body.push(b'\n');
829    HttpResponse::json("200 OK", body)
830}
831
832/// Returns a readiness response after checking that critical infrastructure
833/// dependencies are available (storage root, cache root if configured, S3
834/// reachability) and configurable resource thresholds.
835pub(super) fn handle_health_ready(config: &ServerConfig) -> HttpResponse {
836    // When the server is draining (shutdown signal received), immediately
837    // report not-ready so that load balancers stop routing traffic.
838    // Skip expensive probes (storage, disk, memory) — they are irrelevant
839    // once the process is shutting down.
840    if config.draining.load(Ordering::Relaxed) {
841        let mut body = serde_json::to_vec(&json!({
842            "status": "fail",
843            "checks": [{ "name": "draining", "status": "fail" }],
844        }))
845        .expect("serialize readiness");
846        body.push(b'\n');
847        let mut response = HttpResponse::json("503 Service Unavailable", body);
848        // The process is going away, not momentarily busy, so tell the client
849        // and any intermediary not to come straight back.
850        response
851            .headers
852            .push(("Retry-After".to_string(), "5".to_string()));
853        return response;
854    }
855
856    let (checks, all_ok) = collect_resource_checks(config);
857
858    let status_str = if all_ok { "ok" } else { "fail" };
859    let mut body = serde_json::to_vec(&json!({
860        "status": status_str,
861        "checks": checks,
862    }))
863    .expect("serialize readiness");
864    body.push(b'\n');
865
866    // Resource check results use application/json (health-check format),
867    // not problem+json, because they represent a structured health report
868    // rather than an error condition.
869    if all_ok {
870        HttpResponse::json("200 OK", body)
871    } else {
872        HttpResponse::json("503 Service Unavailable", body)
873    }
874}
875
876/// Collects all resource health checks shared by `/health` and `/health/ready`.
877///
878/// Returns the accumulated check entries and a boolean indicating whether all
879/// checks passed.
880fn collect_resource_checks(config: &ServerConfig) -> (Vec<serde_json::Value>, bool) {
881    let mut checks: Vec<serde_json::Value> = Vec::new();
882    let mut all_ok = true;
883
884    for (ok, name) in storage_health_check(config) {
885        checks.push(json!({
886            "name": name,
887            "status": if ok { "ok" } else { "fail" },
888        }));
889        if !ok {
890            all_ok = false;
891        }
892    }
893
894    if let Some(cache_root) = &config.cache_root {
895        let cache_ok = cache_root.is_dir();
896        checks.push(json!({
897            "name": "cacheRoot",
898            "status": if cache_ok { "ok" } else { "fail" },
899        }));
900        if !cache_ok {
901            all_ok = false;
902        }
903    }
904
905    if let Some(cache_root) = &config.cache_root {
906        let free = config.health_cache.disk_free(cache_root);
907        let threshold = config.health_cache_min_free_bytes;
908        let (disk_ok, disk_recovering) = match (free, threshold) {
909            (Some(f), Some(min)) => config.health_cache.check_with_hysteresis(
910                &config.health_cache.disk_state,
911                f,
912                min,
913                ThresholdDirection::LowerIsWorse,
914            ),
915            _ => (true, false),
916        };
917        let mut check = json!({
918            "name": "cacheDiskFree",
919            "status": if disk_ok { "ok" } else { "fail" },
920        });
921        if let Some(f) = free {
922            check["freeBytes"] = json!(f);
923        }
924        if let Some(min) = threshold {
925            check["thresholdBytes"] = json!(min);
926        }
927        if disk_recovering {
928            check["recovering"] = json!(true);
929        }
930        checks.push(check);
931        if !disk_ok {
932            all_ok = false;
933        }
934    }
935
936    // Concurrency utilization, reported and deliberately outside `all_ok`. A server with
937    // every slot busy is doing what it was configured to do and is answering what it
938    // accepted, so withdrawing it from the load balancer would move its share to its peers
939    // and push them into the same state. Shedding the individual request with a 503 is the
940    // load signal; readiness answers whether this process should receive traffic at all.
941    //
942    // The status stays `ok` because `current` against `max` is the utilization, and a second
943    // field saying the same thing could only disagree with it.
944    let in_flight = config.transforms_in_flight.load(Ordering::Relaxed);
945    checks.push(json!({
946        "name": "transformCapacity",
947        "status": "ok",
948        "current": in_flight,
949        "max": config.max_concurrent_transforms,
950    }));
951
952    // Memory usage (Linux only) — skip entirely when RSS is unavailable
953    if let Some(rss_bytes) = config.health_cache.rss() {
954        let threshold = config.health_max_memory_bytes;
955        let (mem_ok, mem_recovering) = match threshold {
956            Some(max) => config.health_cache.check_with_hysteresis(
957                &config.health_cache.rss_state,
958                rss_bytes,
959                max,
960                ThresholdDirection::HigherIsWorse,
961            ),
962            None => (true, false),
963        };
964        let mut check = json!({
965            "name": "memoryUsage",
966            "status": if mem_ok { "ok" } else { "fail" },
967            "rssBytes": rss_bytes,
968        });
969        if let Some(max) = threshold {
970            check["thresholdBytes"] = json!(max);
971        }
972        if mem_recovering {
973            check["recovering"] = json!(true);
974        }
975        checks.push(check);
976        if !mem_ok {
977            all_ok = false;
978        }
979    }
980
981    (checks, all_ok)
982}
983
984/// Returns storage backend health checks (storage root existence and cloud
985/// backend reachability).
986pub(crate) fn storage_health_check(config: &ServerConfig) -> Vec<(bool, &'static str)> {
987    #[allow(unused_mut)]
988    let mut checks = vec![(config.storage_root.is_dir(), "storageRoot")];
989    #[cfg(feature = "s3")]
990    if config.storage_backend == StorageBackend::S3 {
991        let reachable = config
992            .s3_context
993            .as_ref()
994            .is_some_and(|ctx| ctx.check_reachable());
995        checks.push((reachable, "storageBackend"));
996    }
997    #[cfg(feature = "gcs")]
998    if config.storage_backend == StorageBackend::Gcs {
999        let reachable = config
1000            .gcs_context
1001            .as_ref()
1002            .is_some_and(|ctx| ctx.check_reachable());
1003        checks.push((reachable, "storageBackend"));
1004    }
1005    #[cfg(feature = "azure")]
1006    if config.storage_backend == StorageBackend::Azure {
1007        let reachable = config
1008            .azure_context
1009            .as_ref()
1010            .is_some_and(|ctx| ctx.check_reachable());
1011        checks.push((reachable, "storageBackend"));
1012    }
1013    checks
1014}
1015
1016pub(super) fn handle_health(config: &ServerConfig) -> HttpResponse {
1017    let (checks, all_ok) = collect_resource_checks(config);
1018
1019    let status_str = if all_ok { "ok" } else { "fail" };
1020    let mut body = serde_json::to_vec(&json!({
1021        "status": status_str,
1022        "service": "truss",
1023        "version": env!("CARGO_PKG_VERSION"),
1024        "uptimeSeconds": uptime_seconds(),
1025        "checks": checks,
1026        "maxInputPixels": config.max_input_pixels,
1027    }))
1028    .expect("serialize health");
1029    body.push(b'\n');
1030
1031    HttpResponse::json("200 OK", body)
1032}
1033
1034// ---------------------------------------------------------------------------
1035// Metrics handler
1036// ---------------------------------------------------------------------------
1037
1038pub(super) fn handle_metrics_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1039    if config.disable_metrics {
1040        return HttpResponse::problem("404 Not Found", NOT_FOUND_BODY.as_bytes().to_vec());
1041    }
1042
1043    if let Some(expected) = &config.metrics_token {
1044        let provided = request
1045            .header("authorization")
1046            .and_then(super::auth::extract_bearer_token);
1047        match provided {
1048            Some(token) if token.as_bytes().ct_eq(expected.as_bytes()).into() => {}
1049            _ => {
1050                return super::response::auth_required_response(
1051                    "metrics endpoint requires authentication",
1052                );
1053            }
1054        }
1055    }
1056
1057    HttpResponse::text(
1058        "200 OK",
1059        "text/plain; version=0.0.4; charset=utf-8",
1060        render_metrics_text(
1061            config.max_concurrent_transforms,
1062            &config.transforms_in_flight,
1063        )
1064        .into_bytes(),
1065    )
1066}
1067
1068// ---------------------------------------------------------------------------
1069// Transform handler
1070// ---------------------------------------------------------------------------
1071
1072/// Refuses a query string on a route whose options come from the request body.
1073///
1074/// The public GET routes carry the transform vocabulary in the query and reject a name
1075/// outside it, so a caller who moves a request from a signed URL to one of these two writes
1076/// the same names here. Reading none of them and answering 200 hands back an image that is
1077/// not the one asked for, which is the failure the multipart parser already refuses for an
1078/// unrecognised form field.
1079fn reject_query_string(request: &HttpRequest) -> Result<(), HttpResponse> {
1080    let Some(query) = request.query().filter(|query| !query.is_empty()) else {
1081        return Ok(());
1082    };
1083    let names: Vec<String> = url::form_urlencoded::parse(query.as_bytes())
1084        .map(|(name, _)| format!("`{name}`"))
1085        .collect();
1086    Err(bad_request_response(&format!(
1087        "this route takes its options from the request body, not the query string; remove {}",
1088        names.join(", ")
1089    )))
1090}
1091
1092pub(super) fn handle_transform_request(
1093    request: HttpRequest,
1094    config: &ServerConfig,
1095) -> HttpResponse {
1096    let request_deadline = Some(Instant::now() + Duration::from_secs(REQUEST_DEADLINE_SECS));
1097
1098    if let Err(response) = authorize_request(&request, config) {
1099        return response;
1100    }
1101
1102    if let Err(response) = reject_query_string(&request) {
1103        return response;
1104    }
1105
1106    if !request_has_json_content_type(&request) {
1107        return unsupported_media_type_response("content-type must be application/json");
1108    }
1109
1110    let payload: TransformImageRequestPayload = match serde_json::from_slice(&request.body) {
1111        Ok(payload) => payload,
1112        Err(error) => {
1113            return bad_request_response(&super::response::json_parse_message(
1114                "request body",
1115                &error,
1116            ));
1117        }
1118    };
1119    let options = match payload
1120        .options
1121        .resolve_preset(config)
1122        .and_then(TransformOptionsPayload::into_options)
1123    {
1124        Ok(options) => options,
1125        Err(response) => return response,
1126    };
1127
1128    let versioned_hash = payload.source.versioned_source_hash(config);
1129    let validated_wm = match validate_watermark_payload(payload.watermark.as_ref()) {
1130        Ok(wm) => wm,
1131        Err(response) => return response,
1132    };
1133    let watermark_id = validated_wm
1134        .as_ref()
1135        .map(ValidatedWatermarkPayload::cache_identity);
1136
1137    if let Some(response) = try_versioned_cache_lookup(
1138        versioned_hash.as_deref(),
1139        &options,
1140        &request,
1141        ImageResponsePolicy::PrivateTransform,
1142        config,
1143        watermark_id.as_deref(),
1144    ) {
1145        return response;
1146    }
1147
1148    let storage_start = Instant::now();
1149    let backend_label = payload.source.metrics_backend_label(config);
1150    let backend_idx = backend_label.map(|l| storage_backend_index_from_config(&l));
1151    let source_bytes = match resolve_source_bytes(payload.source, config, request_deadline) {
1152        Ok(bytes) => {
1153            if let Some(idx) = backend_idx {
1154                record_storage_duration(idx, storage_start);
1155            }
1156            bytes
1157        }
1158        Err(response) => {
1159            if let Some(idx) = backend_idx {
1160                record_storage_duration(idx, storage_start);
1161            }
1162            return response;
1163        }
1164    };
1165    transform_source_bytes(
1166        source_bytes,
1167        options,
1168        versioned_hash.as_deref(),
1169        &request,
1170        ImageResponsePolicy::PrivateTransform,
1171        config,
1172        WatermarkSource::from_validated(validated_wm),
1173        watermark_id.as_deref(),
1174        request_deadline,
1175    )
1176}
1177
1178// ---------------------------------------------------------------------------
1179// Public GET handlers
1180// ---------------------------------------------------------------------------
1181
1182pub(super) fn handle_public_path_request(
1183    request: HttpRequest,
1184    config: &ServerConfig,
1185) -> HttpResponse {
1186    handle_public_get_request(request, config, PublicSourceKind::Path)
1187}
1188
1189pub(super) fn handle_public_url_request(
1190    request: HttpRequest,
1191    config: &ServerConfig,
1192) -> HttpResponse {
1193    handle_public_get_request(request, config, PublicSourceKind::Url)
1194}
1195
1196fn handle_public_get_request(
1197    request: HttpRequest,
1198    config: &ServerConfig,
1199    source_kind: PublicSourceKind,
1200) -> HttpResponse {
1201    let request_deadline = Some(Instant::now() + Duration::from_secs(REQUEST_DEADLINE_SECS));
1202    let query = match parse_query_params(&request) {
1203        Ok(query) => query,
1204        Err(response) => return response,
1205    };
1206    if let Err(response) = authorize_signed_request(&request, &query, config) {
1207        return response;
1208    }
1209    let (source, options, watermark_payload) =
1210        match parse_public_get_request(&query, source_kind, config) {
1211            Ok(parsed) => parsed,
1212            Err(response) => return response,
1213        };
1214
1215    let validated_wm = match validate_watermark_payload(watermark_payload.as_ref()) {
1216        Ok(wm) => wm,
1217        Err(response) => return response,
1218    };
1219    let watermark_id = validated_wm
1220        .as_ref()
1221        .map(ValidatedWatermarkPayload::cache_identity);
1222
1223    // When the storage backend is object storage (S3 or GCS), convert Path
1224    // sources to Storage sources so that the `path` query parameter is
1225    // resolved as an object key.
1226    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1227    let source = if is_object_storage_backend(config) {
1228        match source {
1229            TransformSourcePayload::Path { path, version } => TransformSourcePayload::Storage {
1230                bucket: None,
1231                key: path.trim_start_matches('/').to_string(),
1232                version,
1233            },
1234            other => other,
1235        }
1236    } else {
1237        source
1238    };
1239
1240    let versioned_hash = source.versioned_source_hash(config);
1241    if let Some(response) = try_versioned_cache_lookup(
1242        versioned_hash.as_deref(),
1243        &options,
1244        &request,
1245        ImageResponsePolicy::PublicGet,
1246        config,
1247        watermark_id.as_deref(),
1248    ) {
1249        return response;
1250    }
1251
1252    let storage_start = Instant::now();
1253    let backend_label = source.metrics_backend_label(config);
1254    let backend_idx = backend_label.map(|l| storage_backend_index_from_config(&l));
1255    let source_bytes = match resolve_source_bytes(source, config, request_deadline) {
1256        Ok(bytes) => {
1257            if let Some(idx) = backend_idx {
1258                record_storage_duration(idx, storage_start);
1259            }
1260            bytes
1261        }
1262        Err(response) => {
1263            if let Some(idx) = backend_idx {
1264                record_storage_duration(idx, storage_start);
1265            }
1266            return response;
1267        }
1268    };
1269
1270    transform_source_bytes(
1271        source_bytes,
1272        options,
1273        versioned_hash.as_deref(),
1274        &request,
1275        ImageResponsePolicy::PublicGet,
1276        config,
1277        WatermarkSource::from_validated(validated_wm),
1278        watermark_id.as_deref(),
1279        request_deadline,
1280    )
1281}
1282
1283// ---------------------------------------------------------------------------
1284// Upload handler
1285// ---------------------------------------------------------------------------
1286
1287pub(super) fn handle_upload_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1288    if let Err(response) = authorize_request(&request, config) {
1289        return response;
1290    }
1291
1292    if let Err(response) = reject_query_string(&request) {
1293        return response;
1294    }
1295
1296    let boundary = match parse_multipart_boundary(&request) {
1297        Ok(boundary) => boundary,
1298        Err(response) => return response,
1299    };
1300    let (file_bytes, options, watermark) =
1301        match parse_upload_request(&request.body, &boundary, config) {
1302            Ok(parts) => parts,
1303            Err(response) => return response,
1304        };
1305    let watermark_identity = watermark.as_ref().map(|wm| {
1306        let content_hash = hex::encode(sha2::Sha256::digest(&wm.image.bytes));
1307        super::cache::compute_watermark_content_identity(
1308            &content_hash,
1309            wm.position.as_name(),
1310            wm.opacity,
1311            wm.margin,
1312        )
1313    });
1314    transform_source_bytes(
1315        file_bytes,
1316        options,
1317        None,
1318        &request,
1319        ImageResponsePolicy::PrivateTransform,
1320        config,
1321        WatermarkSource::from_ready(watermark),
1322        watermark_identity.as_deref(),
1323        None,
1324    )
1325}
1326
1327// ---------------------------------------------------------------------------
1328// Public GET query parsing
1329// ---------------------------------------------------------------------------
1330
1331pub(super) fn parse_public_get_request(
1332    query: &BTreeMap<String, String>,
1333    source_kind: PublicSourceKind,
1334    config: &ServerConfig,
1335) -> Result<
1336    (
1337        TransformSourcePayload,
1338        TransformOptions,
1339        Option<WatermarkPayload>,
1340    ),
1341    HttpResponse,
1342> {
1343    validate_public_query_names(query, source_kind)?;
1344
1345    let source = match source_kind {
1346        PublicSourceKind::Path => TransformSourcePayload::Path {
1347            path: required_query_param(query, "path")?.to_string(),
1348            version: query.get("version").cloned(),
1349        },
1350        PublicSourceKind::Url => TransformSourcePayload::Url {
1351            url: required_query_param(query, "url")?.to_string(),
1352            version: query.get("version").cloned(),
1353        },
1354    };
1355
1356    let has_orphaned_watermark_params = query.contains_key("watermarkPosition")
1357        || query.contains_key("watermarkOpacity")
1358        || query.contains_key("watermarkMargin");
1359    let watermark = if query.contains_key("watermarkUrl") {
1360        Some(WatermarkPayload {
1361            url: query.get("watermarkUrl").cloned(),
1362            position: query.get("watermarkPosition").cloned(),
1363            opacity: parse_optional_u8_query(
1364                query,
1365                "watermarkOpacity",
1366                crate::core::validate_watermark_opacity_value,
1367            )?,
1368            margin: parse_optional_integer_query(
1369                query,
1370                "watermarkMargin",
1371                crate::core::validate_watermark_margin_value,
1372            )?,
1373        })
1374    } else if has_orphaned_watermark_params {
1375        return Err(bad_request_response(
1376            "watermarkPosition, watermarkOpacity, and watermarkMargin require watermarkUrl",
1377        ));
1378    } else {
1379        None
1380    };
1381
1382    // Build per-request overrides from query parameters.
1383    let per_request = TransformOptionsPayload {
1384        width: parse_optional_integer_query(query, "width", crate::core::validate_width_value)?,
1385        height: parse_optional_integer_query(query, "height", crate::core::validate_height_value)?,
1386        fit: query.get("fit").cloned(),
1387        position: query.get("position").cloned(),
1388        format: query.get("format").cloned(),
1389        quality: parse_optional_u8_query(query, "quality", crate::core::validate_quality_value)?,
1390        optimize: query.get("optimize").cloned(),
1391        target_quality: query.get("targetQuality").cloned(),
1392        background: query.get("background").cloned(),
1393        // `Rotation` accepts any whole number of degrees, negatives included, so the query
1394        // is read through it rather than through a narrower integer type with a range
1395        // sentence of its own that stopped being true in v0.13.0.
1396        rotate: parse_optional_named(query.get("rotate").map(String::as_str), "rotate", |value| {
1397            Rotation::from_str(value).map(|rotation| i32::from(rotation.as_degrees()))
1398        })?,
1399        auto_orient: parse_optional_bool_query(query, "autoOrient")?,
1400        strip_metadata: parse_optional_bool_query(query, "stripMetadata")?,
1401        preserve_exif: parse_optional_bool_query(query, "preserveExif")?,
1402        crop: query.get("crop").cloned(),
1403        blur: parse_optional_float_query(query, "blur")?,
1404        sharpen: parse_optional_float_query(query, "sharpen")?,
1405        grayscale: parse_optional_bool_query(query, "grayscale")?,
1406        without_enlargement: parse_optional_bool_query(query, "withoutEnlargement")?,
1407        preset: query.get("preset").cloned(),
1408    };
1409
1410    let options = per_request.resolve_preset(config)?.into_options()?;
1411
1412    Ok((source, options, watermark))
1413}
1414
1415// ---------------------------------------------------------------------------
1416// Transform pipeline
1417// ---------------------------------------------------------------------------
1418
1419#[allow(clippy::too_many_arguments)]
1420pub(super) fn transform_source_bytes(
1421    source_bytes: Vec<u8>,
1422    options: TransformOptions,
1423    versioned_hash: Option<&str>,
1424    request: &HttpRequest,
1425    response_policy: ImageResponsePolicy,
1426    config: &ServerConfig,
1427    watermark: WatermarkSource,
1428    watermark_identity: Option<&str>,
1429    request_deadline: Option<Instant>,
1430) -> HttpResponse {
1431    let content_hash;
1432    let source_hash = match versioned_hash {
1433        Some(hash) => hash,
1434        None => {
1435            content_hash = hex::encode(Sha256::digest(&source_bytes));
1436            &content_hash
1437        }
1438    };
1439
1440    let cache = config.cache_root.as_ref().map(|root| {
1441        TransformCache::new(root.clone())
1442            .with_log_handler(config.log_handler.clone())
1443            .with_eviction(
1444                config.cache_max_bytes,
1445                Arc::clone(&config.cache_eviction_secs),
1446            )
1447    });
1448
1449    // The concurrency limit is taken inside, once the answer is known to need a transform.
1450    // A variant already on disk costs no decode to serve, so it is answered whether or not
1451    // the slots are full; the lookup that decides needs a concrete format, which a request
1452    // that leaves the format to `Accept` only gets after the negotiation a few lines into
1453    // the callee.
1454    transform_source_bytes_inner(
1455        source_bytes,
1456        options,
1457        request,
1458        response_policy,
1459        cache.as_ref(),
1460        source_hash,
1461        ImageResponseConfig {
1462            disable_accept_negotiation: config.disable_accept_negotiation,
1463            public_cache_control: PublicCacheControl {
1464                max_age: config.public_max_age_seconds,
1465                stale_while_revalidate: config.public_stale_while_revalidate_seconds,
1466            },
1467            transform_deadline: Duration::from_secs(config.transform_deadline_secs),
1468        },
1469        watermark,
1470        watermark_identity,
1471        config,
1472        request_deadline,
1473    )
1474}
1475
1476#[allow(clippy::too_many_arguments)]
1477fn transform_source_bytes_inner(
1478    source_bytes: Vec<u8>,
1479    mut options: TransformOptions,
1480    request: &HttpRequest,
1481    response_policy: ImageResponsePolicy,
1482    cache: Option<&TransformCache>,
1483    source_hash: &str,
1484    response_config: ImageResponseConfig,
1485    watermark_source: WatermarkSource,
1486    watermark_identity: Option<&str>,
1487    config: &ServerConfig,
1488    request_deadline: Option<Instant>,
1489) -> HttpResponse {
1490    if options.deadline.is_none() {
1491        options.deadline = Some(response_config.transform_deadline);
1492    }
1493    let artifact = match sniff_artifact(RawArtifact::new(source_bytes, None)) {
1494        Ok(artifact) => artifact,
1495        Err(error) => {
1496            record_transform_error(&error);
1497            return transform_error_response(error);
1498        }
1499    };
1500    // `Vary` describes the resource, not the request that happened to arrive. What
1501    // matters is whether `Accept` could have selected the representation for this
1502    // URL, not whether it did: a request that sends no `Accept` gets the default
1503    // representation of a URL that another request negotiates away from, and a
1504    // shared cache that stores a response with no `Vary` serves it to everyone.
1505    let accept_may_vary = options.format.is_none() && !response_config.disable_accept_negotiation;
1506    if accept_may_vary {
1507        match negotiate_output_format(
1508            request.header("accept"),
1509            &artifact,
1510            &config.format_preference,
1511        ) {
1512            Ok(Some(format)) => options.format = Some(format),
1513            Ok(None) => {}
1514            Err(response) => return response,
1515        }
1516    }
1517
1518    if options.format.is_none() {
1519        // The server needs a concrete format here, ahead of the transform, for the cache
1520        // key and the response Content-Type. `default_output` keeps the input's format
1521        // except for a decode-only input such as GIF, which resolves to PNG.
1522        options.format = Some(artifact.media_type.default_output());
1523    }
1524
1525    // Check input pixel count against the server-level limit before decode.
1526    // This runs before the cache lookup so that a policy change (lowering the
1527    // limit) takes effect immediately, even for previously-cached images.
1528    if let (Some(w), Some(h)) = (artifact.metadata.width, artifact.metadata.height) {
1529        let pixels = u64::from(w) * u64::from(h);
1530        if pixels > config.max_input_pixels {
1531            return super::response::unprocessable_entity_response(&format!(
1532                "input image has {pixels} pixels, server limit is {}",
1533                config.max_input_pixels
1534            ));
1535        }
1536    }
1537
1538    // The Accept header is deliberately absent from the key. Negotiation's whole output is
1539    // the format, which `options.format` already carries, so including the raw header would
1540    // write one copy of the same image per distinct header string — unboundedly many, and
1541    // straight off the request. `Vary: Accept` is built per response from `accept_may_vary`,
1542    // so an entry shared with a request that named the format explicitly still answers right.
1543    let cache_key = compute_cache_key(source_hash, &options, watermark_identity);
1544
1545    if let Some(cache) = cache
1546        && let Some(response) = cache.get(&cache_key).into_hit_response(
1547            request,
1548            response_policy,
1549            accept_may_vary,
1550            response_config.public_cache_control,
1551            &config.custom_response_headers,
1552        )
1553    {
1554        CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
1555        return response;
1556    }
1557
1558    if cache.is_some() {
1559        CACHE_MISSES_TOTAL.fetch_add(1, Ordering::Relaxed);
1560    }
1561
1562    // Everything above answers from what is already on disk or in the request. What follows
1563    // fetches and decodes, which is the work the limit exists to bound.
1564    let Some(_slot) = TransformSlot::try_acquire(
1565        &config.transforms_in_flight,
1566        config.max_concurrent_transforms,
1567    ) else {
1568        return service_unavailable_response(
1569            "too many concurrent transforms; retry later",
1570            Some(1),
1571        );
1572    };
1573
1574    let is_svg = artifact.media_type == MediaType::Svg;
1575
1576    // Resolve watermark: reject SVG+watermark early (before fetch), then fetch if deferred.
1577    let watermark = if is_svg && watermark_source.is_some() {
1578        return bad_request_response("watermark is not supported for SVG source images");
1579    } else {
1580        match watermark_source {
1581            WatermarkSource::Deferred(validated) => {
1582                match fetch_watermark(validated, config, request_deadline) {
1583                    Ok(wm) => {
1584                        record_watermark_transform();
1585                        Some(wm)
1586                    }
1587                    Err(response) => return response,
1588                }
1589            }
1590            WatermarkSource::Ready(wm) => {
1591                record_watermark_transform();
1592                Some(wm)
1593            }
1594            WatermarkSource::None => None,
1595        }
1596    };
1597
1598    let had_watermark = watermark.is_some();
1599
1600    let transform_start = Instant::now();
1601    let mut request_obj = TransformRequest::new(artifact, options);
1602    request_obj.watermark = watermark;
1603    let result = match transform(request_obj) {
1604        Ok(result) => result,
1605        Err(error) => {
1606            record_transform_error(&error);
1607            return transform_error_response(error);
1608        }
1609    };
1610    record_transform_duration(result.artifact.media_type, transform_start);
1611
1612    // The warnings go three ways: the log, the response, and the cache entry, so that a
1613    // later hit answers with the same headers this miss does.
1614    let warnings: Vec<String> = result
1615        .warnings
1616        .iter()
1617        .map(|warning| warning_header_value(&warning.to_string()))
1618        .collect();
1619    for warning in &result.warnings {
1620        let msg = format!("truss: {warning}");
1621        if let Some(c) = cache
1622            && let Some(handler) = &c.log_handler
1623        {
1624            handler(&msg);
1625        } else {
1626            stderr_write(&msg);
1627        }
1628    }
1629
1630    let output = result.artifact;
1631
1632    if let Some(cache) = cache {
1633        cache.put(&cache_key, output.media_type, &output.bytes, &warnings);
1634    }
1635
1636    let cache_hit_status = if cache.is_some() {
1637        CacheHitStatus::Miss
1638    } else {
1639        CacheHitStatus::Disabled
1640    };
1641
1642    let etag = build_image_etag(&output.bytes);
1643    let mut headers = build_image_response_headers(
1644        output.media_type,
1645        &etag,
1646        response_policy,
1647        accept_may_vary,
1648        cache_hit_status,
1649        response_config.public_cache_control,
1650        &config.custom_response_headers,
1651    );
1652
1653    if matches!(response_policy, ImageResponsePolicy::PublicGet)
1654        && if_none_match_matches(request.header("if-none-match"), &etag)
1655    {
1656        return HttpResponse::empty("304 Not Modified", headers);
1657    }
1658
1659    // A 304 says the client already has the representation, warnings included; only the
1660    // response that carries the image carries them.
1661    push_warning_headers(&mut headers, &warnings);
1662    let mut response = HttpResponse::binary_with_headers(
1663        "200 OK",
1664        output.media_type.as_mime(),
1665        headers,
1666        output.bytes,
1667    );
1668    if had_watermark {
1669        response
1670            .headers
1671            .push(("X-Truss-Watermark".to_string(), "true".to_string()));
1672    }
1673    response
1674}
1675
1676#[cfg(test)]
1677mod tests {
1678
1679    /// The transform options object the JSON body deserializes into and the
1680    /// `ImageTransformOptions` schema `docs/openapi.yaml` publishes name the same fields.
1681    ///
1682    /// They had drifted by one: the document declared `preset` and the payload refused it,
1683    /// so a caller who generated a client from the document and sent the field it declares
1684    /// got a 400 naming that field. The payload's names are read out of the error
1685    /// `deny_unknown_fields` produces rather than repeated here, so the comparison is
1686    /// against the struct rather than against a copy of it.
1687    #[test]
1688    fn the_transform_options_payload_and_the_openapi_schema_name_the_same_fields() {
1689        let error = serde_json::from_str::<TransformOptionsPayload>(r#"{"nosuchfield":1}"#)
1690            .expect_err("an unknown field is refused");
1691        let message = error.to_string();
1692        let listed = message
1693            .split("expected one of ")
1694            .nth(1)
1695            .expect("the refusal lists the fields it expected");
1696        let mut payload_fields: Vec<String> = listed
1697            .split('`')
1698            .filter(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric()))
1699            .map(str::to_string)
1700            .collect();
1701        payload_fields.sort();
1702        assert!(
1703            payload_fields.len() > 15,
1704            "the field list was read wrong: {payload_fields:?}"
1705        );
1706
1707        // A Windows checkout has CRLF line endings, so the document is matched against the
1708        // text with the carriage returns taken out.
1709        let openapi = include_str!("../../../docs/openapi.yaml").replace('\r', "");
1710        let schema = openapi
1711            .split("    ImageTransformOptions:")
1712            .nth(1)
1713            .expect("docs/openapi.yaml declares ImageTransformOptions");
1714        let properties = schema
1715            .split("      properties:\n")
1716            .nth(1)
1717            .expect("the schema has properties");
1718        let mut documented: Vec<String> = Vec::new();
1719        for line in properties.lines() {
1720            // A property is indented eight spaces; anything shallower ends the schema and
1721            // anything deeper belongs to the property above.
1722            if !line.starts_with("        ") {
1723                break;
1724            }
1725            if line.starts_with("         ") {
1726                continue;
1727            }
1728            let Some(name) = line.trim_end().strip_suffix(':') else {
1729                continue;
1730            };
1731            documented.push(name.trim().to_string());
1732        }
1733        documented.sort();
1734
1735        assert_eq!(
1736            payload_fields, documented,
1737            "the JSON payload and ImageTransformOptions declare different fields"
1738        );
1739    }
1740    use super::*;
1741
1742    use ThresholdDirection::{HigherIsWorse, LowerIsWorse};
1743    use rstest::rstest;
1744
1745    /// Shorthand: returns (ok, recovering) tuple from check_with_hysteresis.
1746    fn check(
1747        cache: &HealthCache,
1748        state: &AtomicU8,
1749        current: u64,
1750        threshold: u64,
1751        direction: ThresholdDirection,
1752    ) -> (bool, bool) {
1753        cache.check_with_hysteresis(state, current, threshold, direction)
1754    }
1755
1756    // -- Memory hysteresis (HigherIsWorse) --
1757
1758    #[test]
1759    fn hysteresis_memory_ok_below_threshold() {
1760        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1761        assert_eq!(
1762            check(&c, &c.rss_state, 999, 1000, HigherIsWorse),
1763            (true, false)
1764        );
1765    }
1766
1767    #[test]
1768    fn hysteresis_memory_fails_at_threshold() {
1769        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1770        assert_eq!(
1771            check(&c, &c.rss_state, 1000, 1000, HigherIsWorse),
1772            (false, false)
1773        );
1774    }
1775
1776    #[test]
1777    fn hysteresis_memory_stays_failed_in_margin() {
1778        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1779        check(&c, &c.rss_state, 1000, 1000, HigherIsWorse);
1780        // 960 is below threshold (1000) but above recovery (950) -> recovering
1781        assert_eq!(
1782            check(&c, &c.rss_state, 960, 1000, HigherIsWorse),
1783            (false, true)
1784        );
1785        // 950 is at recovery boundary -> still recovering
1786        assert_eq!(
1787            check(&c, &c.rss_state, 950, 1000, HigherIsWorse),
1788            (false, true)
1789        );
1790    }
1791
1792    #[test]
1793    fn hysteresis_memory_recovers_below_margin() {
1794        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1795        check(&c, &c.rss_state, 1000, 1000, HigherIsWorse);
1796        assert_eq!(
1797            check(&c, &c.rss_state, 949, 1000, HigherIsWorse),
1798            (true, false)
1799        );
1800    }
1801
1802    #[test]
1803    fn hysteresis_memory_full_cycle() {
1804        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1805        assert!(check(&c, &c.rss_state, 900, 1000, HigherIsWorse).0);
1806        assert!(!check(&c, &c.rss_state, 1000, 1000, HigherIsWorse).0);
1807        // In margin: recovering
1808        assert_eq!(
1809            check(&c, &c.rss_state, 960, 1000, HigherIsWorse),
1810            (false, true)
1811        );
1812        assert!(check(&c, &c.rss_state, 940, 1000, HigherIsWorse).0);
1813        assert!(check(&c, &c.rss_state, 999, 1000, HigherIsWorse).0);
1814        assert!(!check(&c, &c.rss_state, 1000, 1000, HigherIsWorse).0);
1815    }
1816
1817    // -- Disk hysteresis (LowerIsWorse) --
1818
1819    #[test]
1820    fn hysteresis_disk_ok_at_threshold() {
1821        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1822        assert_eq!(
1823            check(&c, &c.disk_state, 1000, 1000, LowerIsWorse),
1824            (true, false)
1825        );
1826    }
1827
1828    #[test]
1829    fn hysteresis_disk_fails_below_threshold() {
1830        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1831        assert_eq!(
1832            check(&c, &c.disk_state, 999, 1000, LowerIsWorse),
1833            (false, false)
1834        );
1835    }
1836
1837    #[test]
1838    fn hysteresis_disk_stays_failed_in_margin() {
1839        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1840        check(&c, &c.disk_state, 999, 1000, LowerIsWorse);
1841        // 1040 is above threshold (1000) but below recovery (1050) -> recovering
1842        assert_eq!(
1843            check(&c, &c.disk_state, 1040, 1000, LowerIsWorse),
1844            (false, true)
1845        );
1846        // 1050 is at recovery boundary -> still recovering
1847        assert_eq!(
1848            check(&c, &c.disk_state, 1050, 1000, LowerIsWorse),
1849            (false, true)
1850        );
1851    }
1852
1853    #[test]
1854    fn hysteresis_disk_recovers_above_margin() {
1855        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1856        check(&c, &c.disk_state, 999, 1000, LowerIsWorse);
1857        assert_eq!(
1858            check(&c, &c.disk_state, 1051, 1000, LowerIsWorse),
1859            (true, false)
1860        );
1861    }
1862
1863    #[test]
1864    fn hysteresis_disk_full_cycle() {
1865        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1866        assert!(check(&c, &c.disk_state, 2000, 1000, LowerIsWorse).0);
1867        assert!(!check(&c, &c.disk_state, 999, 1000, LowerIsWorse).0);
1868        // In margin: recovering
1869        assert_eq!(
1870            check(&c, &c.disk_state, 1040, 1000, LowerIsWorse),
1871            (false, true)
1872        );
1873        assert!(check(&c, &c.disk_state, 1051, 1000, LowerIsWorse).0);
1874        assert!(check(&c, &c.disk_state, 1000, 1000, LowerIsWorse).0);
1875        assert!(!check(&c, &c.disk_state, 999, 1000, LowerIsWorse).0);
1876    }
1877
1878    #[test]
1879    fn hysteresis_independent_states() {
1880        let c = HealthCache::new(5, DEFAULT_HYSTERESIS_MARGIN);
1881        assert!(!check(&c, &c.disk_state, 500, 1000, LowerIsWorse).0);
1882        assert!(check(&c, &c.rss_state, 500, 1000, HigherIsWorse).0);
1883        assert_eq!(c.disk_state.load(Ordering::Relaxed), 1);
1884        assert_eq!(c.rss_state.load(Ordering::Relaxed), 0);
1885    }
1886
1887    // -- Metadata flags resolve the way every other adapter resolves them --
1888
1889    /// `rotate` is a whole number of degrees, sign included, everywhere truss takes one.
1890    ///
1891    /// The payload used to hold a `u16`, so `-90`, which the CLI and the Wasm package both
1892    /// turn counter-clockwise, was refused by serde before any truss code ran, with a
1893    /// message that named the Rust type.
1894    #[test]
1895    fn rotate_accepts_the_angles_the_other_adapters_accept() {
1896        for (degrees, expected) in [
1897            (-90, Rotation::DEG_270),
1898            (-360, Rotation::DEG_0),
1899            (0, Rotation::DEG_0),
1900            (45, Rotation::from_degrees(45)),
1901            (370, Rotation::from_degrees(10)),
1902        ] {
1903            let options = TransformOptionsPayload {
1904                rotate: Some(degrees),
1905                ..TransformOptionsPayload::default()
1906            }
1907            .into_options()
1908            .unwrap_or_else(|_| panic!("rotate {degrees} is a whole number of degrees"));
1909
1910            assert_eq!(options.rotate, expected, "rotate {degrees}");
1911        }
1912    }
1913
1914    /// A `gif` output is refused while the options are read, not after the picture has been
1915    /// decoded, and with the class the pipeline gives the same refusal.
1916    #[test]
1917    fn a_decode_only_output_format_is_refused_with_the_options() {
1918        let error = TransformOptionsPayload {
1919            format: Some("gif".to_string()),
1920            ..TransformOptionsPayload::default()
1921        }
1922        .into_options()
1923        .expect_err("gif has no encoder behind it");
1924
1925        assert_eq!(error.status, "415 Unsupported Media Type");
1926        let body = String::from_utf8(error.body).expect("utf-8 problem body");
1927        assert!(body.contains("unsupported-output-media-type"), "{body}");
1928        assert!(body.contains("input-only"), "{body}");
1929    }
1930
1931    /// A format name that is not a format is still a request that could not be understood,
1932    /// which is a different class from a format truss knows and will not write.
1933    #[test]
1934    fn an_unknown_output_format_stays_an_invalid_request() {
1935        let error = TransformOptionsPayload {
1936            format: Some("bogus".to_string()),
1937            ..TransformOptionsPayload::default()
1938        }
1939        .into_options()
1940        .expect_err("bogus is not a format");
1941
1942        assert_eq!(error.status, "400 Bad Request");
1943        let body = String::from_utf8(error.body).expect("utf-8 problem body");
1944        assert!(body.contains("invalid-request"), "{body}");
1945    }
1946
1947    #[test]
1948    fn preserve_exif_alone_implies_not_stripping() {
1949        // `?preserveExif=true` on its own used to be a 400 from this adapter alone,
1950        // because the two fields were read independently instead of through
1951        // `resolve_metadata_flags`, whose contract is that every adapter agrees.
1952        let options = TransformOptionsPayload {
1953            preserve_exif: Some(true),
1954            ..TransformOptionsPayload::default()
1955        }
1956        .into_options()
1957        .expect("preserveExif on its own is a complete request");
1958
1959        assert!(options.preserve_exif);
1960        assert!(!options.strip_metadata);
1961    }
1962
1963    #[test]
1964    fn preserve_exif_wins_over_an_explicit_strip_metadata() {
1965        // The CLI resolves `--strip-metadata --preserve-exif` the same way: the more
1966        // specific request decides, rather than the pair being refused.
1967        let options = TransformOptionsPayload {
1968            preserve_exif: Some(true),
1969            strip_metadata: Some(true),
1970            ..TransformOptionsPayload::default()
1971        }
1972        .into_options()
1973        .expect("the pair resolves rather than failing");
1974
1975        assert!(options.preserve_exif);
1976        assert!(!options.strip_metadata);
1977    }
1978
1979    #[test]
1980    fn metadata_flags_keep_their_defaults_when_nothing_asks_for_them() {
1981        let options = TransformOptionsPayload::default()
1982            .into_options()
1983            .expect("an empty payload is valid");
1984
1985        assert!(!options.preserve_exif);
1986        assert!(options.strip_metadata, "stripping stays the default");
1987
1988        let kept = TransformOptionsPayload {
1989            strip_metadata: Some(false),
1990            ..TransformOptionsPayload::default()
1991        }
1992        .into_options()
1993        .expect("stripMetadata=false is valid on its own");
1994
1995        assert!(!kept.preserve_exif);
1996        assert!(!kept.strip_metadata);
1997    }
1998
1999    #[test]
2000    fn a_preset_that_sets_preserve_exif_is_usable_without_a_second_field() {
2001        // The 400 also reached operators: a preset naming only `preserveExif` was
2002        // unusable until `stripMetadata: false` was written beside it.
2003        let preset = TransformOptionsPayload {
2004            preserve_exif: Some(true),
2005            ..TransformOptionsPayload::default()
2006        };
2007        let options = preset
2008            .with_overrides(&TransformOptionsPayload {
2009                width: Some(64),
2010                ..TransformOptionsPayload::default()
2011            })
2012            .into_options()
2013            .expect("a preset may set preserveExif on its own");
2014
2015        assert!(options.preserve_exif);
2016        assert!(!options.strip_metadata);
2017        assert_eq!(options.width, Some(64));
2018    }
2019
2020    /// The rules that need no image are settled while the options are read, so the cache
2021    /// is never asked about a request that cannot be served and no source is fetched for
2022    /// one. Leaving them to the transform put `fit` and `position` behind a cache lookup
2023    /// whose key drops them, and put every other rule behind the fetch.
2024    #[rstest]
2025    #[case(
2026        TransformOptionsPayload { fit: Some("cover".to_string()), ..TransformOptionsPayload::default() },
2027        "fit requires both width and height"
2028    )]
2029    #[case(
2030        TransformOptionsPayload { position: Some("center".to_string()), ..TransformOptionsPayload::default() },
2031        "position requires both width and height"
2032    )]
2033    #[case(
2034        TransformOptionsPayload { without_enlargement: Some(true), ..TransformOptionsPayload::default() },
2035        "withoutEnlargement requires width or height"
2036    )]
2037    #[case(
2038        TransformOptionsPayload { width: Some(0), ..TransformOptionsPayload::default() },
2039        "width must be greater than zero"
2040    )]
2041    #[case(
2042        TransformOptionsPayload { quality: Some(101), ..TransformOptionsPayload::default() },
2043        "quality must be between 1 and 100"
2044    )]
2045    #[case(
2046        TransformOptionsPayload { blur: Some(200.0), ..TransformOptionsPayload::default() },
2047        "blur sigma must be between 0.1 and 100.0"
2048    )]
2049    #[case(
2050        TransformOptionsPayload { sharpen: Some(500.0), ..TransformOptionsPayload::default() },
2051        "sharpen sigma must be between 0.1 and 100.0"
2052    )]
2053    fn into_options_refuses_what_no_input_could_make_valid(
2054        #[case] payload: TransformOptionsPayload,
2055        #[case] message: &str,
2056    ) {
2057        let response = payload
2058            .into_options()
2059            .expect_err("an always-invalid option set is refused while the options are read");
2060        let body = String::from_utf8_lossy(&response.body);
2061
2062        assert!(
2063            response.status.starts_with("400"),
2064            "expected 400, got {} with {body}",
2065            response.status
2066        );
2067        assert!(body.contains(message), "expected {message}, got {body}");
2068    }
2069
2070    /// A watermark margin has no ceiling of its own here: the pipeline refuses any margin
2071    /// that leaves the watermark no room, and the server's own bound only decided which
2072    /// of two failure classes the caller saw for the same picture. The opacity keeps its
2073    /// early check, before the watermark image is fetched, and reports the message the
2074    /// core and the other adapters use.
2075    #[test]
2076    fn validate_watermark_payload_leaves_the_margin_to_the_pipeline() {
2077        let payload = WatermarkPayload {
2078            url: Some("https://cdn.example.com/logo.png".to_string()),
2079            position: None,
2080            opacity: None,
2081            margin: Some(10_000),
2082        };
2083
2084        let validated = validate_watermark_payload(Some(&payload))
2085            .expect("a large margin is the pipeline's to refuse")
2086            .expect("a watermark with a url is validated");
2087
2088        assert_eq!(validated.margin, 10_000);
2089    }
2090
2091    #[rstest]
2092    #[case(0)]
2093    #[case(101)]
2094    fn validate_watermark_payload_reports_the_shared_opacity_message(#[case] opacity: u8) {
2095        let payload = WatermarkPayload {
2096            url: Some("https://cdn.example.com/logo.png".to_string()),
2097            position: None,
2098            opacity: Some(opacity),
2099            margin: None,
2100        };
2101
2102        let response = validate_watermark_payload(Some(&payload))
2103            .expect_err("an opacity outside 1 to 100 is refused before the fetch");
2104        let body = String::from_utf8_lossy(&response.body);
2105
2106        assert!(
2107            body.contains("watermark opacity must be between 1 and 100"),
2108            "the message names the option the way every adapter does, got {body}"
2109        );
2110    }
2111}