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