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