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