1mod auth;
2#[cfg(feature = "azure")]
3pub mod azure;
4mod cache;
5mod config;
6#[cfg(feature = "gcs")]
7pub mod gcs;
8mod http_parse;
9mod metrics;
10mod multipart;
11mod negotiate;
12mod remote;
13mod response;
14#[cfg(feature = "s3")]
15pub mod s3;
16
17#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
18pub use config::StorageBackend;
19use config::StorageBackendLabel;
20pub use config::{DEFAULT_BIND_ADDR, DEFAULT_STORAGE_ROOT, LogHandler, ServerConfig};
21
22use auth::{
23 authorize_request, authorize_request_headers, authorize_signed_request,
24 canonical_query_without_signature, extend_transform_query, parse_optional_bool_query,
25 parse_optional_float_query, parse_optional_integer_query, parse_optional_u8_query,
26 parse_query_params, required_query_param, signed_source_query, url_authority,
27 validate_public_query_names,
28};
29use cache::{
30 CacheLookup, TransformCache, compute_cache_key, compute_watermark_identity,
31 try_versioned_cache_lookup,
32};
33use http_parse::{
34 HttpRequest, parse_named, parse_optional_named, read_request_body, read_request_headers,
35 request_has_json_content_type,
36};
37use metrics::{
38 CACHE_HITS_TOTAL, CACHE_MISSES_TOTAL, RouteMetric, record_http_metrics,
39 record_http_request_duration, record_storage_duration, record_transform_duration,
40 record_transform_error, record_watermark_transform, render_metrics_text, status_code,
41 storage_backend_index_from_config, uptime_seconds,
42};
43use multipart::{parse_multipart_boundary, parse_upload_request};
44use negotiate::{
45 CacheHitStatus, ImageResponsePolicy, PublicSourceKind, build_image_etag,
46 build_image_response_headers, if_none_match_matches, negotiate_output_format,
47};
48use remote::{read_remote_watermark_bytes, resolve_source_bytes};
49use response::{
50 HttpResponse, NOT_FOUND_BODY, bad_request_response, service_unavailable_response,
51 transform_error_response, unsupported_media_type_response, write_response,
52};
53
54use crate::{
55 CropRegion, Fit, MediaType, Position, RawArtifact, Rgba8, Rotation, TransformOptions,
56 TransformRequest, WatermarkInput, sniff_artifact, transform_raster, transform_svg,
57};
58use hmac::{Hmac, Mac};
59use serde::Deserialize;
60use serde_json::json;
61use sha2::{Digest, Sha256};
62use std::collections::BTreeMap;
63use std::env;
64use std::io;
65use std::net::{TcpListener, TcpStream};
66use std::str::FromStr;
67use std::sync::Arc;
68use std::sync::atomic::{AtomicU64, Ordering};
69use std::time::{Duration, Instant};
70use url::Url;
71use uuid::Uuid;
72
73pub(crate) fn stderr_write(msg: &str) {
78 use std::io::Write;
79
80 let bytes = msg.as_bytes();
81 let mut buf = Vec::with_capacity(bytes.len() + 1);
82 buf.extend_from_slice(bytes);
83 buf.push(b'\n');
84
85 #[cfg(unix)]
86 {
87 use std::os::fd::FromRawFd;
88 let mut f = unsafe { std::fs::File::from_raw_fd(2) };
90 let _ = f.write_all(&buf);
91 std::mem::forget(f);
93 }
94
95 #[cfg(windows)]
96 {
97 use std::os::windows::io::FromRawHandle;
98
99 unsafe extern "system" {
100 fn GetStdHandle(nStdHandle: u32) -> *mut std::ffi::c_void;
101 }
102
103 const STD_ERROR_HANDLE: u32 = (-12_i32) as u32;
104 let handle = unsafe { GetStdHandle(STD_ERROR_HANDLE) };
107 let mut f = unsafe { std::fs::File::from_raw_handle(handle) };
108 let _ = f.write_all(&buf);
109 std::mem::forget(f);
111 }
112}
113
114const SOCKET_READ_TIMEOUT: Duration = Duration::from_secs(60);
115const SOCKET_WRITE_TIMEOUT: Duration = Duration::from_secs(60);
116const WORKER_THREADS: usize = 8;
118type HmacSha256 = Hmac<Sha256>;
119
120const KEEP_ALIVE_MAX_REQUESTS: usize = 100;
124
125#[derive(Clone, Copy)]
126struct PublicCacheControl {
127 max_age: u32,
128 stale_while_revalidate: u32,
129}
130
131#[derive(Clone, Copy)]
132struct ImageResponseConfig {
133 disable_accept_negotiation: bool,
134 public_cache_control: PublicCacheControl,
135 transform_deadline: Duration,
136}
137
138struct TransformSlot {
144 counter: Arc<AtomicU64>,
145}
146
147impl TransformSlot {
148 fn try_acquire(counter: &Arc<AtomicU64>, limit: u64) -> Option<Self> {
149 let prev = counter.fetch_add(1, Ordering::Relaxed);
150 if prev >= limit {
151 counter.fetch_sub(1, Ordering::Relaxed);
152 None
153 } else {
154 Some(Self {
155 counter: Arc::clone(counter),
156 })
157 }
158 }
159}
160
161impl Drop for TransformSlot {
162 fn drop(&mut self) {
163 self.counter.fetch_sub(1, Ordering::Relaxed);
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum SignedUrlSource {
170 Path {
172 path: String,
174 version: Option<String>,
176 },
177 Url {
179 url: String,
181 version: Option<String>,
183 },
184}
185
186#[derive(Debug, Default)]
231pub struct SignedWatermarkParams {
232 pub url: String,
233 pub position: Option<String>,
234 pub opacity: Option<u8>,
235 pub margin: Option<u32>,
236}
237
238#[allow(clippy::too_many_arguments)]
239pub fn sign_public_url(
240 base_url: &str,
241 source: SignedUrlSource,
242 options: &TransformOptions,
243 key_id: &str,
244 secret: &str,
245 expires: u64,
246 watermark: Option<&SignedWatermarkParams>,
247 preset: Option<&str>,
248) -> Result<String, String> {
249 let base_url = Url::parse(base_url).map_err(|error| format!("base URL is invalid: {error}"))?;
250 match base_url.scheme() {
251 "http" | "https" => {}
252 _ => return Err("base URL must use the http or https scheme".to_string()),
253 }
254
255 let route_path = match source {
256 SignedUrlSource::Path { .. } => "/images/by-path",
257 SignedUrlSource::Url { .. } => "/images/by-url",
258 };
259 let mut endpoint = base_url
260 .join(route_path)
261 .map_err(|error| format!("failed to resolve the public endpoint URL: {error}"))?;
262 let authority = url_authority(&endpoint)?;
263 let mut query = signed_source_query(source);
264 if let Some(name) = preset {
265 query.insert("preset".to_string(), name.to_string());
266 }
267 extend_transform_query(&mut query, options);
268 if let Some(wm) = watermark {
269 query.insert("watermarkUrl".to_string(), wm.url.clone());
270 if let Some(ref pos) = wm.position {
271 query.insert("watermarkPosition".to_string(), pos.clone());
272 }
273 if let Some(opacity) = wm.opacity {
274 query.insert("watermarkOpacity".to_string(), opacity.to_string());
275 }
276 if let Some(margin) = wm.margin {
277 query.insert("watermarkMargin".to_string(), margin.to_string());
278 }
279 }
280 query.insert("keyId".to_string(), key_id.to_string());
281 query.insert("expires".to_string(), expires.to_string());
282
283 let canonical = format!(
284 "GET\n{}\n{}\n{}",
285 authority,
286 endpoint.path(),
287 canonical_query_without_signature(&query)
288 );
289 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
290 .map_err(|error| format!("failed to initialize signed URL HMAC: {error}"))?;
291 mac.update(canonical.as_bytes());
292 query.insert(
293 "signature".to_string(),
294 hex::encode(mac.finalize().into_bytes()),
295 );
296
297 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
298 for (name, value) in query {
299 serializer.append_pair(&name, &value);
300 }
301 endpoint.set_query(Some(&serializer.finish()));
302 Ok(endpoint.into())
303}
304
305pub fn bind_addr() -> String {
310 env::var("TRUSS_BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string())
311}
312
313pub fn serve(listener: TcpListener) -> io::Result<()> {
325 let config = ServerConfig::from_env()?;
326
327 for (ok, name) in storage_health_check(&config) {
330 if !ok {
331 return Err(io::Error::new(
332 io::ErrorKind::ConnectionRefused,
333 format!(
334 "storage connectivity check failed for `{name}` — verify the backend \
335 endpoint, credentials, and container/bucket configuration"
336 ),
337 ));
338 }
339 }
340
341 serve_with_config(listener, config)
342}
343
344pub fn serve_with_config(listener: TcpListener, config: ServerConfig) -> io::Result<()> {
354 let config = Arc::new(config);
355 let (sender, receiver) = std::sync::mpsc::channel::<TcpStream>();
356
357 let receiver = Arc::new(std::sync::Mutex::new(receiver));
363 let pool_size = (config.max_concurrent_transforms as usize).max(WORKER_THREADS);
364 let mut workers = Vec::with_capacity(pool_size);
365 for _ in 0..pool_size {
366 let rx = Arc::clone(&receiver);
367 let cfg = Arc::clone(&config);
368 workers.push(std::thread::spawn(move || {
369 loop {
370 let stream = {
371 let guard = rx.lock().expect("worker lock poisoned");
372 match guard.recv() {
373 Ok(stream) => stream,
374 Err(_) => break,
375 }
376 }; if let Err(err) = handle_stream(stream, &cfg) {
378 cfg.log(&format!("failed to handle connection: {err}"));
379 }
380 }
381 }));
382 }
383
384 for stream in listener.incoming() {
385 match stream {
386 Ok(stream) => {
387 if sender.send(stream).is_err() {
388 break;
389 }
390 }
391 Err(err) => return Err(err),
392 }
393 }
394
395 drop(sender);
396 let deadline = std::time::Instant::now() + Duration::from_secs(30);
397 for worker in workers {
398 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
399 if remaining.is_zero() {
400 eprintln!("shutdown: timed out waiting for worker threads");
401 break;
402 }
403 let worker_done =
407 std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
408 let wd = std::sync::Arc::clone(&worker_done);
409 std::thread::spawn(move || {
410 let _ = worker.join();
411 let (lock, cvar) = &*wd;
412 *lock.lock().expect("shutdown notify lock") = true;
413 cvar.notify_one();
414 });
415 let (lock, cvar) = &*worker_done;
416 let mut done = lock.lock().expect("shutdown wait lock");
417 while !*done {
418 let (guard, timeout) = cvar
419 .wait_timeout(done, remaining)
420 .expect("shutdown condvar wait");
421 done = guard;
422 if timeout.timed_out() {
423 eprintln!("shutdown: timed out waiting for a worker thread");
424 break;
425 }
426 }
427 }
428
429 Ok(())
430}
431
432pub fn serve_once(listener: TcpListener) -> io::Result<()> {
442 let config = ServerConfig::from_env()?;
443 serve_once_with_config(listener, config)
444}
445
446pub fn serve_once_with_config(listener: TcpListener, config: ServerConfig) -> io::Result<()> {
453 let (stream, _) = listener.accept()?;
454 handle_stream(stream, &config)
455}
456
457#[derive(Debug, Deserialize)]
458#[serde(deny_unknown_fields)]
459struct TransformImageRequestPayload {
460 source: TransformSourcePayload,
461 #[serde(default)]
462 options: TransformOptionsPayload,
463 #[serde(default)]
464 watermark: Option<WatermarkPayload>,
465}
466
467#[derive(Debug, Deserialize)]
468#[serde(tag = "kind", rename_all = "lowercase")]
469enum TransformSourcePayload {
470 Path {
471 path: String,
472 version: Option<String>,
473 },
474 Url {
475 url: String,
476 version: Option<String>,
477 },
478 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
479 Storage {
480 bucket: Option<String>,
481 key: String,
482 version: Option<String>,
483 },
484}
485
486impl TransformSourcePayload {
487 fn versioned_source_hash(&self, config: &ServerConfig) -> Option<String> {
496 let (kind, reference, version): (&str, std::borrow::Cow<'_, str>, Option<&str>) = match self
497 {
498 Self::Path { path, version } => ("path", path.as_str().into(), version.as_deref()),
499 Self::Url { url, version } => ("url", url.as_str().into(), version.as_deref()),
500 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
501 Self::Storage {
502 bucket,
503 key,
504 version,
505 } => {
506 let (scheme, effective_bucket) =
507 storage_scheme_and_bucket(bucket.as_deref(), config);
508 let effective_bucket = effective_bucket?;
509 (
510 "storage",
511 format!("{scheme}://{effective_bucket}/{key}").into(),
512 version.as_deref(),
513 )
514 }
515 };
516 let version = version?;
517 let mut id = String::new();
521 id.push_str(kind);
522 id.push('\n');
523 id.push_str(&reference);
524 id.push('\n');
525 id.push_str(version);
526 id.push('\n');
527 id.push_str(config.storage_root.to_string_lossy().as_ref());
528 id.push('\n');
529 id.push_str(if config.allow_insecure_url_sources {
530 "insecure"
531 } else {
532 "strict"
533 });
534 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
535 {
536 id.push('\n');
537 id.push_str(storage_backend_label(config));
538 #[cfg(feature = "s3")]
539 if let Some(ref ctx) = config.s3_context
540 && let Some(ref endpoint) = ctx.endpoint_url
541 {
542 id.push('\n');
543 id.push_str(endpoint);
544 }
545 #[cfg(feature = "gcs")]
546 if let Some(ref ctx) = config.gcs_context
547 && let Some(ref endpoint) = ctx.endpoint_url
548 {
549 id.push('\n');
550 id.push_str(endpoint);
551 }
552 #[cfg(feature = "azure")]
553 if let Some(ref ctx) = config.azure_context {
554 id.push('\n');
555 id.push_str(&ctx.endpoint_url);
556 }
557 }
558 Some(hex::encode(Sha256::digest(id.as_bytes())))
559 }
560
561 fn metrics_backend_label(&self, _config: &ServerConfig) -> Option<StorageBackendLabel> {
565 match self {
566 Self::Path { .. } => Some(StorageBackendLabel::Filesystem),
567 Self::Url { .. } => None,
568 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
569 Self::Storage { .. } => Some(_config.storage_backend_label()),
570 }
571 }
572}
573
574#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
575fn storage_scheme_and_bucket<'a>(
576 explicit_bucket: Option<&'a str>,
577 config: &'a ServerConfig,
578) -> (&'static str, Option<&'a str>) {
579 match config.storage_backend {
580 #[cfg(feature = "s3")]
581 StorageBackend::S3 => {
582 let bucket = explicit_bucket.or(config
583 .s3_context
584 .as_ref()
585 .map(|ctx| ctx.default_bucket.as_str()));
586 ("s3", bucket)
587 }
588 #[cfg(feature = "gcs")]
589 StorageBackend::Gcs => {
590 let bucket = explicit_bucket.or(config
591 .gcs_context
592 .as_ref()
593 .map(|ctx| ctx.default_bucket.as_str()));
594 ("gcs", bucket)
595 }
596 StorageBackend::Filesystem => ("fs", explicit_bucket),
597 #[cfg(feature = "azure")]
598 StorageBackend::Azure => {
599 let bucket = explicit_bucket.or(config
600 .azure_context
601 .as_ref()
602 .map(|ctx| ctx.default_container.as_str()));
603 ("azure", bucket)
604 }
605 }
606}
607
608#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
609fn is_object_storage_backend(config: &ServerConfig) -> bool {
610 match config.storage_backend {
611 StorageBackend::Filesystem => false,
612 #[cfg(feature = "s3")]
613 StorageBackend::S3 => true,
614 #[cfg(feature = "gcs")]
615 StorageBackend::Gcs => true,
616 #[cfg(feature = "azure")]
617 StorageBackend::Azure => true,
618 }
619}
620
621#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
622fn storage_backend_label(config: &ServerConfig) -> &'static str {
623 match config.storage_backend {
624 StorageBackend::Filesystem => "fs-backend",
625 #[cfg(feature = "s3")]
626 StorageBackend::S3 => "s3-backend",
627 #[cfg(feature = "gcs")]
628 StorageBackend::Gcs => "gcs-backend",
629 #[cfg(feature = "azure")]
630 StorageBackend::Azure => "azure-backend",
631 }
632}
633
634#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
635#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
636pub struct TransformOptionsPayload {
637 pub width: Option<u32>,
638 pub height: Option<u32>,
639 pub fit: Option<String>,
640 pub position: Option<String>,
641 pub format: Option<String>,
642 pub quality: Option<u8>,
643 pub background: Option<String>,
644 pub rotate: Option<u16>,
645 pub auto_orient: Option<bool>,
646 pub strip_metadata: Option<bool>,
647 pub preserve_exif: Option<bool>,
648 pub crop: Option<String>,
649 pub blur: Option<f32>,
650 pub sharpen: Option<f32>,
651}
652
653impl TransformOptionsPayload {
654 fn with_overrides(self, overrides: &TransformOptionsPayload) -> Self {
657 Self {
658 width: overrides.width.or(self.width),
659 height: overrides.height.or(self.height),
660 fit: overrides.fit.clone().or(self.fit),
661 position: overrides.position.clone().or(self.position),
662 format: overrides.format.clone().or(self.format),
663 quality: overrides.quality.or(self.quality),
664 background: overrides.background.clone().or(self.background),
665 rotate: overrides.rotate.or(self.rotate),
666 auto_orient: overrides.auto_orient.or(self.auto_orient),
667 strip_metadata: overrides.strip_metadata.or(self.strip_metadata),
668 preserve_exif: overrides.preserve_exif.or(self.preserve_exif),
669 crop: overrides.crop.clone().or(self.crop),
670 blur: overrides.blur.or(self.blur),
671 sharpen: overrides.sharpen.or(self.sharpen),
672 }
673 }
674
675 fn into_options(self) -> Result<TransformOptions, HttpResponse> {
676 let defaults = TransformOptions::default();
677
678 Ok(TransformOptions {
679 width: self.width,
680 height: self.height,
681 fit: parse_optional_named(self.fit.as_deref(), "fit", Fit::from_str)?,
682 position: parse_optional_named(
683 self.position.as_deref(),
684 "position",
685 Position::from_str,
686 )?,
687 format: parse_optional_named(self.format.as_deref(), "format", MediaType::from_str)?,
688 quality: self.quality,
689 background: parse_optional_named(
690 self.background.as_deref(),
691 "background",
692 Rgba8::from_hex,
693 )?,
694 rotate: match self.rotate {
695 Some(value) => parse_named(&value.to_string(), "rotate", Rotation::from_str)?,
696 None => defaults.rotate,
697 },
698 auto_orient: self.auto_orient.unwrap_or(defaults.auto_orient),
699 strip_metadata: self.strip_metadata.unwrap_or(defaults.strip_metadata),
700 preserve_exif: self.preserve_exif.unwrap_or(defaults.preserve_exif),
701 crop: parse_optional_named(self.crop.as_deref(), "crop", CropRegion::from_str)?,
702 blur: self.blur,
703 sharpen: self.sharpen,
704 deadline: defaults.deadline,
705 })
706 }
707}
708
709const REQUEST_DEADLINE_SECS: u64 = 60;
711
712const WATERMARK_DEFAULT_POSITION: Position = Position::BottomRight;
713const WATERMARK_DEFAULT_OPACITY: u8 = 50;
714const WATERMARK_DEFAULT_MARGIN: u32 = 10;
715const WATERMARK_MAX_MARGIN: u32 = 9999;
716
717#[derive(Debug, Default, Deserialize)]
718#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
719struct WatermarkPayload {
720 url: Option<String>,
721 position: Option<String>,
722 opacity: Option<u8>,
723 margin: Option<u32>,
724}
725
726struct ValidatedWatermarkPayload {
728 url: String,
729 position: Position,
730 opacity: u8,
731 margin: u32,
732}
733
734impl ValidatedWatermarkPayload {
735 fn cache_identity(&self) -> String {
736 compute_watermark_identity(
737 &self.url,
738 self.position.as_name(),
739 self.opacity,
740 self.margin,
741 )
742 }
743}
744
745fn validate_watermark_payload(
747 payload: Option<&WatermarkPayload>,
748) -> Result<Option<ValidatedWatermarkPayload>, HttpResponse> {
749 let Some(wm) = payload else {
750 return Ok(None);
751 };
752 let url = wm.url.as_deref().filter(|u| !u.is_empty()).ok_or_else(|| {
753 bad_request_response("watermark.url is required when watermark is present")
754 })?;
755
756 let position = parse_optional_named(
757 wm.position.as_deref(),
758 "watermark.position",
759 Position::from_str,
760 )?
761 .unwrap_or(WATERMARK_DEFAULT_POSITION);
762
763 let opacity = wm.opacity.unwrap_or(WATERMARK_DEFAULT_OPACITY);
764 if opacity == 0 || opacity > 100 {
765 return Err(bad_request_response(
766 "watermark.opacity must be between 1 and 100",
767 ));
768 }
769 let margin = wm.margin.unwrap_or(WATERMARK_DEFAULT_MARGIN);
770 if margin > WATERMARK_MAX_MARGIN {
771 return Err(bad_request_response(
772 "watermark.margin must be at most 9999",
773 ));
774 }
775
776 Ok(Some(ValidatedWatermarkPayload {
777 url: url.to_string(),
778 position,
779 opacity,
780 margin,
781 }))
782}
783
784fn fetch_watermark(
786 validated: ValidatedWatermarkPayload,
787 config: &ServerConfig,
788 deadline: Option<Instant>,
789) -> Result<WatermarkInput, HttpResponse> {
790 let bytes = read_remote_watermark_bytes(&validated.url, config, deadline)?;
791 let artifact = sniff_artifact(RawArtifact::new(bytes, None))
792 .map_err(|error| bad_request_response(&format!("watermark image is invalid: {error}")))?;
793 if !artifact.media_type.is_raster() {
794 return Err(bad_request_response(
795 "watermark image must be a raster format (not SVG)",
796 ));
797 }
798 Ok(WatermarkInput {
799 image: artifact,
800 position: validated.position,
801 opacity: validated.opacity,
802 margin: validated.margin,
803 })
804}
805
806fn resolve_multipart_watermark(
807 bytes: Vec<u8>,
808 position: Option<String>,
809 opacity: Option<u8>,
810 margin: Option<u32>,
811) -> Result<WatermarkInput, HttpResponse> {
812 let artifact = sniff_artifact(RawArtifact::new(bytes, None))
813 .map_err(|error| bad_request_response(&format!("watermark image is invalid: {error}")))?;
814 if !artifact.media_type.is_raster() {
815 return Err(bad_request_response(
816 "watermark image must be a raster format (not SVG)",
817 ));
818 }
819 let position = parse_optional_named(
820 position.as_deref(),
821 "watermark_position",
822 Position::from_str,
823 )?
824 .unwrap_or(WATERMARK_DEFAULT_POSITION);
825 let opacity = opacity.unwrap_or(WATERMARK_DEFAULT_OPACITY);
826 if opacity == 0 || opacity > 100 {
827 return Err(bad_request_response(
828 "watermark_opacity must be between 1 and 100",
829 ));
830 }
831 let margin = margin.unwrap_or(WATERMARK_DEFAULT_MARGIN);
832 if margin > WATERMARK_MAX_MARGIN {
833 return Err(bad_request_response(
834 "watermark_margin must be at most 9999",
835 ));
836 }
837 Ok(WatermarkInput {
838 image: artifact,
839 position,
840 opacity,
841 margin,
842 })
843}
844
845struct AccessLogEntry<'a> {
846 request_id: &'a str,
847 method: &'a str,
848 path: &'a str,
849 route: &'a str,
850 status: &'a str,
851 start: Instant,
852 cache_status: Option<&'a str>,
853 watermark: bool,
854}
855
856fn extract_request_id(headers: &[(String, String)]) -> Option<String> {
860 headers.iter().find_map(|(name, value)| {
861 if name != "x-request-id" || value.is_empty() {
862 return None;
863 }
864 if value
865 .bytes()
866 .any(|b| b == b'\r' || b == b'\n' || b == b'\0')
867 {
868 return None;
869 }
870 Some(value.clone())
871 })
872}
873
874fn extract_cache_status(headers: &[(&'static str, String)]) -> Option<&'static str> {
877 headers
878 .iter()
879 .find_map(|(name, value)| (*name == "Cache-Status").then_some(value.as_str()))
880 .map(|v| if v.contains("hit") { "hit" } else { "miss" })
881}
882
883fn extract_watermark_flag(headers: &mut Vec<(&'static str, String)>) -> bool {
885 let pos = headers
886 .iter()
887 .position(|(name, _)| *name == "X-Truss-Watermark");
888 if let Some(idx) = pos {
889 headers.swap_remove(idx);
890 true
891 } else {
892 false
893 }
894}
895
896fn emit_access_log(config: &ServerConfig, entry: &AccessLogEntry<'_>) {
897 config.log(
898 &json!({
899 "kind": "access_log",
900 "request_id": entry.request_id,
901 "method": entry.method,
902 "path": entry.path,
903 "route": entry.route,
904 "status": entry.status,
905 "latency_ms": entry.start.elapsed().as_millis() as u64,
906 "cache_status": entry.cache_status,
907 "watermark": entry.watermark,
908 })
909 .to_string(),
910 );
911}
912
913fn handle_stream(mut stream: TcpStream, config: &ServerConfig) -> io::Result<()> {
914 if let Err(err) = stream.set_read_timeout(Some(SOCKET_READ_TIMEOUT)) {
916 config.log(&format!("failed to set socket read timeout: {err}"));
917 }
918 if let Err(err) = stream.set_write_timeout(Some(SOCKET_WRITE_TIMEOUT)) {
919 config.log(&format!("failed to set socket write timeout: {err}"));
920 }
921
922 let mut requests_served: usize = 0;
923
924 loop {
925 let partial = match read_request_headers(&mut stream) {
926 Ok(partial) => partial,
927 Err(response) => {
928 if requests_served > 0 {
929 return Ok(());
930 }
931 let _ = write_response(&mut stream, response, true);
932 return Ok(());
933 }
934 };
935
936 let start = Instant::now();
939
940 let request_id =
941 extract_request_id(&partial.headers).unwrap_or_else(|| Uuid::new_v4().to_string());
942
943 let client_wants_close = partial
944 .headers
945 .iter()
946 .any(|(name, value)| name == "connection" && value.eq_ignore_ascii_case("close"));
947
948 let is_head = partial.method == "HEAD";
949
950 let requires_auth = matches!(
951 (partial.method.as_str(), partial.path()),
952 ("POST", "/images:transform") | ("POST", "/images")
953 );
954 if requires_auth
955 && let Err(mut response) = authorize_request_headers(&partial.headers, config)
956 {
957 response.headers.push(("X-Request-Id", request_id.clone()));
958 record_http_metrics(RouteMetric::Unknown, response.status);
959 let sc = status_code(response.status).unwrap_or("unknown");
960 let method_log = partial.method.clone();
961 let path_log = partial.path().to_string();
962 let _ = write_response(&mut stream, response, true);
963 record_http_request_duration(RouteMetric::Unknown, start);
964 emit_access_log(
965 config,
966 &AccessLogEntry {
967 request_id: &request_id,
968 method: &method_log,
969 path: &path_log,
970 route: &path_log,
971 status: sc,
972 start,
973 cache_status: None,
974 watermark: false,
975 },
976 );
977 return Ok(());
978 }
979
980 let method = partial.method.clone();
982 let path = partial.path().to_string();
983
984 let request = match read_request_body(&mut stream, partial) {
985 Ok(request) => request,
986 Err(mut response) => {
987 response.headers.push(("X-Request-Id", request_id.clone()));
988 record_http_metrics(RouteMetric::Unknown, response.status);
989 let sc = status_code(response.status).unwrap_or("unknown");
990 let _ = write_response(&mut stream, response, true);
991 record_http_request_duration(RouteMetric::Unknown, start);
992 emit_access_log(
993 config,
994 &AccessLogEntry {
995 request_id: &request_id,
996 method: &method,
997 path: &path,
998 route: &path,
999 status: sc,
1000 start,
1001 cache_status: None,
1002 watermark: false,
1003 },
1004 );
1005 return Ok(());
1006 }
1007 };
1008 let route = classify_route(&request);
1009 let mut response = route_request(request, config);
1010 record_http_metrics(route, response.status);
1011
1012 response.headers.push(("X-Request-Id", request_id.clone()));
1013
1014 let cache_status = extract_cache_status(&response.headers);
1015 let had_watermark = extract_watermark_flag(&mut response.headers);
1016
1017 let sc = status_code(response.status).unwrap_or("unknown");
1018
1019 if is_head {
1020 response.body = Vec::new();
1021 }
1022
1023 requests_served += 1;
1024 let close_after = client_wants_close || requests_served >= KEEP_ALIVE_MAX_REQUESTS;
1025
1026 write_response(&mut stream, response, close_after)?;
1027 record_http_request_duration(route, start);
1028
1029 emit_access_log(
1030 config,
1031 &AccessLogEntry {
1032 request_id: &request_id,
1033 method: &method,
1034 path: &path,
1035 route: route.as_label(),
1036 status: sc,
1037 start,
1038 cache_status,
1039 watermark: had_watermark,
1040 },
1041 );
1042
1043 if close_after {
1044 return Ok(());
1045 }
1046 }
1047}
1048
1049fn route_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1050 let method = request.method.clone();
1051 let path = request.path().to_string();
1052
1053 match (method.as_str(), path.as_str()) {
1054 ("GET" | "HEAD", "/health") => handle_health(config),
1055 ("GET" | "HEAD", "/health/live") => handle_health_live(),
1056 ("GET" | "HEAD", "/health/ready") => handle_health_ready(config),
1057 ("GET" | "HEAD", "/images/by-path") => handle_public_path_request(request, config),
1058 ("GET" | "HEAD", "/images/by-url") => handle_public_url_request(request, config),
1059 ("POST", "/images:transform") => handle_transform_request(request, config),
1060 ("POST", "/images") => handle_upload_request(request, config),
1061 ("GET" | "HEAD", "/metrics") => handle_metrics_request(request, config),
1062 _ => HttpResponse::problem("404 Not Found", NOT_FOUND_BODY.as_bytes().to_vec()),
1063 }
1064}
1065
1066fn classify_route(request: &HttpRequest) -> RouteMetric {
1067 match (request.method.as_str(), request.path()) {
1068 ("GET" | "HEAD", "/health") => RouteMetric::Health,
1069 ("GET" | "HEAD", "/health/live") => RouteMetric::HealthLive,
1070 ("GET" | "HEAD", "/health/ready") => RouteMetric::HealthReady,
1071 ("GET" | "HEAD", "/images/by-path") => RouteMetric::PublicByPath,
1072 ("GET" | "HEAD", "/images/by-url") => RouteMetric::PublicByUrl,
1073 ("POST", "/images:transform") => RouteMetric::Transform,
1074 ("POST", "/images") => RouteMetric::Upload,
1075 ("GET" | "HEAD", "/metrics") => RouteMetric::Metrics,
1076 _ => RouteMetric::Unknown,
1077 }
1078}
1079
1080fn handle_transform_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1081 let request_deadline = Some(Instant::now() + Duration::from_secs(REQUEST_DEADLINE_SECS));
1082
1083 if let Err(response) = authorize_request(&request, config) {
1084 return response;
1085 }
1086
1087 if !request_has_json_content_type(&request) {
1088 return unsupported_media_type_response("content-type must be application/json");
1089 }
1090
1091 let payload: TransformImageRequestPayload = match serde_json::from_slice(&request.body) {
1092 Ok(payload) => payload,
1093 Err(error) => {
1094 return bad_request_response(&format!("request body must be valid JSON: {error}"));
1095 }
1096 };
1097 let options = match payload.options.into_options() {
1098 Ok(options) => options,
1099 Err(response) => return response,
1100 };
1101
1102 let versioned_hash = payload.source.versioned_source_hash(config);
1103 let validated_wm = match validate_watermark_payload(payload.watermark.as_ref()) {
1104 Ok(wm) => wm,
1105 Err(response) => return response,
1106 };
1107 let watermark_id = validated_wm.as_ref().map(|v| v.cache_identity());
1108
1109 if let Some(response) = try_versioned_cache_lookup(
1110 versioned_hash.as_deref(),
1111 &options,
1112 &request,
1113 ImageResponsePolicy::PrivateTransform,
1114 config,
1115 watermark_id.as_deref(),
1116 ) {
1117 return response;
1118 }
1119
1120 let storage_start = Instant::now();
1121 let backend_label = payload.source.metrics_backend_label(config);
1122 let backend_idx = backend_label.map(|l| storage_backend_index_from_config(&l));
1123 let source_bytes = match resolve_source_bytes(payload.source, config, request_deadline) {
1124 Ok(bytes) => {
1125 if let Some(idx) = backend_idx {
1126 record_storage_duration(idx, storage_start);
1127 }
1128 bytes
1129 }
1130 Err(response) => {
1131 if let Some(idx) = backend_idx {
1132 record_storage_duration(idx, storage_start);
1133 }
1134 return response;
1135 }
1136 };
1137 transform_source_bytes(
1138 source_bytes,
1139 options,
1140 versioned_hash.as_deref(),
1141 &request,
1142 ImageResponsePolicy::PrivateTransform,
1143 config,
1144 WatermarkSource::from_validated(validated_wm),
1145 watermark_id.as_deref(),
1146 request_deadline,
1147 )
1148}
1149
1150fn handle_public_path_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1151 handle_public_get_request(request, config, PublicSourceKind::Path)
1152}
1153
1154fn handle_public_url_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1155 handle_public_get_request(request, config, PublicSourceKind::Url)
1156}
1157
1158fn handle_public_get_request(
1159 request: HttpRequest,
1160 config: &ServerConfig,
1161 source_kind: PublicSourceKind,
1162) -> HttpResponse {
1163 let request_deadline = Some(Instant::now() + Duration::from_secs(REQUEST_DEADLINE_SECS));
1164 let query = match parse_query_params(&request) {
1165 Ok(query) => query,
1166 Err(response) => return response,
1167 };
1168 if let Err(response) = authorize_signed_request(&request, &query, config) {
1169 return response;
1170 }
1171 let (source, options, watermark_payload) =
1172 match parse_public_get_request(&query, source_kind, config) {
1173 Ok(parsed) => parsed,
1174 Err(response) => return response,
1175 };
1176
1177 let validated_wm = match validate_watermark_payload(watermark_payload.as_ref()) {
1178 Ok(wm) => wm,
1179 Err(response) => return response,
1180 };
1181 let watermark_id = validated_wm.as_ref().map(|v| v.cache_identity());
1182
1183 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
1187 let source = if is_object_storage_backend(config) {
1188 match source {
1189 TransformSourcePayload::Path { path, version } => TransformSourcePayload::Storage {
1190 bucket: None,
1191 key: path.trim_start_matches('/').to_string(),
1192 version,
1193 },
1194 other => other,
1195 }
1196 } else {
1197 source
1198 };
1199
1200 let versioned_hash = source.versioned_source_hash(config);
1201 if let Some(response) = try_versioned_cache_lookup(
1202 versioned_hash.as_deref(),
1203 &options,
1204 &request,
1205 ImageResponsePolicy::PublicGet,
1206 config,
1207 watermark_id.as_deref(),
1208 ) {
1209 return response;
1210 }
1211
1212 let storage_start = Instant::now();
1213 let backend_label = source.metrics_backend_label(config);
1214 let backend_idx = backend_label.map(|l| storage_backend_index_from_config(&l));
1215 let source_bytes = match resolve_source_bytes(source, config, request_deadline) {
1216 Ok(bytes) => {
1217 if let Some(idx) = backend_idx {
1218 record_storage_duration(idx, storage_start);
1219 }
1220 bytes
1221 }
1222 Err(response) => {
1223 if let Some(idx) = backend_idx {
1224 record_storage_duration(idx, storage_start);
1225 }
1226 return response;
1227 }
1228 };
1229
1230 transform_source_bytes(
1231 source_bytes,
1232 options,
1233 versioned_hash.as_deref(),
1234 &request,
1235 ImageResponsePolicy::PublicGet,
1236 config,
1237 WatermarkSource::from_validated(validated_wm),
1238 watermark_id.as_deref(),
1239 request_deadline,
1240 )
1241}
1242
1243fn handle_upload_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1244 if let Err(response) = authorize_request(&request, config) {
1245 return response;
1246 }
1247
1248 let boundary = match parse_multipart_boundary(&request) {
1249 Ok(boundary) => boundary,
1250 Err(response) => return response,
1251 };
1252 let (file_bytes, options, watermark) = match parse_upload_request(&request.body, &boundary) {
1253 Ok(parts) => parts,
1254 Err(response) => return response,
1255 };
1256 let watermark_identity = watermark.as_ref().map(|wm| {
1257 let content_hash = hex::encode(sha2::Sha256::digest(&wm.image.bytes));
1258 cache::compute_watermark_content_identity(
1259 &content_hash,
1260 wm.position.as_name(),
1261 wm.opacity,
1262 wm.margin,
1263 )
1264 });
1265 transform_source_bytes(
1266 file_bytes,
1267 options,
1268 None,
1269 &request,
1270 ImageResponsePolicy::PrivateTransform,
1271 config,
1272 WatermarkSource::from_ready(watermark),
1273 watermark_identity.as_deref(),
1274 None,
1275 )
1276}
1277
1278fn handle_health_live() -> HttpResponse {
1280 let body = serde_json::to_vec(&json!({
1281 "status": "ok",
1282 "service": "truss",
1283 "version": env!("CARGO_PKG_VERSION"),
1284 }))
1285 .expect("serialize liveness");
1286 let mut body = body;
1287 body.push(b'\n');
1288 HttpResponse::json("200 OK", body)
1289}
1290
1291fn handle_health_ready(config: &ServerConfig) -> HttpResponse {
1296 let mut checks: Vec<serde_json::Value> = Vec::new();
1297 let mut all_ok = true;
1298
1299 for (ok, name) in storage_health_check(config) {
1300 checks.push(json!({
1301 "name": name,
1302 "status": if ok { "ok" } else { "fail" },
1303 }));
1304 if !ok {
1305 all_ok = false;
1306 }
1307 }
1308
1309 if let Some(cache_root) = &config.cache_root {
1310 let cache_ok = cache_root.is_dir();
1311 checks.push(json!({
1312 "name": "cacheRoot",
1313 "status": if cache_ok { "ok" } else { "fail" },
1314 }));
1315 if !cache_ok {
1316 all_ok = false;
1317 }
1318 }
1319
1320 let status_str = if all_ok { "ok" } else { "fail" };
1321 let mut body = serde_json::to_vec(&json!({
1322 "status": status_str,
1323 "checks": checks,
1324 }))
1325 .expect("serialize readiness");
1326 body.push(b'\n');
1327
1328 if all_ok {
1329 HttpResponse::json("200 OK", body)
1330 } else {
1331 HttpResponse::json("503 Service Unavailable", body)
1332 }
1333}
1334
1335fn storage_health_check(config: &ServerConfig) -> Vec<(bool, &'static str)> {
1337 #[allow(unused_mut)]
1338 let mut checks = vec![(config.storage_root.is_dir(), "storageRoot")];
1339 #[cfg(feature = "s3")]
1340 if config.storage_backend == StorageBackend::S3 {
1341 let reachable = config
1342 .s3_context
1343 .as_ref()
1344 .is_some_and(|ctx| ctx.check_reachable());
1345 checks.push((reachable, "storageBackend"));
1346 }
1347 #[cfg(feature = "gcs")]
1348 if config.storage_backend == StorageBackend::Gcs {
1349 let reachable = config
1350 .gcs_context
1351 .as_ref()
1352 .is_some_and(|ctx| ctx.check_reachable());
1353 checks.push((reachable, "storageBackend"));
1354 }
1355 #[cfg(feature = "azure")]
1356 if config.storage_backend == StorageBackend::Azure {
1357 let reachable = config
1358 .azure_context
1359 .as_ref()
1360 .is_some_and(|ctx| ctx.check_reachable());
1361 checks.push((reachable, "storageBackend"));
1362 }
1363 checks
1364}
1365
1366fn handle_health(config: &ServerConfig) -> HttpResponse {
1367 let mut checks: Vec<serde_json::Value> = Vec::new();
1368 let mut all_ok = true;
1369
1370 for (ok, name) in storage_health_check(config) {
1371 checks.push(json!({
1372 "name": name,
1373 "status": if ok { "ok" } else { "fail" },
1374 }));
1375 if !ok {
1376 all_ok = false;
1377 }
1378 }
1379
1380 if let Some(cache_root) = &config.cache_root {
1381 let cache_ok = cache_root.is_dir();
1382 checks.push(json!({
1383 "name": "cacheRoot",
1384 "status": if cache_ok { "ok" } else { "fail" },
1385 }));
1386 if !cache_ok {
1387 all_ok = false;
1388 }
1389 }
1390
1391 let in_flight = config.transforms_in_flight.load(Ordering::Relaxed);
1392 let overloaded = in_flight >= config.max_concurrent_transforms;
1393 checks.push(json!({
1394 "name": "transformCapacity",
1395 "status": if overloaded { "fail" } else { "ok" },
1396 }));
1397 if overloaded {
1398 all_ok = false;
1399 }
1400
1401 let status_str = if all_ok { "ok" } else { "fail" };
1402 let mut body = serde_json::to_vec(&json!({
1403 "status": status_str,
1404 "service": "truss",
1405 "version": env!("CARGO_PKG_VERSION"),
1406 "uptimeSeconds": uptime_seconds(),
1407 "checks": checks,
1408 }))
1409 .expect("serialize health");
1410 body.push(b'\n');
1411
1412 HttpResponse::json("200 OK", body)
1413}
1414
1415fn handle_metrics_request(_request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1416 HttpResponse::text(
1417 "200 OK",
1418 "text/plain; version=0.0.4; charset=utf-8",
1419 render_metrics_text(
1420 config.max_concurrent_transforms,
1421 &config.transforms_in_flight,
1422 )
1423 .into_bytes(),
1424 )
1425}
1426
1427fn parse_public_get_request(
1428 query: &BTreeMap<String, String>,
1429 source_kind: PublicSourceKind,
1430 config: &ServerConfig,
1431) -> Result<
1432 (
1433 TransformSourcePayload,
1434 TransformOptions,
1435 Option<WatermarkPayload>,
1436 ),
1437 HttpResponse,
1438> {
1439 validate_public_query_names(query, source_kind)?;
1440
1441 let source = match source_kind {
1442 PublicSourceKind::Path => TransformSourcePayload::Path {
1443 path: required_query_param(query, "path")?.to_string(),
1444 version: query.get("version").cloned(),
1445 },
1446 PublicSourceKind::Url => TransformSourcePayload::Url {
1447 url: required_query_param(query, "url")?.to_string(),
1448 version: query.get("version").cloned(),
1449 },
1450 };
1451
1452 let has_orphaned_watermark_params = query.contains_key("watermarkPosition")
1453 || query.contains_key("watermarkOpacity")
1454 || query.contains_key("watermarkMargin");
1455 let watermark = if query.contains_key("watermarkUrl") {
1456 Some(WatermarkPayload {
1457 url: query.get("watermarkUrl").cloned(),
1458 position: query.get("watermarkPosition").cloned(),
1459 opacity: parse_optional_u8_query(query, "watermarkOpacity")?,
1460 margin: parse_optional_integer_query(query, "watermarkMargin")?,
1461 })
1462 } else if has_orphaned_watermark_params {
1463 return Err(bad_request_response(
1464 "watermarkPosition, watermarkOpacity, and watermarkMargin require watermarkUrl",
1465 ));
1466 } else {
1467 None
1468 };
1469
1470 let per_request = TransformOptionsPayload {
1472 width: parse_optional_integer_query(query, "width")?,
1473 height: parse_optional_integer_query(query, "height")?,
1474 fit: query.get("fit").cloned(),
1475 position: query.get("position").cloned(),
1476 format: query.get("format").cloned(),
1477 quality: parse_optional_u8_query(query, "quality")?,
1478 background: query.get("background").cloned(),
1479 rotate: query
1480 .get("rotate")
1481 .map(|v| v.parse::<u16>())
1482 .transpose()
1483 .map_err(|_| bad_request_response("rotate must be 0, 90, 180, or 270"))?,
1484 auto_orient: parse_optional_bool_query(query, "autoOrient")?,
1485 strip_metadata: parse_optional_bool_query(query, "stripMetadata")?,
1486 preserve_exif: parse_optional_bool_query(query, "preserveExif")?,
1487 crop: query.get("crop").cloned(),
1488 blur: parse_optional_float_query(query, "blur")?,
1489 sharpen: parse_optional_float_query(query, "sharpen")?,
1490 };
1491
1492 let merged = if let Some(preset_name) = query.get("preset") {
1494 let preset = config
1495 .presets
1496 .get(preset_name)
1497 .ok_or_else(|| bad_request_response(&format!("unknown preset `{preset_name}`")))?;
1498 preset.clone().with_overrides(&per_request)
1499 } else {
1500 per_request
1501 };
1502
1503 let options = merged.into_options()?;
1504
1505 Ok((source, options, watermark))
1506}
1507
1508enum WatermarkSource {
1510 Deferred(ValidatedWatermarkPayload),
1511 Ready(WatermarkInput),
1512 None,
1513}
1514
1515impl WatermarkSource {
1516 fn from_validated(validated: Option<ValidatedWatermarkPayload>) -> Self {
1517 match validated {
1518 Some(v) => Self::Deferred(v),
1519 None => Self::None,
1520 }
1521 }
1522
1523 fn from_ready(input: Option<WatermarkInput>) -> Self {
1524 match input {
1525 Some(w) => Self::Ready(w),
1526 None => Self::None,
1527 }
1528 }
1529
1530 fn is_some(&self) -> bool {
1531 !matches!(self, Self::None)
1532 }
1533}
1534
1535#[allow(clippy::too_many_arguments)]
1536fn transform_source_bytes(
1537 source_bytes: Vec<u8>,
1538 options: TransformOptions,
1539 versioned_hash: Option<&str>,
1540 request: &HttpRequest,
1541 response_policy: ImageResponsePolicy,
1542 config: &ServerConfig,
1543 watermark: WatermarkSource,
1544 watermark_identity: Option<&str>,
1545 request_deadline: Option<Instant>,
1546) -> HttpResponse {
1547 let content_hash;
1548 let source_hash = match versioned_hash {
1549 Some(hash) => hash,
1550 None => {
1551 content_hash = hex::encode(Sha256::digest(&source_bytes));
1552 &content_hash
1553 }
1554 };
1555
1556 let cache = config
1557 .cache_root
1558 .as_ref()
1559 .map(|root| TransformCache::new(root.clone()).with_log_handler(config.log_handler.clone()));
1560
1561 if let Some(ref cache) = cache
1562 && options.format.is_some()
1563 {
1564 let cache_key = compute_cache_key(source_hash, &options, None, watermark_identity);
1565 if let CacheLookup::Hit {
1566 media_type,
1567 body,
1568 age,
1569 } = cache.get(&cache_key)
1570 {
1571 CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
1572 let etag = build_image_etag(&body);
1573 let mut headers = build_image_response_headers(
1574 media_type,
1575 &etag,
1576 response_policy,
1577 false,
1578 CacheHitStatus::Hit,
1579 config.public_max_age_seconds,
1580 config.public_stale_while_revalidate_seconds,
1581 );
1582 headers.push(("Age", age.as_secs().to_string()));
1583 if matches!(response_policy, ImageResponsePolicy::PublicGet)
1584 && if_none_match_matches(request.header("if-none-match"), &etag)
1585 {
1586 return HttpResponse::empty("304 Not Modified", headers);
1587 }
1588 return HttpResponse::binary_with_headers(
1589 "200 OK",
1590 media_type.as_mime(),
1591 headers,
1592 body,
1593 );
1594 }
1595 }
1596
1597 let _slot = match TransformSlot::try_acquire(
1598 &config.transforms_in_flight,
1599 config.max_concurrent_transforms,
1600 ) {
1601 Some(slot) => slot,
1602 None => return service_unavailable_response("too many concurrent transforms; retry later"),
1603 };
1604 transform_source_bytes_inner(
1605 source_bytes,
1606 options,
1607 request,
1608 response_policy,
1609 cache.as_ref(),
1610 source_hash,
1611 ImageResponseConfig {
1612 disable_accept_negotiation: config.disable_accept_negotiation,
1613 public_cache_control: PublicCacheControl {
1614 max_age: config.public_max_age_seconds,
1615 stale_while_revalidate: config.public_stale_while_revalidate_seconds,
1616 },
1617 transform_deadline: Duration::from_secs(config.transform_deadline_secs),
1618 },
1619 watermark,
1620 watermark_identity,
1621 config,
1622 request_deadline,
1623 )
1624}
1625
1626#[allow(clippy::too_many_arguments)]
1627fn transform_source_bytes_inner(
1628 source_bytes: Vec<u8>,
1629 mut options: TransformOptions,
1630 request: &HttpRequest,
1631 response_policy: ImageResponsePolicy,
1632 cache: Option<&TransformCache>,
1633 source_hash: &str,
1634 response_config: ImageResponseConfig,
1635 watermark_source: WatermarkSource,
1636 watermark_identity: Option<&str>,
1637 config: &ServerConfig,
1638 request_deadline: Option<Instant>,
1639) -> HttpResponse {
1640 if options.deadline.is_none() {
1641 options.deadline = Some(response_config.transform_deadline);
1642 }
1643 let artifact = match sniff_artifact(RawArtifact::new(source_bytes, None)) {
1644 Ok(artifact) => artifact,
1645 Err(error) => {
1646 record_transform_error(&error);
1647 return transform_error_response(error);
1648 }
1649 };
1650 let negotiation_used =
1651 if options.format.is_none() && !response_config.disable_accept_negotiation {
1652 match negotiate_output_format(request.header("accept"), &artifact) {
1653 Ok(Some(format)) => {
1654 options.format = Some(format);
1655 true
1656 }
1657 Ok(None) => false,
1658 Err(response) => return response,
1659 }
1660 } else {
1661 false
1662 };
1663
1664 let negotiated_accept = if negotiation_used {
1665 request.header("accept")
1666 } else {
1667 None
1668 };
1669 let cache_key = compute_cache_key(source_hash, &options, negotiated_accept, watermark_identity);
1670
1671 if let Some(cache) = cache
1672 && let CacheLookup::Hit {
1673 media_type,
1674 body,
1675 age,
1676 } = cache.get(&cache_key)
1677 {
1678 CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
1679 let etag = build_image_etag(&body);
1680 let mut headers = build_image_response_headers(
1681 media_type,
1682 &etag,
1683 response_policy,
1684 negotiation_used,
1685 CacheHitStatus::Hit,
1686 response_config.public_cache_control.max_age,
1687 response_config.public_cache_control.stale_while_revalidate,
1688 );
1689 headers.push(("Age", age.as_secs().to_string()));
1690 if matches!(response_policy, ImageResponsePolicy::PublicGet)
1691 && if_none_match_matches(request.header("if-none-match"), &etag)
1692 {
1693 return HttpResponse::empty("304 Not Modified", headers);
1694 }
1695 return HttpResponse::binary_with_headers("200 OK", media_type.as_mime(), headers, body);
1696 }
1697
1698 if cache.is_some() {
1699 CACHE_MISSES_TOTAL.fetch_add(1, Ordering::Relaxed);
1700 }
1701
1702 let is_svg = artifact.media_type == MediaType::Svg;
1703
1704 let watermark = if is_svg && watermark_source.is_some() {
1706 return bad_request_response("watermark is not supported for SVG source images");
1707 } else {
1708 match watermark_source {
1709 WatermarkSource::Deferred(validated) => {
1710 match fetch_watermark(validated, config, request_deadline) {
1711 Ok(wm) => {
1712 record_watermark_transform();
1713 Some(wm)
1714 }
1715 Err(response) => return response,
1716 }
1717 }
1718 WatermarkSource::Ready(wm) => {
1719 record_watermark_transform();
1720 Some(wm)
1721 }
1722 WatermarkSource::None => None,
1723 }
1724 };
1725
1726 let had_watermark = watermark.is_some();
1727
1728 let transform_start = Instant::now();
1729 let mut request_obj = TransformRequest::new(artifact, options);
1730 request_obj.watermark = watermark;
1731 let result = if is_svg {
1732 match transform_svg(request_obj) {
1733 Ok(result) => result,
1734 Err(error) => {
1735 record_transform_error(&error);
1736 return transform_error_response(error);
1737 }
1738 }
1739 } else {
1740 match transform_raster(request_obj) {
1741 Ok(result) => result,
1742 Err(error) => {
1743 record_transform_error(&error);
1744 return transform_error_response(error);
1745 }
1746 }
1747 };
1748 record_transform_duration(result.artifact.media_type, transform_start);
1749
1750 for warning in &result.warnings {
1751 let msg = format!("truss: {warning}");
1752 if let Some(c) = cache
1753 && let Some(handler) = &c.log_handler
1754 {
1755 handler(&msg);
1756 } else {
1757 stderr_write(&msg);
1758 }
1759 }
1760
1761 let output = result.artifact;
1762
1763 if let Some(cache) = cache {
1764 cache.put(&cache_key, output.media_type, &output.bytes);
1765 }
1766
1767 let cache_hit_status = if cache.is_some() {
1768 CacheHitStatus::Miss
1769 } else {
1770 CacheHitStatus::Disabled
1771 };
1772
1773 let etag = build_image_etag(&output.bytes);
1774 let headers = build_image_response_headers(
1775 output.media_type,
1776 &etag,
1777 response_policy,
1778 negotiation_used,
1779 cache_hit_status,
1780 response_config.public_cache_control.max_age,
1781 response_config.public_cache_control.stale_while_revalidate,
1782 );
1783
1784 if matches!(response_policy, ImageResponsePolicy::PublicGet)
1785 && if_none_match_matches(request.header("if-none-match"), &etag)
1786 {
1787 return HttpResponse::empty("304 Not Modified", headers);
1788 }
1789
1790 let mut response = HttpResponse::binary_with_headers(
1791 "200 OK",
1792 output.media_type.as_mime(),
1793 headers,
1794 output.bytes,
1795 );
1796 if had_watermark {
1797 response
1798 .headers
1799 .push(("X-Truss-Watermark", "true".to_string()));
1800 }
1801 response
1802}
1803
1804#[cfg(test)]
1805mod tests {
1806 use serial_test::serial;
1807
1808 use super::config::{
1809 DEFAULT_PUBLIC_MAX_AGE_SECONDS, DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
1810 parse_presets_from_env,
1811 };
1812 use super::http_parse::{
1813 HttpRequest, find_header_terminator, read_request_body, read_request_headers,
1814 resolve_storage_path,
1815 };
1816 use super::metrics::DEFAULT_MAX_CONCURRENT_TRANSFORMS;
1817 use super::multipart::parse_multipart_form_data;
1818 use super::remote::{PinnedResolver, prepare_remote_fetch_target};
1819 use super::response::auth_required_response;
1820 use super::response::{HttpResponse, bad_request_response};
1821 use super::{
1822 CacheHitStatus, DEFAULT_BIND_ADDR, ImageResponsePolicy, PublicSourceKind, ServerConfig,
1823 SignedUrlSource, TransformOptionsPayload, TransformSourcePayload, WatermarkSource,
1824 authorize_signed_request, bind_addr, build_image_etag, build_image_response_headers,
1825 canonical_query_without_signature, negotiate_output_format, parse_public_get_request,
1826 route_request, serve_once_with_config, sign_public_url, transform_source_bytes,
1827 };
1828 use crate::{
1829 Artifact, ArtifactMetadata, Fit, MediaType, RawArtifact, TransformOptions, sniff_artifact,
1830 };
1831 use hmac::{Hmac, Mac};
1832 use image::codecs::png::PngEncoder;
1833 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
1834 use sha2::Sha256;
1835 use std::collections::{BTreeMap, HashMap};
1836 use std::env;
1837 use std::fs;
1838 use std::io::{Cursor, Read, Write};
1839 use std::net::{SocketAddr, TcpListener, TcpStream};
1840 use std::path::{Path, PathBuf};
1841 use std::sync::atomic::Ordering;
1842 use std::thread;
1843 use std::time::{Duration, SystemTime, UNIX_EPOCH};
1844
1845 fn read_request<R: Read>(stream: &mut R) -> Result<HttpRequest, HttpResponse> {
1848 let partial = read_request_headers(stream)?;
1849 read_request_body(stream, partial)
1850 }
1851
1852 fn png_bytes() -> Vec<u8> {
1853 let image = RgbaImage::from_pixel(4, 3, Rgba([10, 20, 30, 255]));
1854 let mut bytes = Vec::new();
1855 PngEncoder::new(&mut bytes)
1856 .write_image(&image, 4, 3, ColorType::Rgba8.into())
1857 .expect("encode png");
1858 bytes
1859 }
1860
1861 fn temp_dir(name: &str) -> PathBuf {
1862 let unique = SystemTime::now()
1863 .duration_since(UNIX_EPOCH)
1864 .expect("current time")
1865 .as_nanos();
1866 let path = std::env::temp_dir().join(format!("truss-server-{name}-{unique}"));
1867 fs::create_dir_all(&path).expect("create temp dir");
1868 path
1869 }
1870
1871 fn write_png(path: &Path) {
1872 fs::write(path, png_bytes()).expect("write png fixture");
1873 }
1874
1875 fn artifact_with_alpha(has_alpha: bool) -> Artifact {
1876 Artifact::new(
1877 png_bytes(),
1878 MediaType::Png,
1879 ArtifactMetadata {
1880 width: Some(4),
1881 height: Some(3),
1882 frame_count: 1,
1883 duration: None,
1884 has_alpha: Some(has_alpha),
1885 },
1886 )
1887 }
1888
1889 fn sign_public_query(
1890 method: &str,
1891 authority: &str,
1892 path: &str,
1893 query: &BTreeMap<String, String>,
1894 secret: &str,
1895 ) -> String {
1896 let canonical = format!(
1897 "{method}\n{authority}\n{path}\n{}",
1898 canonical_query_without_signature(query)
1899 );
1900 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("create hmac");
1901 mac.update(canonical.as_bytes());
1902 hex::encode(mac.finalize().into_bytes())
1903 }
1904
1905 type FixtureResponse = (String, Vec<(String, String)>, Vec<u8>);
1906
1907 fn read_fixture_request(stream: &mut TcpStream) {
1908 stream
1909 .set_nonblocking(false)
1910 .expect("configure fixture stream blocking mode");
1911 stream
1912 .set_read_timeout(Some(Duration::from_millis(100)))
1913 .expect("configure fixture stream timeout");
1914
1915 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1916 let mut buffer = Vec::new();
1917 let mut chunk = [0_u8; 1024];
1918 let header_end = loop {
1919 let read = match stream.read(&mut chunk) {
1920 Ok(read) => read,
1921 Err(error)
1922 if matches!(
1923 error.kind(),
1924 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
1925 ) && std::time::Instant::now() < deadline =>
1926 {
1927 thread::sleep(Duration::from_millis(10));
1928 continue;
1929 }
1930 Err(error) => panic!("read fixture request headers: {error}"),
1931 };
1932 if read == 0 {
1933 panic!("fixture request ended before headers were complete");
1934 }
1935 buffer.extend_from_slice(&chunk[..read]);
1936 if let Some(index) = find_header_terminator(&buffer) {
1937 break index;
1938 }
1939 };
1940
1941 let header_text = std::str::from_utf8(&buffer[..header_end]).expect("fixture request utf8");
1942 let content_length = header_text
1943 .split("\r\n")
1944 .filter_map(|line| line.split_once(':'))
1945 .find_map(|(name, value)| {
1946 name.trim()
1947 .eq_ignore_ascii_case("content-length")
1948 .then_some(value.trim())
1949 })
1950 .map(|value| {
1951 value
1952 .parse::<usize>()
1953 .expect("fixture content-length should be numeric")
1954 })
1955 .unwrap_or(0);
1956
1957 let mut body = buffer.len().saturating_sub(header_end + 4);
1958 while body < content_length {
1959 let read = match stream.read(&mut chunk) {
1960 Ok(read) => read,
1961 Err(error)
1962 if matches!(
1963 error.kind(),
1964 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
1965 ) && std::time::Instant::now() < deadline =>
1966 {
1967 thread::sleep(Duration::from_millis(10));
1968 continue;
1969 }
1970 Err(error) => panic!("read fixture request body: {error}"),
1971 };
1972 if read == 0 {
1973 panic!("fixture request body was truncated");
1974 }
1975 body += read;
1976 }
1977 }
1978
1979 fn spawn_http_server(responses: Vec<FixtureResponse>) -> (String, thread::JoinHandle<()>) {
1980 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture server");
1981 listener
1982 .set_nonblocking(true)
1983 .expect("configure fixture server");
1984 let addr = listener.local_addr().expect("fixture server addr");
1985 let url = format!("http://{addr}/image");
1986
1987 let handle = thread::spawn(move || {
1988 for (status, headers, body) in responses {
1989 let deadline = std::time::Instant::now() + Duration::from_secs(10);
1990 let mut accepted = None;
1991 while std::time::Instant::now() < deadline {
1992 match listener.accept() {
1993 Ok(stream) => {
1994 accepted = Some(stream);
1995 break;
1996 }
1997 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1998 thread::sleep(Duration::from_millis(10));
1999 }
2000 Err(error) => panic!("accept fixture request: {error}"),
2001 }
2002 }
2003
2004 let Some((mut stream, _)) = accepted else {
2005 break;
2006 };
2007 read_fixture_request(&mut stream);
2008 let mut header = format!(
2009 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
2010 body.len()
2011 );
2012 for (name, value) in headers {
2013 header.push_str(&format!("{name}: {value}\r\n"));
2014 }
2015 header.push_str("\r\n");
2016 stream
2017 .write_all(header.as_bytes())
2018 .expect("write fixture headers");
2019 stream.write_all(&body).expect("write fixture body");
2020 stream.flush().expect("flush fixture response");
2021 }
2022 });
2023
2024 (url, handle)
2025 }
2026
2027 fn transform_request(path: &str) -> HttpRequest {
2028 HttpRequest {
2029 method: "POST".to_string(),
2030 target: "/images:transform".to_string(),
2031 version: "HTTP/1.1".to_string(),
2032 headers: vec![
2033 ("authorization".to_string(), "Bearer secret".to_string()),
2034 ("content-type".to_string(), "application/json".to_string()),
2035 ],
2036 body: format!(
2037 "{{\"source\":{{\"kind\":\"path\",\"path\":\"{path}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
2038 )
2039 .into_bytes(),
2040 }
2041 }
2042
2043 fn transform_url_request(url: &str) -> HttpRequest {
2044 HttpRequest {
2045 method: "POST".to_string(),
2046 target: "/images:transform".to_string(),
2047 version: "HTTP/1.1".to_string(),
2048 headers: vec![
2049 ("authorization".to_string(), "Bearer secret".to_string()),
2050 ("content-type".to_string(), "application/json".to_string()),
2051 ],
2052 body: format!(
2053 "{{\"source\":{{\"kind\":\"url\",\"url\":\"{url}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
2054 )
2055 .into_bytes(),
2056 }
2057 }
2058
2059 fn upload_request(file_bytes: &[u8], options_json: Option<&str>) -> HttpRequest {
2060 let boundary = "truss-test-boundary";
2061 let mut body = Vec::new();
2062 body.extend_from_slice(
2063 format!(
2064 "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"image.png\"\r\nContent-Type: image/png\r\n\r\n"
2065 )
2066 .as_bytes(),
2067 );
2068 body.extend_from_slice(file_bytes);
2069 body.extend_from_slice(b"\r\n");
2070
2071 if let Some(options_json) = options_json {
2072 body.extend_from_slice(
2073 format!(
2074 "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{options_json}\r\n"
2075 )
2076 .as_bytes(),
2077 );
2078 }
2079
2080 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
2081
2082 HttpRequest {
2083 method: "POST".to_string(),
2084 target: "/images".to_string(),
2085 version: "HTTP/1.1".to_string(),
2086 headers: vec![
2087 ("authorization".to_string(), "Bearer secret".to_string()),
2088 (
2089 "content-type".to_string(),
2090 format!("multipart/form-data; boundary={boundary}"),
2091 ),
2092 ],
2093 body,
2094 }
2095 }
2096
2097 fn metrics_request(with_auth: bool) -> HttpRequest {
2098 let mut headers = Vec::new();
2099 if with_auth {
2100 headers.push(("authorization".to_string(), "Bearer secret".to_string()));
2101 }
2102
2103 HttpRequest {
2104 method: "GET".to_string(),
2105 target: "/metrics".to_string(),
2106 version: "HTTP/1.1".to_string(),
2107 headers,
2108 body: Vec::new(),
2109 }
2110 }
2111
2112 fn response_body(response: &HttpResponse) -> &str {
2113 std::str::from_utf8(&response.body).expect("utf8 response body")
2114 }
2115
2116 fn signed_public_request(target: &str, host: &str, secret: &str) -> HttpRequest {
2117 let (path, query) = target.split_once('?').expect("target has query");
2118 let mut query = url::form_urlencoded::parse(query.as_bytes())
2119 .into_owned()
2120 .collect::<BTreeMap<_, _>>();
2121 let signature = sign_public_query("GET", host, path, &query, secret);
2122 query.insert("signature".to_string(), signature);
2123 let final_query = url::form_urlencoded::Serializer::new(String::new())
2124 .extend_pairs(
2125 query
2126 .iter()
2127 .map(|(name, value)| (name.as_str(), value.as_str())),
2128 )
2129 .finish();
2130
2131 HttpRequest {
2132 method: "GET".to_string(),
2133 target: format!("{path}?{final_query}"),
2134 version: "HTTP/1.1".to_string(),
2135 headers: vec![("host".to_string(), host.to_string())],
2136 body: Vec::new(),
2137 }
2138 }
2139
2140 #[test]
2141 fn uses_default_bind_addr_when_env_is_missing() {
2142 unsafe { std::env::remove_var("TRUSS_BIND_ADDR") };
2143 assert_eq!(bind_addr(), DEFAULT_BIND_ADDR);
2144 }
2145
2146 #[test]
2147 fn authorize_signed_request_accepts_a_valid_signature() {
2148 let request = signed_public_request(
2149 "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
2150 "assets.example.com",
2151 "secret-value",
2152 );
2153 let query = super::auth::parse_query_params(&request).expect("parse query");
2154 let config = ServerConfig::new(temp_dir("public-auth"), None)
2155 .with_signed_url_credentials("public-dev", "secret-value");
2156
2157 authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
2158 }
2159
2160 #[test]
2161 fn authorize_signed_request_uses_public_base_url_authority() {
2162 let request = signed_public_request(
2163 "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
2164 "cdn.example.com",
2165 "secret-value",
2166 );
2167 let query = super::auth::parse_query_params(&request).expect("parse query");
2168 let mut config = ServerConfig::new(temp_dir("public-authority"), None)
2169 .with_signed_url_credentials("public-dev", "secret-value");
2170 config.public_base_url = Some("https://cdn.example.com".to_string());
2171
2172 authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
2173 }
2174
2175 #[test]
2176 fn negotiate_output_format_prefers_alpha_safe_formats_for_transparent_inputs() {
2177 let format =
2178 negotiate_output_format(Some("image/jpeg,image/png"), &artifact_with_alpha(true))
2179 .expect("negotiate output format")
2180 .expect("resolved output format");
2181
2182 assert_eq!(format, MediaType::Png);
2183 }
2184
2185 #[test]
2186 fn negotiate_output_format_prefers_avif_for_wildcard_accept() {
2187 let format = negotiate_output_format(Some("image/*"), &artifact_with_alpha(false))
2188 .expect("negotiate output format")
2189 .expect("resolved output format");
2190
2191 assert_eq!(format, MediaType::Avif);
2192 }
2193
2194 #[test]
2195 fn build_image_response_headers_include_cache_and_safety_metadata() {
2196 let headers = build_image_response_headers(
2197 MediaType::Webp,
2198 &build_image_etag(b"demo"),
2199 ImageResponsePolicy::PublicGet,
2200 true,
2201 CacheHitStatus::Disabled,
2202 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2203 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2204 );
2205
2206 assert!(headers.contains(&(
2207 "Cache-Control",
2208 "public, max-age=3600, stale-while-revalidate=60".to_string()
2209 )));
2210 assert!(headers.contains(&("Vary", "Accept".to_string())));
2211 assert!(headers.contains(&("X-Content-Type-Options", "nosniff".to_string())));
2212 assert!(headers.contains(&(
2213 "Content-Disposition",
2214 "inline; filename=\"truss.webp\"".to_string()
2215 )));
2216 assert!(headers.contains(&("Cache-Status", "\"truss\"; fwd=miss".to_string())));
2217 }
2218
2219 #[test]
2220 fn build_image_response_headers_include_csp_sandbox_for_svg() {
2221 let headers = build_image_response_headers(
2222 MediaType::Svg,
2223 &build_image_etag(b"svg-data"),
2224 ImageResponsePolicy::PublicGet,
2225 true,
2226 CacheHitStatus::Disabled,
2227 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2228 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2229 );
2230
2231 assert!(headers.contains(&("Content-Security-Policy", "sandbox".to_string())));
2232 }
2233
2234 #[test]
2235 fn build_image_response_headers_omit_csp_sandbox_for_raster() {
2236 let headers = build_image_response_headers(
2237 MediaType::Png,
2238 &build_image_etag(b"png-data"),
2239 ImageResponsePolicy::PublicGet,
2240 true,
2241 CacheHitStatus::Disabled,
2242 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2243 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2244 );
2245
2246 assert!(!headers.iter().any(|(k, _)| *k == "Content-Security-Policy"));
2247 }
2248
2249 #[test]
2250 fn backpressure_rejects_when_at_capacity() {
2251 let config = ServerConfig::new(std::env::temp_dir(), None);
2252 config
2253 .transforms_in_flight
2254 .store(DEFAULT_MAX_CONCURRENT_TRANSFORMS, Ordering::Relaxed);
2255
2256 let request = HttpRequest {
2257 method: "POST".to_string(),
2258 target: "/transform".to_string(),
2259 version: "HTTP/1.1".to_string(),
2260 headers: Vec::new(),
2261 body: Vec::new(),
2262 };
2263
2264 let png_bytes = {
2265 let mut buf = Vec::new();
2266 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
2267 encoder
2268 .write_image(&[255, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8)
2269 .unwrap();
2270 buf
2271 };
2272
2273 let response = transform_source_bytes(
2274 png_bytes,
2275 TransformOptions::default(),
2276 None,
2277 &request,
2278 ImageResponsePolicy::PrivateTransform,
2279 &config,
2280 WatermarkSource::None,
2281 None,
2282 None,
2283 );
2284
2285 assert!(response.status.contains("503"));
2286
2287 assert_eq!(
2288 config.transforms_in_flight.load(Ordering::Relaxed),
2289 DEFAULT_MAX_CONCURRENT_TRANSFORMS
2290 );
2291 }
2292
2293 #[test]
2294 fn backpressure_rejects_with_custom_concurrency_limit() {
2295 let custom_limit = 2u64;
2296 let mut config = ServerConfig::new(std::env::temp_dir(), None);
2297 config.max_concurrent_transforms = custom_limit;
2298 config
2299 .transforms_in_flight
2300 .store(custom_limit, Ordering::Relaxed);
2301
2302 let request = HttpRequest {
2303 method: "POST".to_string(),
2304 target: "/transform".to_string(),
2305 version: "HTTP/1.1".to_string(),
2306 headers: Vec::new(),
2307 body: Vec::new(),
2308 };
2309
2310 let png_bytes = {
2311 let mut buf = Vec::new();
2312 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
2313 encoder
2314 .write_image(&[255, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8)
2315 .unwrap();
2316 buf
2317 };
2318
2319 let response = transform_source_bytes(
2320 png_bytes,
2321 TransformOptions::default(),
2322 None,
2323 &request,
2324 ImageResponsePolicy::PrivateTransform,
2325 &config,
2326 WatermarkSource::None,
2327 None,
2328 None,
2329 );
2330
2331 assert!(response.status.contains("503"));
2332 }
2333
2334 #[test]
2335 fn compute_cache_key_is_deterministic() {
2336 let opts = TransformOptions {
2337 width: Some(300),
2338 height: Some(200),
2339 format: Some(MediaType::Webp),
2340 ..TransformOptions::default()
2341 };
2342 let key1 = super::cache::compute_cache_key("source-abc", &opts, None, None);
2343 let key2 = super::cache::compute_cache_key("source-abc", &opts, None, None);
2344 assert_eq!(key1, key2);
2345 assert_eq!(key1.len(), 64);
2346 }
2347
2348 #[test]
2349 fn compute_cache_key_differs_for_different_options() {
2350 let opts1 = TransformOptions {
2351 width: Some(300),
2352 format: Some(MediaType::Webp),
2353 ..TransformOptions::default()
2354 };
2355 let opts2 = TransformOptions {
2356 width: Some(400),
2357 format: Some(MediaType::Webp),
2358 ..TransformOptions::default()
2359 };
2360 let key1 = super::cache::compute_cache_key("same-source", &opts1, None, None);
2361 let key2 = super::cache::compute_cache_key("same-source", &opts2, None, None);
2362 assert_ne!(key1, key2);
2363 }
2364
2365 #[test]
2366 fn compute_cache_key_includes_accept_when_present() {
2367 let opts = TransformOptions::default();
2368 let key_no_accept = super::cache::compute_cache_key("src", &opts, None, None);
2369 let key_with_accept =
2370 super::cache::compute_cache_key("src", &opts, Some("image/webp"), None);
2371 assert_ne!(key_no_accept, key_with_accept);
2372 }
2373
2374 #[test]
2375 fn transform_cache_put_and_get_round_trips() {
2376 let dir = tempfile::tempdir().expect("create tempdir");
2377 let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2378
2379 cache.put(
2380 "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
2381 MediaType::Png,
2382 b"png-data",
2383 );
2384 let result = cache.get("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
2385
2386 match result {
2387 super::cache::CacheLookup::Hit {
2388 media_type, body, ..
2389 } => {
2390 assert_eq!(media_type, MediaType::Png);
2391 assert_eq!(body, b"png-data");
2392 }
2393 super::cache::CacheLookup::Miss => panic!("expected cache hit"),
2394 }
2395 }
2396
2397 #[test]
2398 fn transform_cache_miss_for_unknown_key() {
2399 let dir = tempfile::tempdir().expect("create tempdir");
2400 let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2401
2402 let result = cache.get("0000001234567890abcdef1234567890abcdef1234567890abcdef1234567890");
2403 assert!(matches!(result, super::cache::CacheLookup::Miss));
2404 }
2405
2406 #[test]
2407 fn transform_cache_uses_sharded_layout() {
2408 let dir = tempfile::tempdir().expect("create tempdir");
2409 let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2410
2411 let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2412 cache.put(key, MediaType::Jpeg, b"jpeg-data");
2413
2414 let expected = dir.path().join("ab").join("cd").join("ef").join(key);
2415 assert!(
2416 expected.exists(),
2417 "sharded file should exist at {expected:?}"
2418 );
2419 }
2420
2421 #[test]
2422 fn transform_cache_expired_entry_is_miss() {
2423 let dir = tempfile::tempdir().expect("create tempdir");
2424 let mut cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2425 cache.ttl = Duration::from_secs(0);
2426
2427 let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2428 cache.put(key, MediaType::Png, b"data");
2429
2430 std::thread::sleep(Duration::from_millis(10));
2431
2432 let result = cache.get(key);
2433 assert!(matches!(result, super::cache::CacheLookup::Miss));
2434 }
2435
2436 #[test]
2437 fn transform_cache_handles_corrupted_entry_as_miss() {
2438 let dir = tempfile::tempdir().expect("create tempdir");
2439 let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2440
2441 let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2442 let path = cache.entry_path(key);
2443 fs::create_dir_all(path.parent().unwrap()).unwrap();
2444 fs::write(&path, b"corrupted-data-without-header").unwrap();
2445
2446 let result = cache.get(key);
2447 assert!(matches!(result, super::cache::CacheLookup::Miss));
2448 }
2449
2450 #[test]
2451 fn cache_status_header_reflects_hit() {
2452 let headers = build_image_response_headers(
2453 MediaType::Png,
2454 &build_image_etag(b"data"),
2455 ImageResponsePolicy::PublicGet,
2456 false,
2457 CacheHitStatus::Hit,
2458 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2459 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2460 );
2461 assert!(headers.contains(&("Cache-Status", "\"truss\"; hit".to_string())));
2462 }
2463
2464 #[test]
2465 fn cache_status_header_reflects_miss() {
2466 let headers = build_image_response_headers(
2467 MediaType::Png,
2468 &build_image_etag(b"data"),
2469 ImageResponsePolicy::PublicGet,
2470 false,
2471 CacheHitStatus::Miss,
2472 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2473 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2474 );
2475 assert!(headers.contains(&("Cache-Status", "\"truss\"; fwd=miss".to_string())));
2476 }
2477
2478 #[test]
2479 fn origin_cache_put_and_get_round_trips() {
2480 let dir = tempfile::tempdir().expect("create tempdir");
2481 let cache = super::cache::OriginCache::new(dir.path());
2482
2483 cache.put("src", "https://example.com/image.png", b"raw-source-bytes");
2484 let result = cache.get("src", "https://example.com/image.png");
2485
2486 assert_eq!(result.as_deref(), Some(b"raw-source-bytes".as_ref()));
2487 }
2488
2489 #[test]
2490 fn origin_cache_miss_for_unknown_url() {
2491 let dir = tempfile::tempdir().expect("create tempdir");
2492 let cache = super::cache::OriginCache::new(dir.path());
2493
2494 assert!(
2495 cache
2496 .get("src", "https://unknown.example.com/missing.png")
2497 .is_none()
2498 );
2499 }
2500
2501 #[test]
2502 fn origin_cache_expired_entry_is_none() {
2503 let dir = tempfile::tempdir().expect("create tempdir");
2504 let mut cache = super::cache::OriginCache::new(dir.path());
2505 cache.ttl = Duration::from_secs(0);
2506
2507 cache.put("src", "https://example.com/img.png", b"data");
2508 std::thread::sleep(Duration::from_millis(10));
2509
2510 assert!(cache.get("src", "https://example.com/img.png").is_none());
2511 }
2512
2513 #[test]
2514 fn origin_cache_uses_origin_subdirectory() {
2515 let dir = tempfile::tempdir().expect("create tempdir");
2516 let cache = super::cache::OriginCache::new(dir.path());
2517
2518 cache.put("src", "https://example.com/test.png", b"bytes");
2519
2520 let origin_dir = dir.path().join("origin");
2521 assert!(origin_dir.exists(), "origin subdirectory should exist");
2522 }
2523
2524 #[test]
2525 fn sign_public_url_builds_a_signed_path_url() {
2526 let url = sign_public_url(
2527 "https://cdn.example.com",
2528 SignedUrlSource::Path {
2529 path: "/image.png".to_string(),
2530 version: Some("v1".to_string()),
2531 },
2532 &crate::TransformOptions {
2533 format: Some(MediaType::Jpeg),
2534 width: Some(320),
2535 ..crate::TransformOptions::default()
2536 },
2537 "public-dev",
2538 "secret-value",
2539 4_102_444_800,
2540 None,
2541 None,
2542 )
2543 .expect("sign public URL");
2544
2545 assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
2546 assert!(url.contains("path=%2Fimage.png"));
2547 assert!(url.contains("version=v1"));
2548 assert!(url.contains("width=320"));
2549 assert!(url.contains("format=jpeg"));
2550 assert!(url.contains("keyId=public-dev"));
2551 assert!(url.contains("expires=4102444800"));
2552 assert!(url.contains("signature="));
2553 }
2554
2555 #[test]
2556 fn parse_public_get_request_rejects_unknown_query_parameters() {
2557 let query = BTreeMap::from([
2558 ("path".to_string(), "/image.png".to_string()),
2559 ("keyId".to_string(), "public-dev".to_string()),
2560 ("expires".to_string(), "4102444800".to_string()),
2561 ("signature".to_string(), "deadbeef".to_string()),
2562 ("unexpected".to_string(), "value".to_string()),
2563 ]);
2564
2565 let config = ServerConfig::new(temp_dir("parse-query"), None);
2566 let response = parse_public_get_request(&query, PublicSourceKind::Path, &config)
2567 .expect_err("unknown query should fail");
2568
2569 assert_eq!(response.status, "400 Bad Request");
2570 assert!(response_body(&response).contains("is not supported"));
2571 }
2572
2573 #[test]
2574 fn parse_public_get_request_resolves_preset() {
2575 let mut presets = HashMap::new();
2576 presets.insert(
2577 "thumbnail".to_string(),
2578 TransformOptionsPayload {
2579 width: Some(150),
2580 height: Some(150),
2581 fit: Some("cover".to_string()),
2582 ..TransformOptionsPayload::default()
2583 },
2584 );
2585 let config = ServerConfig::new(temp_dir("preset"), None).with_presets(presets);
2586
2587 let query = BTreeMap::from([
2588 ("path".to_string(), "/image.png".to_string()),
2589 ("preset".to_string(), "thumbnail".to_string()),
2590 ]);
2591 let (_, options, _) =
2592 parse_public_get_request(&query, PublicSourceKind::Path, &config).unwrap();
2593
2594 assert_eq!(options.width, Some(150));
2595 assert_eq!(options.height, Some(150));
2596 assert_eq!(options.fit, Some(Fit::Cover));
2597 }
2598
2599 #[test]
2600 fn parse_public_get_request_preset_with_override() {
2601 let mut presets = HashMap::new();
2602 presets.insert(
2603 "thumbnail".to_string(),
2604 TransformOptionsPayload {
2605 width: Some(150),
2606 height: Some(150),
2607 fit: Some("cover".to_string()),
2608 format: Some("webp".to_string()),
2609 ..TransformOptionsPayload::default()
2610 },
2611 );
2612 let config = ServerConfig::new(temp_dir("preset-override"), None).with_presets(presets);
2613
2614 let query = BTreeMap::from([
2615 ("path".to_string(), "/image.png".to_string()),
2616 ("preset".to_string(), "thumbnail".to_string()),
2617 ("width".to_string(), "200".to_string()),
2618 ("format".to_string(), "jpeg".to_string()),
2619 ]);
2620 let (_, options, _) =
2621 parse_public_get_request(&query, PublicSourceKind::Path, &config).unwrap();
2622
2623 assert_eq!(options.width, Some(200));
2624 assert_eq!(options.height, Some(150));
2625 assert_eq!(options.format, Some(MediaType::Jpeg));
2626 }
2627
2628 #[test]
2629 fn parse_public_get_request_rejects_unknown_preset() {
2630 let config = ServerConfig::new(temp_dir("preset-unknown"), None);
2631
2632 let query = BTreeMap::from([
2633 ("path".to_string(), "/image.png".to_string()),
2634 ("preset".to_string(), "nonexistent".to_string()),
2635 ]);
2636 let response = parse_public_get_request(&query, PublicSourceKind::Path, &config)
2637 .expect_err("unknown preset should fail");
2638
2639 assert_eq!(response.status, "400 Bad Request");
2640 assert!(response_body(&response).contains("unknown preset"));
2641 }
2642
2643 #[test]
2644 fn sign_public_url_includes_preset_in_signed_url() {
2645 let url = sign_public_url(
2646 "https://cdn.example.com",
2647 SignedUrlSource::Path {
2648 path: "/image.png".to_string(),
2649 version: None,
2650 },
2651 &crate::TransformOptions::default(),
2652 "public-dev",
2653 "secret-value",
2654 4_102_444_800,
2655 None,
2656 Some("thumbnail"),
2657 )
2658 .expect("sign public URL with preset");
2659
2660 assert!(url.contains("preset=thumbnail"));
2661 assert!(url.contains("signature="));
2662 }
2663
2664 #[test]
2665 #[serial]
2666 fn parse_presets_from_env_parses_json() {
2667 unsafe {
2668 env::set_var(
2669 "TRUSS_PRESETS",
2670 r#"{"thumb":{"width":100,"height":100,"fit":"cover"}}"#,
2671 );
2672 env::remove_var("TRUSS_PRESETS_FILE");
2673 }
2674 let presets = parse_presets_from_env().unwrap();
2675 unsafe {
2676 env::remove_var("TRUSS_PRESETS");
2677 }
2678
2679 assert_eq!(presets.len(), 1);
2680 let thumb = presets.get("thumb").unwrap();
2681 assert_eq!(thumb.width, Some(100));
2682 assert_eq!(thumb.height, Some(100));
2683 assert_eq!(thumb.fit.as_deref(), Some("cover"));
2684 }
2685
2686 #[test]
2687 fn prepare_remote_fetch_target_pins_the_validated_netloc() {
2688 let target = prepare_remote_fetch_target(
2689 "http://1.1.1.1/image.png",
2690 &ServerConfig::new(temp_dir("pin"), Some("secret".to_string())),
2691 )
2692 .expect("prepare remote target");
2693
2694 assert_eq!(target.netloc, "1.1.1.1:80");
2695 assert_eq!(target.addrs, vec![SocketAddr::from(([1, 1, 1, 1], 80))]);
2696 }
2697
2698 #[test]
2699 fn pinned_resolver_rejects_unexpected_netlocs() {
2700 use ureq::unversioned::resolver::Resolver;
2701
2702 let resolver = PinnedResolver {
2703 expected_netloc: "example.com:443".to_string(),
2704 addrs: vec![SocketAddr::from(([93, 184, 216, 34], 443))],
2705 };
2706
2707 let config = ureq::config::Config::builder().build();
2708 let timeout = ureq::unversioned::transport::NextTimeout {
2709 after: ureq::unversioned::transport::time::Duration::Exact(
2710 std::time::Duration::from_secs(30),
2711 ),
2712 reason: ureq::Timeout::Resolve,
2713 };
2714
2715 let uri: ureq::http::Uri = "https://example.com/path".parse().unwrap();
2716 let result = resolver
2717 .resolve(&uri, &config, timeout)
2718 .expect("resolve expected netloc");
2719 assert_eq!(&result[..], &[SocketAddr::from(([93, 184, 216, 34], 443))]);
2720
2721 let bad_uri: ureq::http::Uri = "https://proxy.example:8080/path".parse().unwrap();
2722 let timeout2 = ureq::unversioned::transport::NextTimeout {
2723 after: ureq::unversioned::transport::time::Duration::Exact(
2724 std::time::Duration::from_secs(30),
2725 ),
2726 reason: ureq::Timeout::Resolve,
2727 };
2728 let error = resolver
2729 .resolve(&bad_uri, &config, timeout2)
2730 .expect_err("unexpected netloc should fail");
2731 assert!(matches!(error, ureq::Error::HostNotFound));
2732 }
2733
2734 #[test]
2735 fn health_live_returns_status_service_version() {
2736 let request = HttpRequest {
2737 method: "GET".to_string(),
2738 target: "/health/live".to_string(),
2739 version: "HTTP/1.1".to_string(),
2740 headers: Vec::new(),
2741 body: Vec::new(),
2742 };
2743
2744 let response = route_request(request, &ServerConfig::new(temp_dir("live"), None));
2745
2746 assert_eq!(response.status, "200 OK");
2747 let body: serde_json::Value =
2748 serde_json::from_slice(&response.body).expect("parse live body");
2749 assert_eq!(body["status"], "ok");
2750 assert_eq!(body["service"], "truss");
2751 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2752 }
2753
2754 #[test]
2755 fn health_ready_returns_ok_when_storage_exists() {
2756 let storage = temp_dir("ready-ok");
2757 let request = HttpRequest {
2758 method: "GET".to_string(),
2759 target: "/health/ready".to_string(),
2760 version: "HTTP/1.1".to_string(),
2761 headers: Vec::new(),
2762 body: Vec::new(),
2763 };
2764
2765 let response = route_request(request, &ServerConfig::new(storage, None));
2766
2767 assert_eq!(response.status, "200 OK");
2768 let body: serde_json::Value =
2769 serde_json::from_slice(&response.body).expect("parse ready body");
2770 assert_eq!(body["status"], "ok");
2771 let checks = body["checks"].as_array().expect("checks array");
2772 assert!(
2773 checks
2774 .iter()
2775 .any(|c| c["name"] == "storageRoot" && c["status"] == "ok")
2776 );
2777 }
2778
2779 #[test]
2780 fn health_ready_returns_503_when_storage_missing() {
2781 let request = HttpRequest {
2782 method: "GET".to_string(),
2783 target: "/health/ready".to_string(),
2784 version: "HTTP/1.1".to_string(),
2785 headers: Vec::new(),
2786 body: Vec::new(),
2787 };
2788
2789 let config = ServerConfig::new(PathBuf::from("/nonexistent-truss-test-dir"), None);
2790 let response = route_request(request, &config);
2791
2792 assert_eq!(response.status, "503 Service Unavailable");
2793 let body: serde_json::Value =
2794 serde_json::from_slice(&response.body).expect("parse ready fail body");
2795 assert_eq!(body["status"], "fail");
2796 let checks = body["checks"].as_array().expect("checks array");
2797 assert!(
2798 checks
2799 .iter()
2800 .any(|c| c["name"] == "storageRoot" && c["status"] == "fail")
2801 );
2802 }
2803
2804 #[test]
2805 fn health_ready_returns_503_when_cache_root_missing() {
2806 let storage = temp_dir("ready-cache-fail");
2807 let mut config = ServerConfig::new(storage, None);
2808 config.cache_root = Some(PathBuf::from("/nonexistent-truss-cache-dir"));
2809
2810 let request = HttpRequest {
2811 method: "GET".to_string(),
2812 target: "/health/ready".to_string(),
2813 version: "HTTP/1.1".to_string(),
2814 headers: Vec::new(),
2815 body: Vec::new(),
2816 };
2817
2818 let response = route_request(request, &config);
2819
2820 assert_eq!(response.status, "503 Service Unavailable");
2821 let body: serde_json::Value =
2822 serde_json::from_slice(&response.body).expect("parse ready cache body");
2823 assert_eq!(body["status"], "fail");
2824 let checks = body["checks"].as_array().expect("checks array");
2825 assert!(
2826 checks
2827 .iter()
2828 .any(|c| c["name"] == "cacheRoot" && c["status"] == "fail")
2829 );
2830 }
2831
2832 #[test]
2833 fn health_returns_comprehensive_diagnostic() {
2834 let storage = temp_dir("health-diag");
2835 let request = HttpRequest {
2836 method: "GET".to_string(),
2837 target: "/health".to_string(),
2838 version: "HTTP/1.1".to_string(),
2839 headers: Vec::new(),
2840 body: Vec::new(),
2841 };
2842
2843 let response = route_request(request, &ServerConfig::new(storage, None));
2844
2845 assert_eq!(response.status, "200 OK");
2846 let body: serde_json::Value =
2847 serde_json::from_slice(&response.body).expect("parse health body");
2848 assert_eq!(body["status"], "ok");
2849 assert_eq!(body["service"], "truss");
2850 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2851 assert!(body["uptimeSeconds"].is_u64());
2852 assert!(body["checks"].is_array());
2853 }
2854
2855 #[test]
2856 fn unknown_path_returns_not_found() {
2857 let request = HttpRequest {
2858 method: "GET".to_string(),
2859 target: "/unknown".to_string(),
2860 version: "HTTP/1.1".to_string(),
2861 headers: Vec::new(),
2862 body: Vec::new(),
2863 };
2864
2865 let response = route_request(request, &ServerConfig::new(temp_dir("not-found"), None));
2866
2867 assert_eq!(response.status, "404 Not Found");
2868 assert_eq!(response.content_type, Some("application/problem+json"));
2869 let body = response_body(&response);
2870 assert!(body.contains("\"type\":\"about:blank\""));
2871 assert!(body.contains("\"title\":\"Not Found\""));
2872 assert!(body.contains("\"status\":404"));
2873 assert!(body.contains("not found"));
2874 }
2875
2876 #[test]
2877 fn transform_endpoint_requires_authentication() {
2878 let storage_root = temp_dir("auth");
2879 write_png(&storage_root.join("image.png"));
2880 let mut request = transform_request("/image.png");
2881 request.headers.retain(|(name, _)| name != "authorization");
2882
2883 let response = route_request(
2884 request,
2885 &ServerConfig::new(storage_root, Some("secret".to_string())),
2886 );
2887
2888 assert_eq!(response.status, "401 Unauthorized");
2889 assert!(response_body(&response).contains("authorization required"));
2890 }
2891
2892 #[test]
2893 fn transform_endpoint_returns_service_unavailable_without_configured_token() {
2894 let storage_root = temp_dir("token");
2895 write_png(&storage_root.join("image.png"));
2896
2897 let response = route_request(
2898 transform_request("/image.png"),
2899 &ServerConfig::new(storage_root, None),
2900 );
2901
2902 assert_eq!(response.status, "503 Service Unavailable");
2903 assert!(response_body(&response).contains("bearer token is not configured"));
2904 }
2905
2906 #[test]
2907 fn transform_endpoint_transforms_a_path_source() {
2908 let storage_root = temp_dir("transform");
2909 write_png(&storage_root.join("image.png"));
2910
2911 let response = route_request(
2912 transform_request("/image.png"),
2913 &ServerConfig::new(storage_root, Some("secret".to_string())),
2914 );
2915
2916 assert_eq!(response.status, "200 OK");
2917 assert_eq!(response.content_type, Some("image/jpeg"));
2918
2919 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2920 assert_eq!(artifact.media_type, MediaType::Jpeg);
2921 assert_eq!(artifact.metadata.width, Some(4));
2922 assert_eq!(artifact.metadata.height, Some(3));
2923 }
2924
2925 #[test]
2926 fn transform_endpoint_rejects_private_url_sources_by_default() {
2927 let response = route_request(
2928 transform_url_request("http://127.0.0.1:8080/image.png"),
2929 &ServerConfig::new(temp_dir("url-blocked"), Some("secret".to_string())),
2930 );
2931
2932 assert_eq!(response.status, "403 Forbidden");
2933 assert!(response_body(&response).contains("port is not allowed"));
2934 }
2935
2936 #[test]
2937 fn transform_endpoint_transforms_a_url_source_when_insecure_allowance_is_enabled() {
2938 let (url, handle) = spawn_http_server(vec![(
2939 "200 OK".to_string(),
2940 vec![("Content-Type".to_string(), "image/png".to_string())],
2941 png_bytes(),
2942 )]);
2943
2944 let response = route_request(
2945 transform_url_request(&url),
2946 &ServerConfig::new(temp_dir("url"), Some("secret".to_string()))
2947 .with_insecure_url_sources(true),
2948 );
2949
2950 handle.join().expect("join fixture server");
2951
2952 assert_eq!(response.status, "200 OK");
2953 assert_eq!(response.content_type, Some("image/jpeg"));
2954
2955 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2956 assert_eq!(artifact.media_type, MediaType::Jpeg);
2957 }
2958
2959 #[test]
2960 fn transform_endpoint_follows_remote_redirects() {
2961 let (redirect_url, handle) = spawn_http_server(vec![
2962 (
2963 "302 Found".to_string(),
2964 vec![("Location".to_string(), "/final-image".to_string())],
2965 Vec::new(),
2966 ),
2967 (
2968 "200 OK".to_string(),
2969 vec![("Content-Type".to_string(), "image/png".to_string())],
2970 png_bytes(),
2971 ),
2972 ]);
2973
2974 let response = route_request(
2975 transform_url_request(&redirect_url),
2976 &ServerConfig::new(temp_dir("redirect"), Some("secret".to_string()))
2977 .with_insecure_url_sources(true),
2978 );
2979
2980 handle.join().expect("join fixture server");
2981
2982 assert_eq!(response.status, "200 OK");
2983 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2984 assert_eq!(artifact.media_type, MediaType::Jpeg);
2985 }
2986
2987 #[test]
2988 fn upload_endpoint_transforms_uploaded_file() {
2989 let response = route_request(
2990 upload_request(&png_bytes(), Some(r#"{"format":"jpeg"}"#)),
2991 &ServerConfig::new(temp_dir("upload"), Some("secret".to_string())),
2992 );
2993
2994 assert_eq!(response.status, "200 OK");
2995 assert_eq!(response.content_type, Some("image/jpeg"));
2996
2997 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2998 assert_eq!(artifact.media_type, MediaType::Jpeg);
2999 }
3000
3001 #[test]
3002 fn upload_endpoint_requires_a_file_field() {
3003 let boundary = "truss-test-boundary";
3004 let request = HttpRequest {
3005 method: "POST".to_string(),
3006 target: "/images".to_string(),
3007 version: "HTTP/1.1".to_string(),
3008 headers: vec![
3009 ("authorization".to_string(), "Bearer secret".to_string()),
3010 (
3011 "content-type".to_string(),
3012 format!("multipart/form-data; boundary={boundary}"),
3013 ),
3014 ],
3015 body: format!(
3016 "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{{\"format\":\"jpeg\"}}\r\n--{boundary}--\r\n"
3017 )
3018 .into_bytes(),
3019 };
3020
3021 let response = route_request(
3022 request,
3023 &ServerConfig::new(temp_dir("upload-missing-file"), Some("secret".to_string())),
3024 );
3025
3026 assert_eq!(response.status, "400 Bad Request");
3027 assert!(response_body(&response).contains("requires a `file` field"));
3028 }
3029
3030 #[test]
3031 fn upload_endpoint_rejects_non_multipart_content_type() {
3032 let request = HttpRequest {
3033 method: "POST".to_string(),
3034 target: "/images".to_string(),
3035 version: "HTTP/1.1".to_string(),
3036 headers: vec![
3037 ("authorization".to_string(), "Bearer secret".to_string()),
3038 ("content-type".to_string(), "application/json".to_string()),
3039 ],
3040 body: br#"{"file":"not-really-json"}"#.to_vec(),
3041 };
3042
3043 let response = route_request(
3044 request,
3045 &ServerConfig::new(temp_dir("upload-content-type"), Some("secret".to_string())),
3046 );
3047
3048 assert_eq!(response.status, "415 Unsupported Media Type");
3049 assert!(response_body(&response).contains("multipart/form-data"));
3050 }
3051
3052 #[test]
3053 fn parse_upload_request_extracts_file_and_options() {
3054 let request = upload_request(&png_bytes(), Some(r#"{"width":8,"format":"jpeg"}"#));
3055 let boundary =
3056 super::multipart::parse_multipart_boundary(&request).expect("parse boundary");
3057 let (file_bytes, options, _watermark) =
3058 super::multipart::parse_upload_request(&request.body, &boundary)
3059 .expect("parse upload body");
3060
3061 assert_eq!(file_bytes, png_bytes());
3062 assert_eq!(options.width, Some(8));
3063 assert_eq!(options.format, Some(MediaType::Jpeg));
3064 }
3065
3066 #[test]
3067 fn metrics_endpoint_does_not_require_authentication() {
3068 let response = route_request(
3069 metrics_request(false),
3070 &ServerConfig::new(temp_dir("metrics-no-auth"), Some("secret".to_string())),
3071 );
3072
3073 assert_eq!(response.status, "200 OK");
3074 }
3075
3076 #[test]
3077 fn metrics_endpoint_returns_prometheus_text() {
3078 super::metrics::record_http_metrics(super::metrics::RouteMetric::Health, "200 OK");
3079 let response = route_request(
3080 metrics_request(true),
3081 &ServerConfig::new(temp_dir("metrics"), Some("secret".to_string())),
3082 );
3083 let body = response_body(&response);
3084
3085 assert_eq!(response.status, "200 OK");
3086 assert_eq!(
3087 response.content_type,
3088 Some("text/plain; version=0.0.4; charset=utf-8")
3089 );
3090 assert!(body.contains("truss_http_requests_total"));
3091 assert!(body.contains("truss_http_requests_by_route_total{route=\"/health\"}"));
3092 assert!(body.contains("truss_http_responses_total{status=\"200\"}"));
3093 assert!(body.contains("# TYPE truss_http_request_duration_seconds histogram"));
3095 assert!(
3096 body.contains(
3097 "truss_http_request_duration_seconds_bucket{route=\"/health\",le=\"+Inf\"}"
3098 )
3099 );
3100 assert!(body.contains("# TYPE truss_transform_duration_seconds histogram"));
3101 assert!(body.contains("# TYPE truss_storage_request_duration_seconds histogram"));
3102 assert!(body.contains("# TYPE truss_transform_errors_total counter"));
3104 assert!(body.contains("truss_transform_errors_total{error_type=\"decode_failed\"}"));
3105 }
3106
3107 #[test]
3108 fn transform_endpoint_rejects_unsupported_remote_content_encoding() {
3109 let (url, handle) = spawn_http_server(vec![(
3110 "200 OK".to_string(),
3111 vec![
3112 ("Content-Type".to_string(), "image/png".to_string()),
3113 ("Content-Encoding".to_string(), "compress".to_string()),
3114 ],
3115 png_bytes(),
3116 )]);
3117
3118 let response = route_request(
3119 transform_url_request(&url),
3120 &ServerConfig::new(temp_dir("encoding"), Some("secret".to_string()))
3121 .with_insecure_url_sources(true),
3122 );
3123
3124 handle.join().expect("join fixture server");
3125
3126 assert_eq!(response.status, "502 Bad Gateway");
3127 assert!(response_body(&response).contains("unsupported content-encoding"));
3128 }
3129
3130 #[test]
3131 fn resolve_storage_path_rejects_parent_segments() {
3132 let storage_root = temp_dir("resolve");
3133 let response = resolve_storage_path(&storage_root, "../escape.png")
3134 .expect_err("parent segments should be rejected");
3135
3136 assert_eq!(response.status, "400 Bad Request");
3137 assert!(response_body(&response).contains("must not contain root"));
3138 }
3139
3140 #[test]
3141 fn read_request_parses_headers_and_body() {
3142 let request_bytes = b"POST /images:transform HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}";
3143 let mut cursor = Cursor::new(request_bytes);
3144 let request = read_request(&mut cursor).expect("parse request");
3145
3146 assert_eq!(request.method, "POST");
3147 assert_eq!(request.target, "/images:transform");
3148 assert_eq!(request.version, "HTTP/1.1");
3149 assert_eq!(request.header("host"), Some("localhost"));
3150 assert_eq!(request.body, b"{}");
3151 }
3152
3153 #[test]
3154 fn read_request_rejects_duplicate_content_length() {
3155 let request_bytes =
3156 b"POST /images:transform HTTP/1.1\r\nContent-Length: 2\r\nContent-Length: 2\r\n\r\n{}";
3157 let mut cursor = Cursor::new(request_bytes);
3158 let response = read_request(&mut cursor).expect_err("duplicate headers should fail");
3159
3160 assert_eq!(response.status, "400 Bad Request");
3161 assert!(response_body(&response).contains("content-length"));
3162 }
3163
3164 #[test]
3165 fn serve_once_handles_a_tcp_request() {
3166 let storage_root = temp_dir("serve-once");
3167 let config = ServerConfig::new(storage_root, None);
3168 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
3169 let addr = listener.local_addr().expect("read local addr");
3170
3171 let server = thread::spawn(move || serve_once_with_config(listener, config));
3172
3173 let mut stream = TcpStream::connect(addr).expect("connect to test server");
3174 stream
3175 .write_all(b"GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
3176 .expect("write request");
3177
3178 let mut response = String::new();
3179 stream.read_to_string(&mut response).expect("read response");
3180
3181 server
3182 .join()
3183 .expect("join test server thread")
3184 .expect("serve one request");
3185
3186 assert!(response.starts_with("HTTP/1.1 200 OK"));
3187 assert!(response.contains("Content-Type: application/json"));
3188 assert!(response.contains("\"status\":\"ok\""));
3189 assert!(response.contains("\"service\":\"truss\""));
3190 assert!(response.contains("\"version\":"));
3191 }
3192
3193 #[test]
3194 fn helper_error_responses_use_rfc7807_problem_details() {
3195 let response = auth_required_response("authorization required");
3196 let bad_request = bad_request_response("bad input");
3197
3198 assert_eq!(
3199 response.content_type,
3200 Some("application/problem+json"),
3201 "error responses must use application/problem+json"
3202 );
3203 assert_eq!(bad_request.content_type, Some("application/problem+json"),);
3204
3205 let auth_body = response_body(&response);
3206 assert!(auth_body.contains("authorization required"));
3207 assert!(auth_body.contains("\"type\":\"about:blank\""));
3208 assert!(auth_body.contains("\"title\":\"Unauthorized\""));
3209 assert!(auth_body.contains("\"status\":401"));
3210
3211 let bad_body = response_body(&bad_request);
3212 assert!(bad_body.contains("bad input"));
3213 assert!(bad_body.contains("\"type\":\"about:blank\""));
3214 assert!(bad_body.contains("\"title\":\"Bad Request\""));
3215 assert!(bad_body.contains("\"status\":400"));
3216 }
3217
3218 #[test]
3219 fn parse_headers_rejects_duplicate_host() {
3220 let lines = "Host: example.com\r\nHost: evil.com\r\n";
3221 let result = super::http_parse::parse_headers(lines.split("\r\n"));
3222 assert!(result.is_err());
3223 }
3224
3225 #[test]
3226 fn parse_headers_rejects_duplicate_authorization() {
3227 let lines = "Authorization: Bearer a\r\nAuthorization: Bearer b\r\n";
3228 let result = super::http_parse::parse_headers(lines.split("\r\n"));
3229 assert!(result.is_err());
3230 }
3231
3232 #[test]
3233 fn parse_headers_rejects_duplicate_content_type() {
3234 let lines = "Content-Type: application/json\r\nContent-Type: text/plain\r\n";
3235 let result = super::http_parse::parse_headers(lines.split("\r\n"));
3236 assert!(result.is_err());
3237 }
3238
3239 #[test]
3240 fn parse_headers_rejects_duplicate_transfer_encoding() {
3241 let lines = "Transfer-Encoding: chunked\r\nTransfer-Encoding: gzip\r\n";
3242 let result = super::http_parse::parse_headers(lines.split("\r\n"));
3243 assert!(result.is_err());
3244 }
3245
3246 #[test]
3247 fn parse_headers_rejects_single_transfer_encoding() {
3248 let lines = "Host: example.com\r\nTransfer-Encoding: chunked\r\n";
3249 let result = super::http_parse::parse_headers(lines.split("\r\n"));
3250 let err = result.unwrap_err();
3251 assert!(
3252 err.status.starts_with("501"),
3253 "expected 501 status, got: {}",
3254 err.status
3255 );
3256 assert!(
3257 String::from_utf8_lossy(&err.body).contains("Transfer-Encoding"),
3258 "error response should mention Transfer-Encoding"
3259 );
3260 }
3261
3262 #[test]
3263 fn parse_headers_rejects_transfer_encoding_identity() {
3264 let lines = "Transfer-Encoding: identity\r\n";
3265 let result = super::http_parse::parse_headers(lines.split("\r\n"));
3266 assert!(result.is_err());
3267 }
3268
3269 #[test]
3270 fn parse_headers_allows_single_instances_of_singleton_headers() {
3271 let lines =
3272 "Host: example.com\r\nAuthorization: Bearer tok\r\nContent-Type: application/json\r\n";
3273 let result = super::http_parse::parse_headers(lines.split("\r\n"));
3274 assert!(result.is_ok());
3275 assert_eq!(result.unwrap().len(), 3);
3276 }
3277
3278 #[test]
3279 fn max_body_for_multipart_uses_upload_limit() {
3280 let headers = vec![(
3281 "content-type".to_string(),
3282 "multipart/form-data; boundary=abc".to_string(),
3283 )];
3284 assert_eq!(
3285 super::http_parse::max_body_for_headers(&headers),
3286 super::http_parse::MAX_UPLOAD_BODY_BYTES
3287 );
3288 }
3289
3290 #[test]
3291 fn max_body_for_json_uses_default_limit() {
3292 let headers = vec![("content-type".to_string(), "application/json".to_string())];
3293 assert_eq!(
3294 super::http_parse::max_body_for_headers(&headers),
3295 super::http_parse::MAX_REQUEST_BODY_BYTES
3296 );
3297 }
3298
3299 #[test]
3300 fn max_body_for_no_content_type_uses_default_limit() {
3301 let headers: Vec<(String, String)> = vec![];
3302 assert_eq!(
3303 super::http_parse::max_body_for_headers(&headers),
3304 super::http_parse::MAX_REQUEST_BODY_BYTES
3305 );
3306 }
3307
3308 fn make_test_config() -> ServerConfig {
3309 ServerConfig::new(std::env::temp_dir(), None)
3310 }
3311
3312 #[test]
3313 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
3314 fn storage_backend_parse_filesystem_aliases() {
3315 assert_eq!(
3316 super::StorageBackend::parse("filesystem").unwrap(),
3317 super::StorageBackend::Filesystem
3318 );
3319 assert_eq!(
3320 super::StorageBackend::parse("fs").unwrap(),
3321 super::StorageBackend::Filesystem
3322 );
3323 assert_eq!(
3324 super::StorageBackend::parse("local").unwrap(),
3325 super::StorageBackend::Filesystem
3326 );
3327 }
3328
3329 #[test]
3330 #[cfg(feature = "s3")]
3331 fn storage_backend_parse_s3() {
3332 assert_eq!(
3333 super::StorageBackend::parse("s3").unwrap(),
3334 super::StorageBackend::S3
3335 );
3336 assert_eq!(
3337 super::StorageBackend::parse("S3").unwrap(),
3338 super::StorageBackend::S3
3339 );
3340 }
3341
3342 #[test]
3343 #[cfg(feature = "gcs")]
3344 fn storage_backend_parse_gcs() {
3345 assert_eq!(
3346 super::StorageBackend::parse("gcs").unwrap(),
3347 super::StorageBackend::Gcs
3348 );
3349 assert_eq!(
3350 super::StorageBackend::parse("GCS").unwrap(),
3351 super::StorageBackend::Gcs
3352 );
3353 }
3354
3355 #[test]
3356 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
3357 fn storage_backend_parse_rejects_unknown() {
3358 assert!(super::StorageBackend::parse("").is_err());
3359 #[cfg(not(feature = "azure"))]
3360 assert!(super::StorageBackend::parse("azure").is_err());
3361 #[cfg(feature = "azure")]
3362 assert!(super::StorageBackend::parse("azure").is_ok());
3363 }
3364
3365 #[test]
3366 fn versioned_source_hash_returns_none_without_version() {
3367 let source = TransformSourcePayload::Path {
3368 path: "/photos/hero.jpg".to_string(),
3369 version: None,
3370 };
3371 assert!(source.versioned_source_hash(&make_test_config()).is_none());
3372 }
3373
3374 #[test]
3375 fn versioned_source_hash_is_deterministic() {
3376 let cfg = make_test_config();
3377 let source = TransformSourcePayload::Path {
3378 path: "/photos/hero.jpg".to_string(),
3379 version: Some("v1".to_string()),
3380 };
3381 let hash1 = source.versioned_source_hash(&cfg).unwrap();
3382 let hash2 = source.versioned_source_hash(&cfg).unwrap();
3383 assert_eq!(hash1, hash2);
3384 assert_eq!(hash1.len(), 64);
3385 }
3386
3387 #[test]
3388 fn versioned_source_hash_differs_by_version() {
3389 let cfg = make_test_config();
3390 let v1 = TransformSourcePayload::Path {
3391 path: "/photos/hero.jpg".to_string(),
3392 version: Some("v1".to_string()),
3393 };
3394 let v2 = TransformSourcePayload::Path {
3395 path: "/photos/hero.jpg".to_string(),
3396 version: Some("v2".to_string()),
3397 };
3398 assert_ne!(
3399 v1.versioned_source_hash(&cfg).unwrap(),
3400 v2.versioned_source_hash(&cfg).unwrap()
3401 );
3402 }
3403
3404 #[test]
3405 fn versioned_source_hash_differs_by_kind() {
3406 let cfg = make_test_config();
3407 let path = TransformSourcePayload::Path {
3408 path: "example.com/image.jpg".to_string(),
3409 version: Some("v1".to_string()),
3410 };
3411 let url = TransformSourcePayload::Url {
3412 url: "example.com/image.jpg".to_string(),
3413 version: Some("v1".to_string()),
3414 };
3415 assert_ne!(
3416 path.versioned_source_hash(&cfg).unwrap(),
3417 url.versioned_source_hash(&cfg).unwrap()
3418 );
3419 }
3420
3421 #[test]
3422 fn versioned_source_hash_differs_by_storage_root() {
3423 let cfg1 = ServerConfig::new(PathBuf::from("/data/images"), None);
3424 let cfg2 = ServerConfig::new(PathBuf::from("/other/images"), None);
3425 let source = TransformSourcePayload::Path {
3426 path: "/photos/hero.jpg".to_string(),
3427 version: Some("v1".to_string()),
3428 };
3429 assert_ne!(
3430 source.versioned_source_hash(&cfg1).unwrap(),
3431 source.versioned_source_hash(&cfg2).unwrap()
3432 );
3433 }
3434
3435 #[test]
3436 fn versioned_source_hash_differs_by_insecure_flag() {
3437 let mut cfg1 = make_test_config();
3438 cfg1.allow_insecure_url_sources = false;
3439 let mut cfg2 = make_test_config();
3440 cfg2.allow_insecure_url_sources = true;
3441 let source = TransformSourcePayload::Url {
3442 url: "http://example.com/img.jpg".to_string(),
3443 version: Some("v1".to_string()),
3444 };
3445 assert_ne!(
3446 source.versioned_source_hash(&cfg1).unwrap(),
3447 source.versioned_source_hash(&cfg2).unwrap()
3448 );
3449 }
3450
3451 #[test]
3452 #[cfg(feature = "s3")]
3453 fn versioned_source_hash_storage_variant_is_deterministic() {
3454 let cfg = make_test_config();
3455 let source = TransformSourcePayload::Storage {
3456 bucket: Some("my-bucket".to_string()),
3457 key: "photos/hero.jpg".to_string(),
3458 version: Some("v1".to_string()),
3459 };
3460 let hash1 = source.versioned_source_hash(&cfg).unwrap();
3461 let hash2 = source.versioned_source_hash(&cfg).unwrap();
3462 assert_eq!(hash1, hash2);
3463 assert_eq!(hash1.len(), 64);
3464 }
3465
3466 #[test]
3467 #[cfg(feature = "s3")]
3468 fn versioned_source_hash_storage_differs_from_path() {
3469 let cfg = make_test_config();
3470 let path_source = TransformSourcePayload::Path {
3471 path: "photos/hero.jpg".to_string(),
3472 version: Some("v1".to_string()),
3473 };
3474 let storage_source = TransformSourcePayload::Storage {
3475 bucket: Some("my-bucket".to_string()),
3476 key: "photos/hero.jpg".to_string(),
3477 version: Some("v1".to_string()),
3478 };
3479 assert_ne!(
3480 path_source.versioned_source_hash(&cfg).unwrap(),
3481 storage_source.versioned_source_hash(&cfg).unwrap()
3482 );
3483 }
3484
3485 #[test]
3486 #[cfg(feature = "s3")]
3487 fn versioned_source_hash_storage_differs_by_bucket() {
3488 let cfg = make_test_config();
3489 let s1 = TransformSourcePayload::Storage {
3490 bucket: Some("bucket-a".to_string()),
3491 key: "image.jpg".to_string(),
3492 version: Some("v1".to_string()),
3493 };
3494 let s2 = TransformSourcePayload::Storage {
3495 bucket: Some("bucket-b".to_string()),
3496 key: "image.jpg".to_string(),
3497 version: Some("v1".to_string()),
3498 };
3499 assert_ne!(
3500 s1.versioned_source_hash(&cfg).unwrap(),
3501 s2.versioned_source_hash(&cfg).unwrap()
3502 );
3503 }
3504
3505 #[test]
3506 #[cfg(feature = "s3")]
3507 fn versioned_source_hash_differs_by_backend() {
3508 let cfg_fs = make_test_config();
3509 let mut cfg_s3 = make_test_config();
3510 cfg_s3.storage_backend = super::StorageBackend::S3;
3511
3512 let source = TransformSourcePayload::Path {
3513 path: "photos/hero.jpg".to_string(),
3514 version: Some("v1".to_string()),
3515 };
3516 assert_ne!(
3517 source.versioned_source_hash(&cfg_fs).unwrap(),
3518 source.versioned_source_hash(&cfg_s3).unwrap()
3519 );
3520 }
3521
3522 #[test]
3523 #[cfg(feature = "s3")]
3524 fn versioned_source_hash_storage_differs_by_endpoint() {
3525 let mut cfg_a = make_test_config();
3526 cfg_a.storage_backend = super::StorageBackend::S3;
3527 cfg_a.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3528 "shared",
3529 Some("http://minio-a:9000"),
3530 )));
3531
3532 let mut cfg_b = make_test_config();
3533 cfg_b.storage_backend = super::StorageBackend::S3;
3534 cfg_b.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3535 "shared",
3536 Some("http://minio-b:9000"),
3537 )));
3538
3539 let source = TransformSourcePayload::Storage {
3540 bucket: None,
3541 key: "image.jpg".to_string(),
3542 version: Some("v1".to_string()),
3543 };
3544 assert_ne!(
3545 source.versioned_source_hash(&cfg_a).unwrap(),
3546 source.versioned_source_hash(&cfg_b).unwrap(),
3547 );
3548 assert_ne!(cfg_a, cfg_b);
3549 }
3550
3551 #[test]
3552 #[cfg(feature = "s3")]
3553 fn storage_backend_default_is_filesystem() {
3554 let cfg = make_test_config();
3555 assert_eq!(cfg.storage_backend, super::StorageBackend::Filesystem);
3556 assert!(cfg.s3_context.is_none());
3557 }
3558
3559 #[test]
3560 #[cfg(feature = "s3")]
3561 fn storage_payload_deserializes_storage_variant() {
3562 let json = r#"{"source":{"kind":"storage","key":"photos/hero.jpg"},"options":{}}"#;
3563 let payload: super::TransformImageRequestPayload = serde_json::from_str(json).unwrap();
3564 match payload.source {
3565 TransformSourcePayload::Storage {
3566 bucket,
3567 key,
3568 version,
3569 } => {
3570 assert!(bucket.is_none());
3571 assert_eq!(key, "photos/hero.jpg");
3572 assert!(version.is_none());
3573 }
3574 _ => panic!("expected Storage variant"),
3575 }
3576 }
3577
3578 #[test]
3579 #[cfg(feature = "s3")]
3580 fn storage_payload_deserializes_with_bucket() {
3581 let json = r#"{"source":{"kind":"storage","bucket":"my-bucket","key":"img.png","version":"v2"},"options":{}}"#;
3582 let payload: super::TransformImageRequestPayload = serde_json::from_str(json).unwrap();
3583 match payload.source {
3584 TransformSourcePayload::Storage {
3585 bucket,
3586 key,
3587 version,
3588 } => {
3589 assert_eq!(bucket.as_deref(), Some("my-bucket"));
3590 assert_eq!(key, "img.png");
3591 assert_eq!(version.as_deref(), Some("v2"));
3592 }
3593 _ => panic!("expected Storage variant"),
3594 }
3595 }
3596
3597 #[test]
3602 #[cfg(feature = "s3")]
3603 fn versioned_source_hash_uses_default_bucket_when_bucket_is_none() {
3604 let mut cfg_a = make_test_config();
3605 cfg_a.storage_backend = super::StorageBackend::S3;
3606 cfg_a.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3607 "bucket-a", None,
3608 )));
3609
3610 let mut cfg_b = make_test_config();
3611 cfg_b.storage_backend = super::StorageBackend::S3;
3612 cfg_b.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3613 "bucket-b", None,
3614 )));
3615
3616 let source = TransformSourcePayload::Storage {
3617 bucket: None,
3618 key: "image.jpg".to_string(),
3619 version: Some("v1".to_string()),
3620 };
3621 assert_ne!(
3623 source.versioned_source_hash(&cfg_a).unwrap(),
3624 source.versioned_source_hash(&cfg_b).unwrap(),
3625 );
3626 assert_ne!(cfg_a, cfg_b);
3628 }
3629
3630 #[test]
3631 #[cfg(feature = "s3")]
3632 fn versioned_source_hash_returns_none_without_bucket_or_context() {
3633 let mut cfg = make_test_config();
3634 cfg.storage_backend = super::StorageBackend::S3;
3635 cfg.s3_context = None;
3636
3637 let source = TransformSourcePayload::Storage {
3638 bucket: None,
3639 key: "image.jpg".to_string(),
3640 version: Some("v1".to_string()),
3641 };
3642 assert!(source.versioned_source_hash(&cfg).is_none());
3644 }
3645
3646 #[cfg(feature = "s3")]
3655 static FROM_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3656
3657 #[cfg(feature = "s3")]
3658 const S3_ENV_VARS: &[&str] = &[
3659 "TRUSS_STORAGE_ROOT",
3660 "TRUSS_STORAGE_BACKEND",
3661 "TRUSS_S3_BUCKET",
3662 ];
3663
3664 #[cfg(feature = "s3")]
3667 fn with_s3_env<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
3668 let _guard = FROM_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
3669 let saved: Vec<(&str, Option<String>)> = S3_ENV_VARS
3670 .iter()
3671 .map(|k| (*k, std::env::var(k).ok()))
3672 .collect();
3673 for &(key, value) in vars {
3675 match value {
3676 Some(v) => unsafe { std::env::set_var(key, v) },
3677 None => unsafe { std::env::remove_var(key) },
3678 }
3679 }
3680 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
3681 for (key, original) in saved {
3683 match original {
3684 Some(v) => unsafe { std::env::set_var(key, v) },
3685 None => unsafe { std::env::remove_var(key) },
3686 }
3687 }
3688 if let Err(payload) = result {
3689 std::panic::resume_unwind(payload);
3690 }
3691 }
3692
3693 #[test]
3694 #[cfg(feature = "s3")]
3695 fn from_env_rejects_invalid_storage_backend() {
3696 let storage = temp_dir("env-bad-backend");
3697 let storage_str = storage.to_str().unwrap().to_string();
3698 with_s3_env(
3699 &[
3700 ("TRUSS_STORAGE_ROOT", Some(&storage_str)),
3701 ("TRUSS_STORAGE_BACKEND", Some("nosuchbackend")),
3702 ("TRUSS_S3_BUCKET", None),
3703 ],
3704 || {
3705 let result = ServerConfig::from_env();
3706 assert!(result.is_err());
3707 let msg = result.unwrap_err().to_string();
3708 assert!(msg.contains("unknown storage backend"), "got: {msg}");
3709 },
3710 );
3711 let _ = std::fs::remove_dir_all(storage);
3712 }
3713
3714 #[test]
3715 #[cfg(feature = "s3")]
3716 fn from_env_rejects_s3_without_bucket() {
3717 let storage = temp_dir("env-no-bucket");
3718 let storage_str = storage.to_str().unwrap().to_string();
3719 with_s3_env(
3720 &[
3721 ("TRUSS_STORAGE_ROOT", Some(&storage_str)),
3722 ("TRUSS_STORAGE_BACKEND", Some("s3")),
3723 ("TRUSS_S3_BUCKET", None),
3724 ],
3725 || {
3726 let result = ServerConfig::from_env();
3727 assert!(result.is_err());
3728 let msg = result.unwrap_err().to_string();
3729 assert!(msg.contains("TRUSS_S3_BUCKET"), "got: {msg}");
3730 },
3731 );
3732 let _ = std::fs::remove_dir_all(storage);
3733 }
3734
3735 #[test]
3736 #[cfg(feature = "s3")]
3737 fn from_env_accepts_s3_with_bucket() {
3738 let storage = temp_dir("env-s3-ok");
3739 let storage_str = storage.to_str().unwrap().to_string();
3740 with_s3_env(
3741 &[
3742 ("TRUSS_STORAGE_ROOT", Some(&storage_str)),
3743 ("TRUSS_STORAGE_BACKEND", Some("s3")),
3744 ("TRUSS_S3_BUCKET", Some("my-images")),
3745 ],
3746 || {
3747 let cfg =
3748 ServerConfig::from_env().expect("from_env should succeed with s3 + bucket");
3749 assert_eq!(cfg.storage_backend, super::StorageBackend::S3);
3750 let ctx = cfg.s3_context.expect("s3_context should be Some");
3751 assert_eq!(ctx.default_bucket, "my-images");
3752 },
3753 );
3754 let _ = std::fs::remove_dir_all(storage);
3755 }
3756
3757 #[test]
3762 #[cfg(feature = "s3")]
3763 fn health_ready_s3_returns_503_when_context_missing() {
3764 let storage = temp_dir("health-s3-no-ctx");
3765 let mut config = ServerConfig::new(storage.clone(), None);
3766 config.storage_backend = super::StorageBackend::S3;
3767 config.s3_context = None;
3768
3769 let request = super::http_parse::HttpRequest {
3770 method: "GET".to_string(),
3771 target: "/health/ready".to_string(),
3772 version: "HTTP/1.1".to_string(),
3773 headers: Vec::new(),
3774 body: Vec::new(),
3775 };
3776 let response = route_request(request, &config);
3777 let _ = std::fs::remove_dir_all(storage);
3778
3779 assert_eq!(response.status, "503 Service Unavailable");
3780 let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
3781 let checks = body["checks"].as_array().expect("checks array");
3782 assert!(
3783 checks
3784 .iter()
3785 .any(|c| c["name"] == "storageBackend" && c["status"] == "fail"),
3786 "expected s3Client fail check in {body}",
3787 );
3788 }
3789
3790 #[test]
3791 #[cfg(feature = "s3")]
3792 fn health_ready_s3_includes_s3_client_check() {
3793 let storage = temp_dir("health-s3-ok");
3794 let mut config = ServerConfig::new(storage.clone(), None);
3795 config.storage_backend = super::StorageBackend::S3;
3796 config.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3797 "test-bucket",
3798 None,
3799 )));
3800
3801 let request = super::http_parse::HttpRequest {
3802 method: "GET".to_string(),
3803 target: "/health/ready".to_string(),
3804 version: "HTTP/1.1".to_string(),
3805 headers: Vec::new(),
3806 body: Vec::new(),
3807 };
3808 let response = route_request(request, &config);
3809 let _ = std::fs::remove_dir_all(storage);
3810
3811 let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
3814 let checks = body["checks"].as_array().expect("checks array");
3815 assert!(
3816 checks.iter().any(|c| c["name"] == "storageBackend"),
3817 "expected s3Client check in {body}",
3818 );
3819 }
3820
3821 #[cfg(feature = "s3")]
3829 fn remap_path_to_storage(path: &str, version: Option<&str>) -> TransformSourcePayload {
3830 let source = TransformSourcePayload::Path {
3831 path: path.to_string(),
3832 version: version.map(|v| v.to_string()),
3833 };
3834 match source {
3835 TransformSourcePayload::Path { path, version } => TransformSourcePayload::Storage {
3836 bucket: None,
3837 key: path.trim_start_matches('/').to_string(),
3838 version,
3839 },
3840 other => other,
3841 }
3842 }
3843
3844 #[test]
3845 #[cfg(feature = "s3")]
3846 fn public_by_path_s3_remap_trims_leading_slash() {
3847 let source = remap_path_to_storage("/photos/hero.jpg", Some("v1"));
3851 match &source {
3852 TransformSourcePayload::Storage { key, .. } => {
3853 assert_eq!(key, "photos/hero.jpg", "leading / must be trimmed");
3854 }
3855 _ => panic!("expected Storage variant after remap"),
3856 }
3857
3858 let source2 = remap_path_to_storage("photos/hero.jpg", Some("v1"));
3860 match &source2 {
3861 TransformSourcePayload::Storage { key, .. } => {
3862 assert_eq!(key, "photos/hero.jpg");
3863 }
3864 _ => panic!("expected Storage variant after remap"),
3865 }
3866
3867 let mut cfg = make_test_config();
3869 cfg.storage_backend = super::StorageBackend::S3;
3870 cfg.s3_context = Some(std::sync::Arc::new(super::s3::S3Context::for_test(
3871 "my-bucket",
3872 None,
3873 )));
3874 assert_eq!(
3875 source.versioned_source_hash(&cfg),
3876 source2.versioned_source_hash(&cfg),
3877 "leading-slash and no-leading-slash paths must hash identically after trim",
3878 );
3879 }
3880
3881 #[test]
3882 #[cfg(feature = "s3")]
3883 fn public_by_path_s3_remap_produces_storage_variant() {
3884 let source = remap_path_to_storage("/image.png", None);
3886 match source {
3887 TransformSourcePayload::Storage {
3888 bucket,
3889 key,
3890 version,
3891 } => {
3892 assert!(bucket.is_none(), "bucket must be None (use default)");
3893 assert_eq!(key, "image.png");
3894 assert!(version.is_none());
3895 }
3896 _ => panic!("expected Storage variant"),
3897 }
3898 }
3899
3900 #[test]
3905 #[cfg(feature = "gcs")]
3906 fn health_ready_gcs_returns_503_when_context_missing() {
3907 let storage = temp_dir("health-gcs-no-ctx");
3908 let mut config = ServerConfig::new(storage.clone(), None);
3909 config.storage_backend = super::StorageBackend::Gcs;
3910 config.gcs_context = None;
3911
3912 let request = super::http_parse::HttpRequest {
3913 method: "GET".to_string(),
3914 target: "/health/ready".to_string(),
3915 version: "HTTP/1.1".to_string(),
3916 headers: Vec::new(),
3917 body: Vec::new(),
3918 };
3919 let response = route_request(request, &config);
3920 let _ = std::fs::remove_dir_all(storage);
3921
3922 assert_eq!(response.status, "503 Service Unavailable");
3923 let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
3924 let checks = body["checks"].as_array().expect("checks array");
3925 assert!(
3926 checks
3927 .iter()
3928 .any(|c| c["name"] == "storageBackend" && c["status"] == "fail"),
3929 "expected gcsClient fail check in {body}",
3930 );
3931 }
3932
3933 #[test]
3934 #[cfg(feature = "gcs")]
3935 fn health_ready_gcs_includes_gcs_client_check() {
3936 let storage = temp_dir("health-gcs-ok");
3937 let mut config = ServerConfig::new(storage.clone(), None);
3938 config.storage_backend = super::StorageBackend::Gcs;
3939 config.gcs_context = Some(std::sync::Arc::new(super::gcs::GcsContext::for_test(
3940 "test-bucket",
3941 None,
3942 )));
3943
3944 let request = super::http_parse::HttpRequest {
3945 method: "GET".to_string(),
3946 target: "/health/ready".to_string(),
3947 version: "HTTP/1.1".to_string(),
3948 headers: Vec::new(),
3949 body: Vec::new(),
3950 };
3951 let response = route_request(request, &config);
3952 let _ = std::fs::remove_dir_all(storage);
3953
3954 let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
3957 let checks = body["checks"].as_array().expect("checks array");
3958 assert!(
3959 checks.iter().any(|c| c["name"] == "storageBackend"),
3960 "expected gcsClient check in {body}",
3961 );
3962 }
3963
3964 #[cfg(feature = "gcs")]
3969 fn remap_path_to_storage_gcs(path: &str, version: Option<&str>) -> TransformSourcePayload {
3970 let source = TransformSourcePayload::Path {
3971 path: path.to_string(),
3972 version: version.map(|v| v.to_string()),
3973 };
3974 match source {
3975 TransformSourcePayload::Path { path, version } => TransformSourcePayload::Storage {
3976 bucket: None,
3977 key: path.trim_start_matches('/').to_string(),
3978 version,
3979 },
3980 other => other,
3981 }
3982 }
3983
3984 #[test]
3985 #[cfg(feature = "gcs")]
3986 fn public_by_path_gcs_remap_trims_leading_slash() {
3987 let source = remap_path_to_storage_gcs("/photos/hero.jpg", Some("v1"));
3988 match &source {
3989 TransformSourcePayload::Storage { key, .. } => {
3990 assert_eq!(key, "photos/hero.jpg", "leading / must be trimmed");
3991 }
3992 _ => panic!("expected Storage variant after remap"),
3993 }
3994
3995 let source2 = remap_path_to_storage_gcs("photos/hero.jpg", Some("v1"));
3996 match &source2 {
3997 TransformSourcePayload::Storage { key, .. } => {
3998 assert_eq!(key, "photos/hero.jpg");
3999 }
4000 _ => panic!("expected Storage variant after remap"),
4001 }
4002
4003 let mut cfg = make_test_config();
4004 cfg.storage_backend = super::StorageBackend::Gcs;
4005 cfg.gcs_context = Some(std::sync::Arc::new(super::gcs::GcsContext::for_test(
4006 "my-bucket",
4007 None,
4008 )));
4009 assert_eq!(
4010 source.versioned_source_hash(&cfg),
4011 source2.versioned_source_hash(&cfg),
4012 "leading-slash and no-leading-slash paths must hash identically after trim",
4013 );
4014 }
4015
4016 #[test]
4017 #[cfg(feature = "gcs")]
4018 fn public_by_path_gcs_remap_produces_storage_variant() {
4019 let source = remap_path_to_storage_gcs("/image.png", None);
4020 match source {
4021 TransformSourcePayload::Storage {
4022 bucket,
4023 key,
4024 version,
4025 } => {
4026 assert!(bucket.is_none(), "bucket must be None (use default)");
4027 assert_eq!(key, "image.png");
4028 assert!(version.is_none());
4029 }
4030 _ => panic!("expected Storage variant"),
4031 }
4032 }
4033
4034 #[test]
4039 #[cfg(feature = "azure")]
4040 fn health_ready_azure_returns_503_when_context_missing() {
4041 let storage = temp_dir("health-azure-no-ctx");
4042 let mut config = ServerConfig::new(storage.clone(), None);
4043 config.storage_backend = super::StorageBackend::Azure;
4044 config.azure_context = None;
4045
4046 let request = super::http_parse::HttpRequest {
4047 method: "GET".to_string(),
4048 target: "/health/ready".to_string(),
4049 version: "HTTP/1.1".to_string(),
4050 headers: Vec::new(),
4051 body: Vec::new(),
4052 };
4053 let response = route_request(request, &config);
4054 let _ = std::fs::remove_dir_all(storage);
4055
4056 assert_eq!(response.status, "503 Service Unavailable");
4057 let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
4058 let checks = body["checks"].as_array().expect("checks array");
4059 assert!(
4060 checks
4061 .iter()
4062 .any(|c| c["name"] == "storageBackend" && c["status"] == "fail"),
4063 "expected azureClient fail check in {body}",
4064 );
4065 }
4066
4067 #[test]
4068 #[cfg(feature = "azure")]
4069 fn health_ready_azure_includes_azure_client_check() {
4070 let storage = temp_dir("health-azure-ok");
4071 let mut config = ServerConfig::new(storage.clone(), None);
4072 config.storage_backend = super::StorageBackend::Azure;
4073 config.azure_context = Some(std::sync::Arc::new(super::azure::AzureContext::for_test(
4074 "test-bucket",
4075 "http://localhost:10000/devstoreaccount1",
4076 )));
4077
4078 let request = super::http_parse::HttpRequest {
4079 method: "GET".to_string(),
4080 target: "/health/ready".to_string(),
4081 version: "HTTP/1.1".to_string(),
4082 headers: Vec::new(),
4083 body: Vec::new(),
4084 };
4085 let response = route_request(request, &config);
4086 let _ = std::fs::remove_dir_all(storage);
4087
4088 let body: serde_json::Value = serde_json::from_slice(&response.body).expect("parse body");
4089 let checks = body["checks"].as_array().expect("checks array");
4090 assert!(
4091 checks.iter().any(|c| c["name"] == "storageBackend"),
4092 "expected azureClient check in {body}",
4093 );
4094 }
4095
4096 #[test]
4097 fn read_request_rejects_json_body_over_1mib() {
4098 let body = vec![b'x'; super::http_parse::MAX_REQUEST_BODY_BYTES + 1];
4099 let content_length = body.len();
4100 let raw = format!(
4101 "POST /images:transform HTTP/1.1\r\n\
4102 Content-Type: application/json\r\n\
4103 Content-Length: {content_length}\r\n\r\n"
4104 );
4105 let mut data = raw.into_bytes();
4106 data.extend_from_slice(&body);
4107 let result = read_request(&mut data.as_slice());
4108 assert!(result.is_err());
4109 }
4110
4111 #[test]
4112 fn read_request_accepts_multipart_body_over_1mib() {
4113 let payload_size = super::http_parse::MAX_REQUEST_BODY_BYTES + 100;
4114 let body_content = vec![b'A'; payload_size];
4115 let boundary = "test-boundary-123";
4116 let mut body = Vec::new();
4117 body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"big.jpg\"\r\n\r\n").as_bytes());
4118 body.extend_from_slice(&body_content);
4119 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
4120 let content_length = body.len();
4121 let raw = format!(
4122 "POST /images HTTP/1.1\r\n\
4123 Content-Type: multipart/form-data; boundary={boundary}\r\n\
4124 Content-Length: {content_length}\r\n\r\n"
4125 );
4126 let mut data = raw.into_bytes();
4127 data.extend_from_slice(&body);
4128 let result = read_request(&mut data.as_slice());
4129 assert!(
4130 result.is_ok(),
4131 "multipart upload over 1 MiB should be accepted"
4132 );
4133 }
4134
4135 #[test]
4136 fn multipart_boundary_in_payload_does_not_split_part() {
4137 let boundary = "abc123";
4138 let fake_boundary_in_payload = format!("\r\n--{boundary}NOTREAL");
4139 let part_body = format!("before{fake_boundary_in_payload}after");
4140 let body = format!(
4141 "--{boundary}\r\n\
4142 Content-Disposition: form-data; name=\"file\"\r\n\
4143 Content-Type: application/octet-stream\r\n\r\n\
4144 {part_body}\r\n\
4145 --{boundary}--\r\n"
4146 );
4147
4148 let parts = parse_multipart_form_data(body.as_bytes(), boundary)
4149 .expect("should parse despite boundary-like string in payload");
4150 assert_eq!(parts.len(), 1, "should have exactly one part");
4151
4152 let part_data = &body.as_bytes()[parts[0].body_range.clone()];
4153 let part_text = std::str::from_utf8(part_data).unwrap();
4154 assert!(
4155 part_text.contains("NOTREAL"),
4156 "part body should contain the full fake boundary string"
4157 );
4158 }
4159
4160 #[test]
4161 fn multipart_normal_two_parts_still_works() {
4162 let boundary = "testboundary";
4163 let body = format!(
4164 "--{boundary}\r\n\
4165 Content-Disposition: form-data; name=\"field1\"\r\n\r\n\
4166 value1\r\n\
4167 --{boundary}\r\n\
4168 Content-Disposition: form-data; name=\"field2\"\r\n\r\n\
4169 value2\r\n\
4170 --{boundary}--\r\n"
4171 );
4172
4173 let parts = parse_multipart_form_data(body.as_bytes(), boundary)
4174 .expect("should parse two normal parts");
4175 assert_eq!(parts.len(), 2);
4176 assert_eq!(parts[0].name, "field1");
4177 assert_eq!(parts[1].name, "field2");
4178 }
4179
4180 #[test]
4181 #[serial]
4182 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4183 fn test_storage_timeout_default() {
4184 unsafe {
4185 std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4186 }
4187 let config = ServerConfig::from_env().unwrap();
4188 assert_eq!(config.storage_timeout_secs, 30);
4189 }
4190
4191 #[test]
4192 #[serial]
4193 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4194 fn test_storage_timeout_custom() {
4195 unsafe {
4196 std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "60");
4197 }
4198 let config = ServerConfig::from_env().unwrap();
4199 assert_eq!(config.storage_timeout_secs, 60);
4200 unsafe {
4201 std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4202 }
4203 }
4204
4205 #[test]
4206 #[serial]
4207 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4208 fn test_storage_timeout_min_boundary() {
4209 unsafe {
4210 std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "1");
4211 }
4212 let config = ServerConfig::from_env().unwrap();
4213 assert_eq!(config.storage_timeout_secs, 1);
4214 unsafe {
4215 std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4216 }
4217 }
4218
4219 #[test]
4220 #[serial]
4221 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4222 fn test_storage_timeout_max_boundary() {
4223 unsafe {
4224 std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "300");
4225 }
4226 let config = ServerConfig::from_env().unwrap();
4227 assert_eq!(config.storage_timeout_secs, 300);
4228 unsafe {
4229 std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4230 }
4231 }
4232
4233 #[test]
4234 #[serial]
4235 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4236 fn test_storage_timeout_empty_string_uses_default() {
4237 unsafe {
4238 std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "");
4239 }
4240 let config = ServerConfig::from_env().unwrap();
4241 assert_eq!(config.storage_timeout_secs, 30);
4242 unsafe {
4243 std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4244 }
4245 }
4246
4247 #[test]
4248 #[serial]
4249 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4250 fn test_storage_timeout_zero_rejected() {
4251 unsafe {
4252 std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "0");
4253 }
4254 let err = ServerConfig::from_env().unwrap_err();
4255 assert!(
4256 err.to_string().contains("between 1 and 300"),
4257 "error should mention valid range: {err}"
4258 );
4259 unsafe {
4260 std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4261 }
4262 }
4263
4264 #[test]
4265 #[serial]
4266 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4267 fn test_storage_timeout_over_max_rejected() {
4268 unsafe {
4269 std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "301");
4270 }
4271 let err = ServerConfig::from_env().unwrap_err();
4272 assert!(
4273 err.to_string().contains("between 1 and 300"),
4274 "error should mention valid range: {err}"
4275 );
4276 unsafe {
4277 std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4278 }
4279 }
4280
4281 #[test]
4282 #[serial]
4283 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
4284 fn test_storage_timeout_non_numeric_rejected() {
4285 unsafe {
4286 std::env::set_var("TRUSS_STORAGE_TIMEOUT_SECS", "abc");
4287 }
4288 let err = ServerConfig::from_env().unwrap_err();
4289 assert!(
4290 err.to_string().contains("positive integer"),
4291 "error should mention positive integer: {err}"
4292 );
4293 unsafe {
4294 std::env::remove_var("TRUSS_STORAGE_TIMEOUT_SECS");
4295 }
4296 }
4297
4298 #[test]
4299 #[serial]
4300 fn test_max_concurrent_transforms_default() {
4301 unsafe {
4302 std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4303 }
4304 let config = ServerConfig::from_env().unwrap();
4305 assert_eq!(config.max_concurrent_transforms, 64);
4306 }
4307
4308 #[test]
4309 #[serial]
4310 fn test_max_concurrent_transforms_custom() {
4311 unsafe {
4312 std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "128");
4313 }
4314 let config = ServerConfig::from_env().unwrap();
4315 assert_eq!(config.max_concurrent_transforms, 128);
4316 unsafe {
4317 std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4318 }
4319 }
4320
4321 #[test]
4322 #[serial]
4323 fn test_max_concurrent_transforms_min_boundary() {
4324 unsafe {
4325 std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "1");
4326 }
4327 let config = ServerConfig::from_env().unwrap();
4328 assert_eq!(config.max_concurrent_transforms, 1);
4329 unsafe {
4330 std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4331 }
4332 }
4333
4334 #[test]
4335 #[serial]
4336 fn test_max_concurrent_transforms_max_boundary() {
4337 unsafe {
4338 std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "1024");
4339 }
4340 let config = ServerConfig::from_env().unwrap();
4341 assert_eq!(config.max_concurrent_transforms, 1024);
4342 unsafe {
4343 std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4344 }
4345 }
4346
4347 #[test]
4348 #[serial]
4349 fn test_max_concurrent_transforms_empty_uses_default() {
4350 unsafe {
4351 std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "");
4352 }
4353 let config = ServerConfig::from_env().unwrap();
4354 assert_eq!(config.max_concurrent_transforms, 64);
4355 unsafe {
4356 std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4357 }
4358 }
4359
4360 #[test]
4361 #[serial]
4362 fn test_max_concurrent_transforms_zero_rejected() {
4363 unsafe {
4364 std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "0");
4365 }
4366 let err = ServerConfig::from_env().unwrap_err();
4367 assert!(
4368 err.to_string().contains("between 1 and 1024"),
4369 "error should mention valid range: {err}"
4370 );
4371 unsafe {
4372 std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4373 }
4374 }
4375
4376 #[test]
4377 #[serial]
4378 fn test_max_concurrent_transforms_over_max_rejected() {
4379 unsafe {
4380 std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "1025");
4381 }
4382 let err = ServerConfig::from_env().unwrap_err();
4383 assert!(
4384 err.to_string().contains("between 1 and 1024"),
4385 "error should mention valid range: {err}"
4386 );
4387 unsafe {
4388 std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4389 }
4390 }
4391
4392 #[test]
4393 #[serial]
4394 fn test_max_concurrent_transforms_non_numeric_rejected() {
4395 unsafe {
4396 std::env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "abc");
4397 }
4398 let err = ServerConfig::from_env().unwrap_err();
4399 assert!(
4400 err.to_string().contains("positive integer"),
4401 "error should mention positive integer: {err}"
4402 );
4403 unsafe {
4404 std::env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS");
4405 }
4406 }
4407
4408 #[test]
4409 #[serial]
4410 fn test_transform_deadline_default() {
4411 unsafe {
4412 std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4413 }
4414 let config = ServerConfig::from_env().unwrap();
4415 assert_eq!(config.transform_deadline_secs, 30);
4416 }
4417
4418 #[test]
4419 #[serial]
4420 fn test_transform_deadline_custom() {
4421 unsafe {
4422 std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "60");
4423 }
4424 let config = ServerConfig::from_env().unwrap();
4425 assert_eq!(config.transform_deadline_secs, 60);
4426 unsafe {
4427 std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4428 }
4429 }
4430
4431 #[test]
4432 #[serial]
4433 fn test_transform_deadline_min_boundary() {
4434 unsafe {
4435 std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "1");
4436 }
4437 let config = ServerConfig::from_env().unwrap();
4438 assert_eq!(config.transform_deadline_secs, 1);
4439 unsafe {
4440 std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4441 }
4442 }
4443
4444 #[test]
4445 #[serial]
4446 fn test_transform_deadline_max_boundary() {
4447 unsafe {
4448 std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "300");
4449 }
4450 let config = ServerConfig::from_env().unwrap();
4451 assert_eq!(config.transform_deadline_secs, 300);
4452 unsafe {
4453 std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4454 }
4455 }
4456
4457 #[test]
4458 #[serial]
4459 fn test_transform_deadline_empty_uses_default() {
4460 unsafe {
4461 std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "");
4462 }
4463 let config = ServerConfig::from_env().unwrap();
4464 assert_eq!(config.transform_deadline_secs, 30);
4465 unsafe {
4466 std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4467 }
4468 }
4469
4470 #[test]
4471 #[serial]
4472 fn test_transform_deadline_zero_rejected() {
4473 unsafe {
4474 std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "0");
4475 }
4476 let err = ServerConfig::from_env().unwrap_err();
4477 assert!(
4478 err.to_string().contains("between 1 and 300"),
4479 "error should mention valid range: {err}"
4480 );
4481 unsafe {
4482 std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4483 }
4484 }
4485
4486 #[test]
4487 #[serial]
4488 fn test_transform_deadline_over_max_rejected() {
4489 unsafe {
4490 std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "301");
4491 }
4492 let err = ServerConfig::from_env().unwrap_err();
4493 assert!(
4494 err.to_string().contains("between 1 and 300"),
4495 "error should mention valid range: {err}"
4496 );
4497 unsafe {
4498 std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4499 }
4500 }
4501
4502 #[test]
4503 #[serial]
4504 fn test_transform_deadline_non_numeric_rejected() {
4505 unsafe {
4506 std::env::set_var("TRUSS_TRANSFORM_DEADLINE_SECS", "abc");
4507 }
4508 let err = ServerConfig::from_env().unwrap_err();
4509 assert!(
4510 err.to_string().contains("positive integer"),
4511 "error should mention positive integer: {err}"
4512 );
4513 unsafe {
4514 std::env::remove_var("TRUSS_TRANSFORM_DEADLINE_SECS");
4515 }
4516 }
4517
4518 #[test]
4519 #[serial]
4520 #[cfg(feature = "azure")]
4521 fn test_azure_container_env_var_required() {
4522 unsafe {
4523 std::env::set_var("TRUSS_STORAGE_BACKEND", "azure");
4524 std::env::remove_var("TRUSS_AZURE_CONTAINER");
4525 }
4526 let err = ServerConfig::from_env().unwrap_err();
4527 assert!(
4528 err.to_string().contains("TRUSS_AZURE_CONTAINER"),
4529 "error should mention TRUSS_AZURE_CONTAINER: {err}"
4530 );
4531 unsafe {
4532 std::env::remove_var("TRUSS_STORAGE_BACKEND");
4533 }
4534 }
4535
4536 #[test]
4537 fn server_config_debug_redacts_bearer_token_and_signed_url_secret() {
4538 let mut config = ServerConfig::new(
4539 temp_dir("debug-redact"),
4540 Some("super-secret-token-12345".to_string()),
4541 );
4542 config.signed_url_key_id = Some("visible-key-id".to_string());
4543 config.signed_url_secret = Some("super-secret-hmac-key".to_string());
4544 let debug = format!("{config:?}");
4545 assert!(
4546 !debug.contains("super-secret-token-12345"),
4547 "bearer_token leaked in Debug output: {debug}"
4548 );
4549 assert!(
4550 !debug.contains("super-secret-hmac-key"),
4551 "signed_url_secret leaked in Debug output: {debug}"
4552 );
4553 assert!(
4554 debug.contains("[REDACTED]"),
4555 "expected [REDACTED] in Debug output: {debug}"
4556 );
4557 assert!(
4558 debug.contains("visible-key-id"),
4559 "signed_url_key_id should be visible: {debug}"
4560 );
4561 }
4562
4563 #[test]
4564 fn authorize_headers_accepts_correct_bearer_token() {
4565 let config = ServerConfig::new(temp_dir("auth-ok"), Some("correct-token".to_string()));
4566 let headers = vec![(
4567 "authorization".to_string(),
4568 "Bearer correct-token".to_string(),
4569 )];
4570 assert!(super::authorize_request_headers(&headers, &config).is_ok());
4571 }
4572
4573 #[test]
4574 fn authorize_headers_rejects_wrong_bearer_token() {
4575 let config = ServerConfig::new(temp_dir("auth-wrong"), Some("correct-token".to_string()));
4576 let headers = vec![(
4577 "authorization".to_string(),
4578 "Bearer wrong-token".to_string(),
4579 )];
4580 let err = super::authorize_request_headers(&headers, &config).unwrap_err();
4581 assert_eq!(err.status, "401 Unauthorized");
4582 }
4583
4584 #[test]
4585 fn authorize_headers_rejects_missing_header() {
4586 let config = ServerConfig::new(temp_dir("auth-missing"), Some("correct-token".to_string()));
4587 let headers: Vec<(String, String)> = vec![];
4588 let err = super::authorize_request_headers(&headers, &config).unwrap_err();
4589 assert_eq!(err.status, "401 Unauthorized");
4590 }
4591
4592 #[test]
4595 fn transform_slot_acquire_succeeds_under_limit() {
4596 use std::sync::Arc;
4597 use std::sync::atomic::AtomicU64;
4598
4599 let counter = Arc::new(AtomicU64::new(0));
4600 let slot = super::TransformSlot::try_acquire(&counter, 2);
4601 assert!(slot.is_some());
4602 assert_eq!(counter.load(Ordering::Relaxed), 1);
4603 }
4604
4605 #[test]
4606 fn transform_slot_acquire_returns_none_at_limit() {
4607 use std::sync::Arc;
4608 use std::sync::atomic::AtomicU64;
4609
4610 let counter = Arc::new(AtomicU64::new(0));
4611 let _s1 = super::TransformSlot::try_acquire(&counter, 1).unwrap();
4612 let s2 = super::TransformSlot::try_acquire(&counter, 1);
4613 assert!(s2.is_none());
4614 assert_eq!(counter.load(Ordering::Relaxed), 1);
4616 }
4617
4618 #[test]
4619 fn transform_slot_drop_decrements_counter() {
4620 use std::sync::Arc;
4621 use std::sync::atomic::AtomicU64;
4622
4623 let counter = Arc::new(AtomicU64::new(0));
4624 {
4625 let _slot = super::TransformSlot::try_acquire(&counter, 4).unwrap();
4626 assert_eq!(counter.load(Ordering::Relaxed), 1);
4627 }
4628 assert_eq!(counter.load(Ordering::Relaxed), 0);
4630 }
4631
4632 #[test]
4633 fn transform_slot_multiple_acquires_up_to_limit() {
4634 use std::sync::Arc;
4635 use std::sync::atomic::AtomicU64;
4636
4637 let counter = Arc::new(AtomicU64::new(0));
4638 let limit = 3u64;
4639 let mut slots = Vec::new();
4640 for _ in 0..limit {
4641 slots.push(super::TransformSlot::try_acquire(&counter, limit).unwrap());
4642 }
4643 assert_eq!(counter.load(Ordering::Relaxed), limit);
4644 assert!(super::TransformSlot::try_acquire(&counter, limit).is_none());
4646 assert_eq!(counter.load(Ordering::Relaxed), limit);
4647 slots.clear();
4649 assert_eq!(counter.load(Ordering::Relaxed), 0);
4650 }
4651
4652 #[test]
4655 fn emit_access_log_produces_json_with_expected_fields() {
4656 use std::sync::{Arc, Mutex};
4657 use std::time::Instant;
4658
4659 let captured = Arc::new(Mutex::new(String::new()));
4660 let captured_clone = Arc::clone(&captured);
4661 let handler: super::LogHandler =
4662 Arc::new(move |msg: &str| *captured_clone.lock().unwrap() = msg.to_owned());
4663
4664 let mut config = ServerConfig::new(temp_dir("access-log"), None);
4665 config.log_handler = Some(handler);
4666
4667 let start = Instant::now();
4668 super::emit_access_log(
4669 &config,
4670 &super::AccessLogEntry {
4671 request_id: "req-123",
4672 method: "GET",
4673 path: "/image.png",
4674 route: "transform",
4675 status: "200",
4676 start,
4677 cache_status: Some("hit"),
4678 watermark: false,
4679 },
4680 );
4681
4682 let output = captured.lock().unwrap().clone();
4683 let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
4684 assert_eq!(parsed["kind"], "access_log");
4685 assert_eq!(parsed["request_id"], "req-123");
4686 assert_eq!(parsed["method"], "GET");
4687 assert_eq!(parsed["path"], "/image.png");
4688 assert_eq!(parsed["route"], "transform");
4689 assert_eq!(parsed["status"], "200");
4690 assert_eq!(parsed["cache_status"], "hit");
4691 assert!(parsed["latency_ms"].is_u64());
4692 }
4693
4694 #[test]
4695 fn emit_access_log_null_cache_status_when_none() {
4696 use std::sync::{Arc, Mutex};
4697 use std::time::Instant;
4698
4699 let captured = Arc::new(Mutex::new(String::new()));
4700 let captured_clone = Arc::clone(&captured);
4701 let handler: super::LogHandler =
4702 Arc::new(move |msg: &str| *captured_clone.lock().unwrap() = msg.to_owned());
4703
4704 let mut config = ServerConfig::new(temp_dir("access-log-none"), None);
4705 config.log_handler = Some(handler);
4706
4707 super::emit_access_log(
4708 &config,
4709 &super::AccessLogEntry {
4710 request_id: "req-456",
4711 method: "POST",
4712 path: "/upload",
4713 route: "upload",
4714 status: "201",
4715 start: Instant::now(),
4716 cache_status: None,
4717 watermark: false,
4718 },
4719 );
4720
4721 let output = captured.lock().unwrap().clone();
4722 let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
4723 assert!(parsed["cache_status"].is_null());
4724 }
4725
4726 #[test]
4729 fn x_request_id_is_extracted_from_incoming_headers() {
4730 let headers = vec![
4731 ("host".to_string(), "localhost".to_string()),
4732 ("x-request-id".to_string(), "custom-id-abc".to_string()),
4733 ];
4734 assert_eq!(
4735 super::extract_request_id(&headers),
4736 Some("custom-id-abc".to_string())
4737 );
4738 }
4739
4740 #[test]
4741 fn x_request_id_not_extracted_when_empty() {
4742 let headers = vec![("x-request-id".to_string(), "".to_string())];
4743 assert!(super::extract_request_id(&headers).is_none());
4744 }
4745
4746 #[test]
4747 fn x_request_id_not_extracted_when_absent() {
4748 let headers = vec![("host".to_string(), "localhost".to_string())];
4749 assert!(super::extract_request_id(&headers).is_none());
4750 }
4751
4752 #[test]
4755 fn cache_status_hit_detected() {
4756 let headers: Vec<(&str, String)> = vec![("Cache-Status", "\"truss\"; hit".to_string())];
4757 assert_eq!(super::extract_cache_status(&headers), Some("hit"));
4758 }
4759
4760 #[test]
4761 fn cache_status_miss_detected() {
4762 let headers: Vec<(&str, String)> =
4763 vec![("Cache-Status", "\"truss\"; fwd=miss".to_string())];
4764 assert_eq!(super::extract_cache_status(&headers), Some("miss"));
4765 }
4766
4767 #[test]
4768 fn cache_status_none_when_header_absent() {
4769 let headers: Vec<(&str, String)> = vec![("Content-Type", "image/png".to_string())];
4770 assert!(super::extract_cache_status(&headers).is_none());
4771 }
4772
4773 #[test]
4774 fn signing_keys_populated_by_with_signed_url_credentials() {
4775 let config = ServerConfig::new(temp_dir("signing-keys-populated"), None)
4776 .with_signed_url_credentials("key-alpha", "secret-alpha");
4777
4778 assert_eq!(
4779 config.signing_keys.get("key-alpha").map(String::as_str),
4780 Some("secret-alpha")
4781 );
4782 }
4783
4784 #[test]
4785 fn authorize_signed_request_accepts_multiple_keys() {
4786 let mut extra = HashMap::new();
4787 extra.insert("key-beta".to_string(), "secret-beta".to_string());
4788 let config = ServerConfig::new(temp_dir("multi-key-accept"), None)
4789 .with_signed_url_credentials("key-alpha", "secret-alpha")
4790 .with_signing_keys(extra);
4791
4792 let request_alpha = signed_public_request(
4794 "/images/by-path?path=%2Fimage.png&keyId=key-alpha&expires=4102444800&format=jpeg",
4795 "assets.example.com",
4796 "secret-alpha",
4797 );
4798 let query_alpha =
4799 super::auth::parse_query_params(&request_alpha).expect("parse query alpha");
4800 authorize_signed_request(&request_alpha, &query_alpha, &config)
4801 .expect("key-alpha should be accepted");
4802
4803 let request_beta = signed_public_request(
4805 "/images/by-path?path=%2Fimage.png&keyId=key-beta&expires=4102444800&format=jpeg",
4806 "assets.example.com",
4807 "secret-beta",
4808 );
4809 let query_beta = super::auth::parse_query_params(&request_beta).expect("parse query beta");
4810 authorize_signed_request(&request_beta, &query_beta, &config)
4811 .expect("key-beta should be accepted");
4812 }
4813
4814 #[test]
4815 fn authorize_signed_request_rejects_unknown_key() {
4816 let config = ServerConfig::new(temp_dir("unknown-key-reject"), None)
4817 .with_signed_url_credentials("key-alpha", "secret-alpha");
4818
4819 let request = signed_public_request(
4820 "/images/by-path?path=%2Fimage.png&keyId=key-unknown&expires=4102444800&format=jpeg",
4821 "assets.example.com",
4822 "secret-unknown",
4823 );
4824 let query = super::auth::parse_query_params(&request).expect("parse query");
4825 authorize_signed_request(&request, &query, &config)
4826 .expect_err("unknown key should be rejected");
4827 }
4828
4829 #[test]
4832 fn x_request_id_rejects_crlf_injection() {
4833 let headers = vec![(
4834 "x-request-id".to_string(),
4835 "evil\r\nX-Injected: true".to_string(),
4836 )];
4837 assert!(
4838 super::extract_request_id(&headers).is_none(),
4839 "CRLF in request ID must be rejected"
4840 );
4841 }
4842
4843 #[test]
4844 fn x_request_id_rejects_lone_cr() {
4845 let headers = vec![("x-request-id".to_string(), "evil\rid".to_string())];
4846 assert!(super::extract_request_id(&headers).is_none());
4847 }
4848
4849 #[test]
4850 fn x_request_id_rejects_lone_lf() {
4851 let headers = vec![("x-request-id".to_string(), "evil\nid".to_string())];
4852 assert!(super::extract_request_id(&headers).is_none());
4853 }
4854
4855 #[test]
4856 fn x_request_id_rejects_nul_byte() {
4857 let headers = vec![("x-request-id".to_string(), "evil\0id".to_string())];
4858 assert!(super::extract_request_id(&headers).is_none());
4859 }
4860
4861 #[test]
4862 fn x_request_id_accepts_normal_uuid() {
4863 let headers = vec![(
4864 "x-request-id".to_string(),
4865 "550e8400-e29b-41d4-a716-446655440000".to_string(),
4866 )];
4867 assert_eq!(
4868 super::extract_request_id(&headers),
4869 Some("550e8400-e29b-41d4-a716-446655440000".to_string())
4870 );
4871 }
4872
4873 #[test]
4876 fn server_config_new_has_expected_defaults() {
4877 let root = temp_dir("cfg-defaults");
4878 let config = ServerConfig::new(root.clone(), None);
4879 assert_eq!(config.storage_root, root);
4880 assert!(config.bearer_token.is_none());
4881 assert!(config.signed_url_secret.is_none());
4882 assert!(config.signing_keys.is_empty());
4883 assert!(config.presets.is_empty());
4884 assert_eq!(
4885 config.max_concurrent_transforms,
4886 DEFAULT_MAX_CONCURRENT_TRANSFORMS
4887 );
4888 assert_eq!(
4889 config.public_max_age_seconds,
4890 DEFAULT_PUBLIC_MAX_AGE_SECONDS
4891 );
4892 assert_eq!(
4893 config.public_stale_while_revalidate_seconds,
4894 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS
4895 );
4896 assert!(!config.allow_insecure_url_sources);
4897 }
4898
4899 #[test]
4900 fn server_config_builder_with_signed_url_credentials_overwrites() {
4901 let root = temp_dir("cfg-builder");
4902 let config = ServerConfig::new(root, None)
4903 .with_signed_url_credentials("key1", "secret1")
4904 .with_signed_url_credentials("key2", "secret2");
4905 assert!(config.signing_keys.contains_key("key1"));
4906 assert!(config.signing_keys.contains_key("key2"));
4907 }
4908
4909 #[test]
4912 fn route_request_returns_not_found_for_unknown_path() {
4913 let root = temp_dir("route-unknown");
4914 let config = ServerConfig::new(root, None);
4915 let request = HttpRequest {
4916 method: "GET".to_string(),
4917 target: "/nonexistent".to_string(),
4918 version: "HTTP/1.1".to_string(),
4919 headers: vec![("host".to_string(), "localhost".to_string())],
4920 body: Vec::new(),
4921 };
4922 let response = route_request(request, &config);
4923 assert_eq!(response.status, "404 Not Found");
4924 }
4925
4926 #[test]
4927 fn route_request_health_returns_200() {
4928 let root = temp_dir("route-health");
4929 let config = ServerConfig::new(root, None);
4930 let request = HttpRequest {
4931 method: "GET".to_string(),
4932 target: "/health".to_string(),
4933 version: "HTTP/1.1".to_string(),
4934 headers: vec![("host".to_string(), "localhost".to_string())],
4935 body: Vec::new(),
4936 };
4937 let response = route_request(request, &config);
4938 assert_eq!(response.status, "200 OK");
4939 }
4940
4941 #[test]
4944 fn transform_slot_concurrent_acquire_respects_limit() {
4945 use std::sync::Arc;
4946 use std::sync::Barrier;
4947 use std::sync::atomic::AtomicU64;
4948
4949 let counter = Arc::new(AtomicU64::new(0));
4950 let limit = 4u64;
4951 let num_threads = 16;
4952 let barrier = Arc::new(Barrier::new(num_threads));
4953 let acquired = Arc::new(AtomicU64::new(0));
4954
4955 let handles: Vec<_> = (0..num_threads)
4956 .map(|_| {
4957 let counter = Arc::clone(&counter);
4958 let barrier = Arc::clone(&barrier);
4959 let acquired = Arc::clone(&acquired);
4960 thread::spawn(move || {
4961 barrier.wait();
4962 if let Some(_slot) = super::TransformSlot::try_acquire(&counter, limit) {
4963 acquired.fetch_add(1, Ordering::Relaxed);
4964 thread::sleep(Duration::from_millis(10));
4965 }
4966 })
4967 })
4968 .collect();
4969
4970 for h in handles {
4971 h.join().unwrap();
4972 }
4973 assert_eq!(counter.load(Ordering::Relaxed), 0);
4974 }
4975}