1use 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
64pub(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
122fn 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 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 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 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 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 #[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 pub preset: Option<String>,
322}
323
324impl TransformOptionsPayload {
325 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 preset: None,
350 }
351 }
352
353 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 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 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 options
461 .validate_without_input()
462 .map_err(transform_error_response)?;
463
464 Ok(options)
465 }
466}
467
468const 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#[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
504pub(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 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
544pub(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
595pub(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
622const CACHED_NONE: u64 = u64::MAX;
628
629pub(super) const DEFAULT_HEALTH_CACHE_TTL_SECS: u64 = 5;
631
632pub(super) const DEFAULT_HYSTERESIS_MARGIN: f64 = 0.05;
640
641#[derive(Clone, Copy)]
643pub(crate) enum ThresholdDirection {
644 HigherIsWorse,
646 LowerIsWorse,
648}
649
650pub(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 pub(super) hysteresis_margin: f64,
663 disk_state: AtomicU8,
665 rss_state: AtomicU8,
667}
668
669impl HealthCache {
670 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 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 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 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 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 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 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#[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#[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 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
815pub(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
832pub(super) fn handle_health_ready(config: &ServerConfig) -> HttpResponse {
836 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 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 if all_ok {
870 HttpResponse::json("200 OK", body)
871 } else {
872 HttpResponse::json("503 Service Unavailable", body)
873 }
874}
875
876fn 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 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 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
984pub(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
1034pub(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
1068fn 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
1178pub(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 #[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
1283pub(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
1327pub(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 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 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#[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 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 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 options.format = Some(artifact.media_type.default_output());
1523 }
1524
1525 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 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 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 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 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 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 #[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 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 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 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 #[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 assert_eq!(
1782 check(&c, &c.rss_state, 960, 1000, HigherIsWorse),
1783 (false, true)
1784 );
1785 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 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 #[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 assert_eq!(
1843 check(&c, &c.disk_state, 1040, 1000, LowerIsWorse),
1844 (false, true)
1845 );
1846 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 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 #[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 #[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 #[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 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 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 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 #[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 #[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}