1use crate::{
2 Artifact, Fit, MediaType, Position, RawArtifact, Rgba8, Rotation, TransformError,
3 TransformOptions, TransformRequest, sniff_artifact, transform_raster, transform_svg,
4};
5use hmac::{Hmac, Mac};
6use serde::Deserialize;
7use serde_json::json;
8use sha2::{Digest, Sha256};
9use std::collections::BTreeMap;
10use std::env;
11use std::fmt;
12use std::fs;
13use std::io::{self, Read, Write};
14use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
15use std::path::{Component, Path, PathBuf};
16use std::str::FromStr;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::time::{Duration, Instant};
20use ureq::http;
21use url::Url;
22
23pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
25
26pub const DEFAULT_STORAGE_ROOT: &str = ".";
28
29const NOT_FOUND_BODY: &str =
30 "{\"type\":\"about:blank\",\"title\":\"Not Found\",\"status\":404,\"detail\":\"not found\"}\n";
31const MAX_HEADER_BYTES: usize = 16 * 1024;
32const MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024;
33const MAX_UPLOAD_BODY_BYTES: usize = 100 * 1024 * 1024;
34const MAX_SOURCE_BYTES: u64 = 100 * 1024 * 1024;
35const MAX_REMOTE_REDIRECTS: usize = 5;
36const DEFAULT_PUBLIC_MAX_AGE_SECONDS: u32 = 3600;
37const DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS: u32 = 60;
38const DEFAULT_CACHE_TTL_SECONDS: u64 = 3600;
39const SOCKET_READ_TIMEOUT: Duration = Duration::from_secs(60);
40const SOCKET_WRITE_TIMEOUT: Duration = Duration::from_secs(60);
41const WORKER_THREADS: usize = 8;
43type HmacSha256 = Hmac<Sha256>;
44
45static HTTP_REQUESTS_TOTAL: AtomicU64 = AtomicU64::new(0);
46static HTTP_REQUESTS_HEALTH_TOTAL: AtomicU64 = AtomicU64::new(0);
47static HTTP_REQUESTS_HEALTH_LIVE_TOTAL: AtomicU64 = AtomicU64::new(0);
48static HTTP_REQUESTS_HEALTH_READY_TOTAL: AtomicU64 = AtomicU64::new(0);
49static HTTP_REQUESTS_PUBLIC_BY_PATH_TOTAL: AtomicU64 = AtomicU64::new(0);
50static HTTP_REQUESTS_PUBLIC_BY_URL_TOTAL: AtomicU64 = AtomicU64::new(0);
51static HTTP_REQUESTS_TRANSFORM_TOTAL: AtomicU64 = AtomicU64::new(0);
52static HTTP_REQUESTS_UPLOAD_TOTAL: AtomicU64 = AtomicU64::new(0);
53static HTTP_REQUESTS_METRICS_TOTAL: AtomicU64 = AtomicU64::new(0);
54static HTTP_REQUESTS_UNKNOWN_TOTAL: AtomicU64 = AtomicU64::new(0);
55
56static HTTP_RESPONSES_200_TOTAL: AtomicU64 = AtomicU64::new(0);
57static HTTP_RESPONSES_400_TOTAL: AtomicU64 = AtomicU64::new(0);
58static HTTP_RESPONSES_401_TOTAL: AtomicU64 = AtomicU64::new(0);
59static HTTP_RESPONSES_403_TOTAL: AtomicU64 = AtomicU64::new(0);
60static HTTP_RESPONSES_404_TOTAL: AtomicU64 = AtomicU64::new(0);
61static HTTP_RESPONSES_406_TOTAL: AtomicU64 = AtomicU64::new(0);
62static HTTP_RESPONSES_413_TOTAL: AtomicU64 = AtomicU64::new(0);
63static HTTP_RESPONSES_415_TOTAL: AtomicU64 = AtomicU64::new(0);
64static HTTP_RESPONSES_500_TOTAL: AtomicU64 = AtomicU64::new(0);
65static HTTP_RESPONSES_501_TOTAL: AtomicU64 = AtomicU64::new(0);
66static HTTP_RESPONSES_502_TOTAL: AtomicU64 = AtomicU64::new(0);
67static HTTP_RESPONSES_503_TOTAL: AtomicU64 = AtomicU64::new(0);
68static HTTP_RESPONSES_508_TOTAL: AtomicU64 = AtomicU64::new(0);
69static HTTP_RESPONSES_OTHER_TOTAL: AtomicU64 = AtomicU64::new(0);
70
71static TRANSFORMS_IN_FLIGHT: AtomicU64 = AtomicU64::new(0);
72static CACHE_HITS_TOTAL: AtomicU64 = AtomicU64::new(0);
73static CACHE_MISSES_TOTAL: AtomicU64 = AtomicU64::new(0);
74static ORIGIN_CACHE_HITS_TOTAL: AtomicU64 = AtomicU64::new(0);
75static ORIGIN_CACHE_MISSES_TOTAL: AtomicU64 = AtomicU64::new(0);
76
77const MAX_CONCURRENT_TRANSFORMS: u64 = 64;
80
81static START_TIME: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
83
84fn uptime_seconds() -> u64 {
86 START_TIME.get_or_init(Instant::now).elapsed().as_secs()
87}
88
89pub type LogHandler = Arc<dyn Fn(&str) + Send + Sync>;
100
101pub struct ServerConfig {
102 pub storage_root: PathBuf,
104 pub bearer_token: Option<String>,
106 pub public_base_url: Option<String>,
113 pub signed_url_key_id: Option<String>,
115 pub signed_url_secret: Option<String>,
117 pub allow_insecure_url_sources: bool,
123 pub cache_root: Option<PathBuf>,
130 pub log_handler: Option<LogHandler>,
136}
137
138impl Clone for ServerConfig {
139 fn clone(&self) -> Self {
140 Self {
141 storage_root: self.storage_root.clone(),
142 bearer_token: self.bearer_token.clone(),
143 public_base_url: self.public_base_url.clone(),
144 signed_url_key_id: self.signed_url_key_id.clone(),
145 signed_url_secret: self.signed_url_secret.clone(),
146 allow_insecure_url_sources: self.allow_insecure_url_sources,
147 cache_root: self.cache_root.clone(),
148 log_handler: self.log_handler.clone(),
149 }
150 }
151}
152
153impl fmt::Debug for ServerConfig {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 f.debug_struct("ServerConfig")
156 .field("storage_root", &self.storage_root)
157 .field("bearer_token", &self.bearer_token)
158 .field("public_base_url", &self.public_base_url)
159 .field("signed_url_key_id", &self.signed_url_key_id)
160 .field("signed_url_secret", &self.signed_url_secret)
161 .field(
162 "allow_insecure_url_sources",
163 &self.allow_insecure_url_sources,
164 )
165 .field("cache_root", &self.cache_root)
166 .field("log_handler", &self.log_handler.as_ref().map(|_| ".."))
167 .finish()
168 }
169}
170
171impl PartialEq for ServerConfig {
172 fn eq(&self, other: &Self) -> bool {
173 self.storage_root == other.storage_root
174 && self.bearer_token == other.bearer_token
175 && self.public_base_url == other.public_base_url
176 && self.signed_url_key_id == other.signed_url_key_id
177 && self.signed_url_secret == other.signed_url_secret
178 && self.allow_insecure_url_sources == other.allow_insecure_url_sources
179 && self.cache_root == other.cache_root
180 }
181}
182
183impl Eq for ServerConfig {}
184
185impl ServerConfig {
186 pub fn new(storage_root: PathBuf, bearer_token: Option<String>) -> Self {
201 Self {
202 storage_root,
203 bearer_token,
204 public_base_url: None,
205 signed_url_key_id: None,
206 signed_url_secret: None,
207 allow_insecure_url_sources: false,
208 cache_root: None,
209 log_handler: None,
210 }
211 }
212
213 fn log(&self, msg: &str) {
216 if let Some(handler) = &self.log_handler {
217 handler(msg);
218 } else {
219 eprintln!("{msg}");
220 }
221 }
222
223 pub fn with_signed_url_credentials(
241 mut self,
242 key_id: impl Into<String>,
243 secret: impl Into<String>,
244 ) -> Self {
245 self.signed_url_key_id = Some(key_id.into());
246 self.signed_url_secret = Some(secret.into());
247 self
248 }
249
250 pub fn with_insecure_url_sources(mut self, allow_insecure_url_sources: bool) -> Self {
267 self.allow_insecure_url_sources = allow_insecure_url_sources;
268 self
269 }
270
271 pub fn with_cache_root(mut self, cache_root: impl Into<PathBuf>) -> Self {
287 self.cache_root = Some(cache_root.into());
288 self
289 }
290
291 pub fn from_env() -> io::Result<Self> {
329 let storage_root =
330 env::var("TRUSS_STORAGE_ROOT").unwrap_or_else(|_| DEFAULT_STORAGE_ROOT.to_string());
331 let storage_root = PathBuf::from(storage_root).canonicalize()?;
332 let bearer_token = env::var("TRUSS_BEARER_TOKEN")
333 .ok()
334 .filter(|value| !value.is_empty());
335 let public_base_url = env::var("TRUSS_PUBLIC_BASE_URL")
336 .ok()
337 .filter(|value| !value.is_empty())
338 .map(validate_public_base_url)
339 .transpose()?;
340 let signed_url_key_id = env::var("TRUSS_SIGNED_URL_KEY_ID")
341 .ok()
342 .filter(|value| !value.is_empty());
343 let signed_url_secret = env::var("TRUSS_SIGNED_URL_SECRET")
344 .ok()
345 .filter(|value| !value.is_empty());
346
347 if signed_url_key_id.is_some() != signed_url_secret.is_some() {
348 return Err(io::Error::new(
349 io::ErrorKind::InvalidInput,
350 "TRUSS_SIGNED_URL_KEY_ID and TRUSS_SIGNED_URL_SECRET must be set together",
351 ));
352 }
353
354 let cache_root = env::var("TRUSS_CACHE_ROOT")
355 .ok()
356 .filter(|value| !value.is_empty())
357 .map(PathBuf::from);
358
359 Ok(Self {
360 storage_root,
361 bearer_token,
362 public_base_url,
363 signed_url_key_id,
364 signed_url_secret,
365 allow_insecure_url_sources: env_flag("TRUSS_ALLOW_INSECURE_URL_SOURCES"),
366 cache_root,
367 log_handler: None,
368 })
369 }
370}
371
372struct TransformCache {
384 root: PathBuf,
385 ttl: Duration,
386 log_handler: Option<LogHandler>,
387}
388
389#[derive(Debug)]
391enum CacheLookup {
392 Hit {
394 media_type: MediaType,
395 body: Vec<u8>,
396 age: Duration,
397 },
398 Miss,
400}
401
402impl TransformCache {
403 fn new(root: PathBuf) -> Self {
405 Self {
406 root,
407 ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECONDS),
408 log_handler: None,
409 }
410 }
411
412 fn with_log_handler(mut self, handler: Option<LogHandler>) -> Self {
413 self.log_handler = handler;
414 self
415 }
416
417 fn log(&self, msg: &str) {
418 if let Some(handler) = &self.log_handler {
419 handler(msg);
420 } else {
421 eprintln!("{msg}");
422 }
423 }
424
425 fn entry_path(&self, key: &str) -> PathBuf {
431 debug_assert!(
432 key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()),
433 "cache key must be a 64-character hex string"
434 );
435 let a = &key[0..2];
438 let b = &key[2..4];
439 let c = &key[4..6];
440 self.root.join(a).join(b).join(c).join(key)
441 }
442
443 fn get(&self, key: &str) -> CacheLookup {
448 let path = self.entry_path(key);
449
450 let file = match fs::File::open(&path) {
452 Ok(f) => f,
453 Err(_) => return CacheLookup::Miss,
454 };
455
456 let age = match file
458 .metadata()
459 .and_then(|m| m.modified())
460 .and_then(|mtime| mtime.elapsed().map_err(io::Error::other))
461 {
462 Ok(age) => age,
463 Err(_) => return CacheLookup::Miss,
464 };
465
466 if age > self.ttl {
467 return CacheLookup::Miss;
468 }
469
470 let mut data = Vec::new();
471 if io::Read::read_to_end(&mut &file, &mut data).is_err() {
472 return CacheLookup::Miss;
473 }
474
475 let newline_pos = match data.iter().position(|&b| b == b'\n') {
477 Some(pos) => pos,
478 None => return CacheLookup::Miss,
479 };
480 let media_type_str = match std::str::from_utf8(&data[..newline_pos]) {
481 Ok(s) => s,
482 Err(_) => return CacheLookup::Miss,
483 };
484 let media_type = match MediaType::from_str(media_type_str) {
485 Ok(mt) => mt,
486 Err(_) => return CacheLookup::Miss,
487 };
488
489 data.drain(..=newline_pos);
491
492 CacheLookup::Hit {
493 media_type,
494 body: data,
495 age,
496 }
497 }
498
499 fn put(&self, key: &str, media_type: MediaType, body: &[u8]) {
504 let path = self.entry_path(key);
505 if let Some(parent) = path.parent()
506 && let Err(err) = fs::create_dir_all(parent)
507 {
508 self.log(&format!("truss: cache mkdir failed: {err}"));
509 return;
510 }
511
512 let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
515 let mut header = media_type.as_name().as_bytes().to_vec();
516 header.push(b'\n');
517
518 let result = (|| -> io::Result<()> {
519 let mut file = fs::File::create(&tmp_path)?;
520 file.write_all(&header)?;
521 file.write_all(body)?;
522 file.sync_all()?;
523 fs::rename(&tmp_path, &path)?;
524 Ok(())
525 })();
526
527 if let Err(err) = result {
528 self.log(&format!("truss: cache write failed: {err}"));
529 let _ = fs::remove_file(&tmp_path);
531 }
532 }
533}
534
535struct OriginCache {
545 root: PathBuf,
546 ttl: Duration,
547 log_handler: Option<LogHandler>,
548}
549
550impl OriginCache {
551 fn new(cache_root: &Path) -> Self {
553 Self {
554 root: cache_root.join("origin"),
555 ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECONDS),
556 log_handler: None,
557 }
558 }
559
560 fn with_log_handler(mut self, handler: Option<LogHandler>) -> Self {
561 self.log_handler = handler;
562 self
563 }
564
565 fn log(&self, msg: &str) {
566 if let Some(handler) = &self.log_handler {
567 handler(msg);
568 } else {
569 eprintln!("{msg}");
570 }
571 }
572
573 fn entry_path(&self, url: &str) -> PathBuf {
575 let key = hex::encode(Sha256::digest(url.as_bytes()));
576 let a = &key[0..2];
577 let b = &key[2..4];
578 let c = &key[4..6];
579 self.root.join(a).join(b).join(c).join(&key)
580 }
581
582 fn get(&self, url: &str) -> Option<Vec<u8>> {
584 let path = self.entry_path(url);
585 let file = fs::File::open(&path).ok()?;
586
587 let age = file
588 .metadata()
589 .and_then(|m| m.modified())
590 .and_then(|mtime| mtime.elapsed().map_err(io::Error::other))
591 .ok()?;
592
593 if age > self.ttl {
594 return None;
595 }
596
597 let mut data = Vec::new();
598 io::Read::read_to_end(&mut &file, &mut data).ok()?;
599 Some(data)
600 }
601
602 fn put(&self, url: &str, body: &[u8]) {
604 let path = self.entry_path(url);
605 if let Some(parent) = path.parent()
606 && let Err(err) = fs::create_dir_all(parent)
607 {
608 self.log(&format!("truss: origin cache mkdir failed: {err}"));
609 return;
610 }
611
612 let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
613 let result = (|| -> io::Result<()> {
614 let mut file = fs::File::create(&tmp_path)?;
615 file.write_all(body)?;
616 file.sync_all()?;
617 fs::rename(&tmp_path, &path)?;
618 Ok(())
619 })();
620
621 if let Err(err) = result {
622 self.log(&format!("truss: origin cache write failed: {err}"));
623 let _ = fs::remove_file(&tmp_path);
624 }
625 }
626}
627
628fn compute_cache_key(
643 source_identifier: &str,
644 options: &TransformOptions,
645 negotiated_accept: Option<&str>,
646) -> String {
647 let mut canonical = String::new();
648 canonical.push_str(source_identifier);
649 canonical.push('\n');
650
651 let mut params: Vec<(&str, String)> = Vec::new();
653 if options.auto_orient {
654 params.push(("autoOrient", "true".to_string()));
655 }
656 if let Some(bg) = &options.background {
657 params.push((
658 "background",
659 format!("{:02x}{:02x}{:02x}{:02x}", bg.r, bg.g, bg.b, bg.a),
660 ));
661 }
662 if let Some(fit) = options.fit {
663 params.push(("fit", fit.as_name().to_string()));
664 }
665 if let Some(format) = options.format {
666 params.push(("format", format.as_name().to_string()));
667 }
668 if let Some(h) = options.height {
669 params.push(("height", h.to_string()));
670 }
671 if let Some(pos) = options.position {
672 params.push(("position", pos.as_name().to_string()));
673 }
674 if options.preserve_exif {
675 params.push(("preserveExif", "true".to_string()));
676 }
677 if let Some(q) = options.quality {
678 params.push(("quality", q.to_string()));
679 }
680 if options.rotate != Rotation::Deg0 {
681 params.push(("rotate", options.rotate.as_degrees().to_string()));
682 }
683 if options.strip_metadata {
684 params.push(("stripMetadata", "true".to_string()));
685 }
686 if let Some(w) = options.width {
687 params.push(("width", w.to_string()));
688 }
689 params.sort_by_key(|(k, _)| *k);
691 for (i, (k, v)) in params.iter().enumerate() {
692 if i > 0 {
693 canonical.push('&');
694 }
695 canonical.push_str(k);
696 canonical.push('=');
697 canonical.push_str(v);
698 }
699
700 canonical.push('\n');
701 if let Some(accept) = negotiated_accept {
702 canonical.push_str(accept);
703 }
704
705 let digest = Sha256::digest(canonical.as_bytes());
706 hex::encode(digest)
707}
708
709fn try_versioned_cache_lookup(
714 versioned_hash: Option<&str>,
715 options: &TransformOptions,
716 request: &HttpRequest,
717 response_policy: ImageResponsePolicy,
718 config: &ServerConfig,
719) -> Option<HttpResponse> {
720 let source_hash = versioned_hash?;
721 let cache_root = config.cache_root.as_ref()?;
722 options.format?;
725
726 let cache =
727 TransformCache::new(cache_root.clone()).with_log_handler(config.log_handler.clone());
728 let cache_key = compute_cache_key(source_hash, options, None);
729 if let CacheLookup::Hit {
730 media_type,
731 body,
732 age,
733 } = cache.get(&cache_key)
734 {
735 CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
736 let etag = build_image_etag(&body);
737 let mut headers = build_image_response_headers(
738 media_type,
739 &etag,
740 response_policy,
741 false,
742 CacheHitStatus::Hit,
743 );
744 headers.push(("Age".to_string(), age.as_secs().to_string()));
745 if matches!(response_policy, ImageResponsePolicy::PublicGet)
746 && if_none_match_matches(request.header("if-none-match"), &etag)
747 {
748 return Some(HttpResponse::empty("304 Not Modified", headers));
749 }
750 return Some(HttpResponse::binary_with_headers(
751 "200 OK",
752 media_type.as_mime(),
753 headers,
754 body,
755 ));
756 }
757 None
758}
759
760#[derive(Debug, Clone, PartialEq, Eq)]
762pub enum SignedUrlSource {
763 Path {
765 path: String,
767 version: Option<String>,
769 },
770 Url {
772 url: String,
774 version: Option<String>,
776 },
777}
778
779pub fn sign_public_url(
821 base_url: &str,
822 source: SignedUrlSource,
823 options: &TransformOptions,
824 key_id: &str,
825 secret: &str,
826 expires: u64,
827) -> Result<String, String> {
828 let base_url = Url::parse(base_url).map_err(|error| format!("base URL is invalid: {error}"))?;
829 match base_url.scheme() {
830 "http" | "https" => {}
831 _ => return Err("base URL must use the http or https scheme".to_string()),
832 }
833
834 let route_path = match source {
835 SignedUrlSource::Path { .. } => "/images/by-path",
836 SignedUrlSource::Url { .. } => "/images/by-url",
837 };
838 let mut endpoint = base_url
839 .join(route_path)
840 .map_err(|error| format!("failed to resolve the public endpoint URL: {error}"))?;
841 let authority = url_authority(&endpoint)?;
842 let mut query = signed_source_query(source);
843 extend_transform_query(&mut query, options);
844 query.insert("keyId".to_string(), key_id.to_string());
845 query.insert("expires".to_string(), expires.to_string());
846
847 let canonical = format!(
848 "GET\n{}\n{}\n{}",
849 authority,
850 endpoint.path(),
851 canonical_query_without_signature(&query)
852 );
853 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
854 .map_err(|error| format!("failed to initialize signed URL HMAC: {error}"))?;
855 mac.update(canonical.as_bytes());
856 query.insert(
857 "signature".to_string(),
858 hex::encode(mac.finalize().into_bytes()),
859 );
860
861 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
862 for (name, value) in query {
863 serializer.append_pair(&name, &value);
864 }
865 endpoint.set_query(Some(&serializer.finish()));
866 Ok(endpoint.into())
867}
868
869pub fn bind_addr() -> String {
874 env::var("TRUSS_BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string())
875}
876
877pub fn serve(listener: TcpListener) -> io::Result<()> {
889 let config = ServerConfig::from_env()?;
890 serve_with_config(listener, &config)
891}
892
893pub fn serve_with_config(listener: TcpListener, config: &ServerConfig) -> io::Result<()> {
903 let config = Arc::new(config.clone());
904 let (sender, receiver) = std::sync::mpsc::channel::<TcpStream>();
905
906 let receiver = Arc::new(std::sync::Mutex::new(receiver));
910 let mut workers = Vec::with_capacity(WORKER_THREADS);
911 for _ in 0..WORKER_THREADS {
912 let rx = Arc::clone(&receiver);
913 let cfg = Arc::clone(&config);
914 workers.push(std::thread::spawn(move || {
915 while let Ok(stream) = rx.lock().expect("worker lock poisoned").recv() {
916 if let Err(err) = handle_stream(stream, &cfg) {
917 cfg.log(&format!("failed to handle connection: {err}"));
918 }
919 }
920 }));
921 }
922
923 for stream in listener.incoming() {
924 match stream {
925 Ok(stream) => {
926 if sender.send(stream).is_err() {
927 break;
928 }
929 }
930 Err(err) => return Err(err),
931 }
932 }
933
934 drop(sender);
935 for worker in workers {
936 let _ = worker.join();
937 }
938
939 Ok(())
940}
941
942pub fn serve_once(listener: TcpListener) -> io::Result<()> {
952 let config = ServerConfig::from_env()?;
953 serve_once_with_config(listener, &config)
954}
955
956pub fn serve_once_with_config(listener: TcpListener, config: &ServerConfig) -> io::Result<()> {
963 let (stream, _) = listener.accept()?;
964 handle_stream(stream, config)
965}
966
967#[derive(Debug, Clone, PartialEq, Eq)]
968struct HttpRequest {
969 method: String,
970 target: String,
971 version: String,
972 headers: Vec<(String, String)>,
973 body: Vec<u8>,
974}
975
976impl HttpRequest {
977 fn header(&self, name: &str) -> Option<&str> {
978 self.headers
979 .iter()
980 .find_map(|(header_name, value)| (header_name == name).then_some(value.as_str()))
981 }
982
983 fn path(&self) -> &str {
984 self.target
985 .split('?')
986 .next()
987 .unwrap_or(self.target.as_str())
988 }
989
990 fn query(&self) -> Option<&str> {
991 self.target.split_once('?').map(|(_, query)| query)
992 }
993}
994
995#[derive(Debug, Clone, PartialEq, Eq)]
996struct HttpResponse {
997 status: &'static str,
998 content_type: Option<String>,
999 headers: Vec<(String, String)>,
1000 body: Vec<u8>,
1001}
1002
1003impl HttpResponse {
1004 fn json(status: &'static str, body: Vec<u8>) -> Self {
1005 Self {
1006 status,
1007 content_type: Some("application/json".to_string()),
1008 headers: Vec::new(),
1009 body,
1010 }
1011 }
1012
1013 fn problem(status: &'static str, body: Vec<u8>) -> Self {
1014 Self {
1015 status,
1016 content_type: Some("application/problem+json".to_string()),
1017 headers: Vec::new(),
1018 body,
1019 }
1020 }
1021
1022 fn problem_with_headers(
1023 status: &'static str,
1024 headers: Vec<(String, String)>,
1025 body: Vec<u8>,
1026 ) -> Self {
1027 Self {
1028 status,
1029 content_type: Some("application/problem+json".to_string()),
1030 headers,
1031 body,
1032 }
1033 }
1034
1035 fn binary_with_headers(
1036 status: &'static str,
1037 content_type: &str,
1038 headers: Vec<(String, String)>,
1039 body: Vec<u8>,
1040 ) -> Self {
1041 Self {
1042 status,
1043 content_type: Some(content_type.to_string()),
1044 headers,
1045 body,
1046 }
1047 }
1048
1049 fn text(status: &'static str, content_type: &str, body: Vec<u8>) -> Self {
1050 Self {
1051 status,
1052 content_type: Some(content_type.to_string()),
1053 headers: Vec::new(),
1054 body,
1055 }
1056 }
1057
1058 fn empty(status: &'static str, headers: Vec<(String, String)>) -> Self {
1059 Self {
1060 status,
1061 content_type: None,
1062 headers,
1063 body: Vec::new(),
1064 }
1065 }
1066}
1067
1068#[derive(Debug, Deserialize)]
1069#[serde(deny_unknown_fields)]
1070struct TransformImageRequestPayload {
1071 source: TransformSourcePayload,
1072 #[serde(default)]
1073 options: TransformOptionsPayload,
1074}
1075
1076#[derive(Debug, Deserialize)]
1077#[serde(tag = "kind", rename_all = "lowercase")]
1078enum TransformSourcePayload {
1079 Path {
1080 path: String,
1081 version: Option<String>,
1082 },
1083 Url {
1084 url: String,
1085 version: Option<String>,
1086 },
1087}
1088
1089impl TransformSourcePayload {
1090 fn versioned_source_hash(&self, config: &ServerConfig) -> Option<String> {
1099 let (kind, reference, version) = match self {
1100 Self::Path { path, version } => ("path", path.as_str(), version.as_deref()),
1101 Self::Url { url, version } => ("url", url.as_str(), version.as_deref()),
1102 };
1103 let version = version?;
1104 let mut id = String::new();
1108 id.push_str(kind);
1109 id.push('\n');
1110 id.push_str(reference);
1111 id.push('\n');
1112 id.push_str(version);
1113 id.push('\n');
1114 id.push_str(config.storage_root.to_string_lossy().as_ref());
1115 id.push('\n');
1116 id.push_str(if config.allow_insecure_url_sources {
1117 "insecure"
1118 } else {
1119 "strict"
1120 });
1121 Some(hex::encode(Sha256::digest(id.as_bytes())))
1122 }
1123}
1124
1125#[derive(Debug, Default, Deserialize)]
1126#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
1127struct TransformOptionsPayload {
1128 width: Option<u32>,
1129 height: Option<u32>,
1130 fit: Option<String>,
1131 position: Option<String>,
1132 format: Option<String>,
1133 quality: Option<u8>,
1134 background: Option<String>,
1135 rotate: Option<u16>,
1136 auto_orient: Option<bool>,
1137 strip_metadata: Option<bool>,
1138 preserve_exif: Option<bool>,
1139}
1140
1141impl TransformOptionsPayload {
1142 fn into_options(self) -> Result<TransformOptions, HttpResponse> {
1143 let defaults = TransformOptions::default();
1144
1145 Ok(TransformOptions {
1146 width: self.width,
1147 height: self.height,
1148 fit: parse_optional_named(self.fit.as_deref(), "fit", Fit::from_str)?,
1149 position: parse_optional_named(
1150 self.position.as_deref(),
1151 "position",
1152 Position::from_str,
1153 )?,
1154 format: parse_optional_named(self.format.as_deref(), "format", MediaType::from_str)?,
1155 quality: self.quality,
1156 background: parse_optional_named(
1157 self.background.as_deref(),
1158 "background",
1159 Rgba8::from_hex,
1160 )?,
1161 rotate: match self.rotate {
1162 Some(value) => parse_named(&value.to_string(), "rotate", Rotation::from_str)?,
1163 None => defaults.rotate,
1164 },
1165 auto_orient: self.auto_orient.unwrap_or(defaults.auto_orient),
1166 strip_metadata: self.strip_metadata.unwrap_or(defaults.strip_metadata),
1167 preserve_exif: self.preserve_exif.unwrap_or(defaults.preserve_exif),
1168 deadline: defaults.deadline,
1169 })
1170 }
1171}
1172
1173#[derive(Debug, Clone, PartialEq, Eq)]
1174struct MultipartPart {
1175 name: String,
1176 content_type: Option<String>,
1177 body_range: std::ops::Range<usize>,
1179}
1180
1181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1182enum RouteMetric {
1183 Health,
1184 HealthLive,
1185 HealthReady,
1186 PublicByPath,
1187 PublicByUrl,
1188 Transform,
1189 Upload,
1190 Metrics,
1191 Unknown,
1192}
1193
1194impl RouteMetric {
1195 const fn as_label(self) -> &'static str {
1196 match self {
1197 Self::Health => "/health",
1198 Self::HealthLive => "/health/live",
1199 Self::HealthReady => "/health/ready",
1200 Self::PublicByPath => "/images/by-path",
1201 Self::PublicByUrl => "/images/by-url",
1202 Self::Transform => "/images:transform",
1203 Self::Upload => "/images",
1204 Self::Metrics => "/metrics",
1205 Self::Unknown => "<unknown>",
1206 }
1207 }
1208}
1209
1210fn handle_stream(mut stream: TcpStream, config: &ServerConfig) -> io::Result<()> {
1211 if let Err(err) = stream.set_read_timeout(Some(SOCKET_READ_TIMEOUT)) {
1215 config.log(&format!("failed to set socket read timeout: {err}"));
1216 }
1217 if let Err(err) = stream.set_write_timeout(Some(SOCKET_WRITE_TIMEOUT)) {
1218 config.log(&format!("failed to set socket write timeout: {err}"));
1219 }
1220
1221 let request = match read_request(&mut stream) {
1222 Ok(request) => request,
1223 Err(response) => return write_response(&mut stream, response),
1224 };
1225 let route = classify_route(&request);
1226 let response = route_request(request, config);
1227 record_http_metrics(route, response.status);
1228
1229 write_response(&mut stream, response)
1230}
1231
1232fn route_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1233 let method = request.method.clone();
1234 let path = request.path().to_string();
1235
1236 match (method.as_str(), path.as_str()) {
1237 ("GET", "/health") => handle_health(config),
1238 ("GET", "/health/live") => handle_health_live(),
1239 ("GET", "/health/ready") => handle_health_ready(config),
1240 ("GET", "/images/by-path") => handle_public_path_request(request, config),
1241 ("GET", "/images/by-url") => handle_public_url_request(request, config),
1242 ("POST", "/images:transform") => handle_transform_request(request, config),
1243 ("POST", "/images") => handle_upload_request(request, config),
1244 ("GET", "/metrics") => handle_metrics_request(request, config),
1245 _ => HttpResponse::problem("404 Not Found", NOT_FOUND_BODY.as_bytes().to_vec()),
1246 }
1247}
1248
1249fn classify_route(request: &HttpRequest) -> RouteMetric {
1250 match (request.method.as_str(), request.path()) {
1251 ("GET", "/health") => RouteMetric::Health,
1252 ("GET", "/health/live") => RouteMetric::HealthLive,
1253 ("GET", "/health/ready") => RouteMetric::HealthReady,
1254 ("GET", "/images/by-path") => RouteMetric::PublicByPath,
1255 ("GET", "/images/by-url") => RouteMetric::PublicByUrl,
1256 ("POST", "/images:transform") => RouteMetric::Transform,
1257 ("POST", "/images") => RouteMetric::Upload,
1258 ("GET", "/metrics") => RouteMetric::Metrics,
1259 _ => RouteMetric::Unknown,
1260 }
1261}
1262
1263fn handle_transform_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1264 if let Err(response) = authorize_request(&request, config) {
1265 return response;
1266 }
1267
1268 if !request_has_json_content_type(&request) {
1269 return unsupported_media_type_response("content-type must be application/json");
1270 }
1271
1272 let payload: TransformImageRequestPayload = match serde_json::from_slice(&request.body) {
1273 Ok(payload) => payload,
1274 Err(error) => {
1275 return bad_request_response(&format!("request body must be valid JSON: {error}"));
1276 }
1277 };
1278 let options = match payload.options.into_options() {
1279 Ok(options) => options,
1280 Err(response) => return response,
1281 };
1282
1283 let versioned_hash = payload.source.versioned_source_hash(config);
1285 if let Some(response) = try_versioned_cache_lookup(
1286 versioned_hash.as_deref(),
1287 &options,
1288 &request,
1289 ImageResponsePolicy::PrivateTransform,
1290 config,
1291 ) {
1292 return response;
1293 }
1294
1295 let source_bytes = match resolve_source_bytes(payload.source, config) {
1296 Ok(bytes) => bytes,
1297 Err(response) => return response,
1298 };
1299 transform_source_bytes(
1300 source_bytes,
1301 options,
1302 versioned_hash.as_deref(),
1303 &request,
1304 ImageResponsePolicy::PrivateTransform,
1305 config,
1306 )
1307}
1308
1309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1310enum PublicSourceKind {
1311 Path,
1312 Url,
1313}
1314
1315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1316enum ImageResponsePolicy {
1317 PublicGet,
1318 PrivateTransform,
1319}
1320
1321fn handle_public_path_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1322 handle_public_get_request(request, config, PublicSourceKind::Path)
1323}
1324
1325fn handle_public_url_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1326 handle_public_get_request(request, config, PublicSourceKind::Url)
1327}
1328
1329fn handle_public_get_request(
1330 request: HttpRequest,
1331 config: &ServerConfig,
1332 source_kind: PublicSourceKind,
1333) -> HttpResponse {
1334 let query = match parse_query_params(&request) {
1335 Ok(query) => query,
1336 Err(response) => return response,
1337 };
1338 if let Err(response) = authorize_signed_request(&request, &query, config) {
1339 return response;
1340 }
1341 let (source, options) = match parse_public_get_request(&query, source_kind) {
1342 Ok(parsed) => parsed,
1343 Err(response) => return response,
1344 };
1345
1346 let versioned_hash = source.versioned_source_hash(config);
1348 if let Some(response) = try_versioned_cache_lookup(
1349 versioned_hash.as_deref(),
1350 &options,
1351 &request,
1352 ImageResponsePolicy::PublicGet,
1353 config,
1354 ) {
1355 return response;
1356 }
1357
1358 let source_bytes = match resolve_source_bytes(source, config) {
1359 Ok(bytes) => bytes,
1360 Err(response) => return response,
1361 };
1362
1363 transform_source_bytes(
1364 source_bytes,
1365 options,
1366 versioned_hash.as_deref(),
1367 &request,
1368 ImageResponsePolicy::PublicGet,
1369 config,
1370 )
1371}
1372
1373fn handle_upload_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1374 if let Err(response) = authorize_request(&request, config) {
1375 return response;
1376 }
1377
1378 let boundary = match parse_multipart_boundary(&request) {
1379 Ok(boundary) => boundary,
1380 Err(response) => return response,
1381 };
1382 let (file_bytes, options) = match parse_upload_request(&request.body, &boundary) {
1383 Ok(parts) => parts,
1384 Err(response) => return response,
1385 };
1386 transform_source_bytes(
1387 file_bytes,
1388 options,
1389 None,
1390 &request,
1391 ImageResponsePolicy::PrivateTransform,
1392 config,
1393 )
1394}
1395
1396fn handle_health_live() -> HttpResponse {
1401 let body = serde_json::to_vec(&json!({
1402 "status": "ok",
1403 "service": "truss",
1404 "version": env!("CARGO_PKG_VERSION"),
1405 }))
1406 .expect("serialize liveness");
1407 let mut body = body;
1408 body.push(b'\n');
1409 HttpResponse::json("200 OK", body)
1410}
1411
1412fn handle_health_ready(config: &ServerConfig) -> HttpResponse {
1418 let mut checks: Vec<serde_json::Value> = Vec::new();
1419 let mut all_ok = true;
1420
1421 let storage_ok = config.storage_root.is_dir();
1423 checks.push(json!({
1424 "name": "storageRoot",
1425 "status": if storage_ok { "ok" } else { "fail" },
1426 }));
1427 if !storage_ok {
1428 all_ok = false;
1429 }
1430
1431 if let Some(cache_root) = &config.cache_root {
1433 let cache_ok = cache_root.is_dir();
1434 checks.push(json!({
1435 "name": "cacheRoot",
1436 "status": if cache_ok { "ok" } else { "fail" },
1437 }));
1438 if !cache_ok {
1439 all_ok = false;
1440 }
1441 }
1442
1443 let in_flight = TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed);
1445 let overloaded = in_flight >= MAX_CONCURRENT_TRANSFORMS;
1446 checks.push(json!({
1447 "name": "transformCapacity",
1448 "status": if overloaded { "fail" } else { "ok" },
1449 }));
1450 if overloaded {
1451 all_ok = false;
1452 }
1453
1454 let status_str = if all_ok { "ok" } else { "fail" };
1455 let mut body = serde_json::to_vec(&json!({
1456 "status": status_str,
1457 "checks": checks,
1458 }))
1459 .expect("serialize readiness");
1460 body.push(b'\n');
1461
1462 if all_ok {
1463 HttpResponse::json("200 OK", body)
1464 } else {
1465 HttpResponse::json("503 Service Unavailable", body)
1466 }
1467}
1468
1469fn handle_health(config: &ServerConfig) -> HttpResponse {
1475 let mut checks: Vec<serde_json::Value> = Vec::new();
1476 let mut all_ok = true;
1477
1478 let storage_ok = config.storage_root.is_dir();
1479 checks.push(json!({
1480 "name": "storageRoot",
1481 "status": if storage_ok { "ok" } else { "fail" },
1482 }));
1483 if !storage_ok {
1484 all_ok = false;
1485 }
1486
1487 if let Some(cache_root) = &config.cache_root {
1488 let cache_ok = cache_root.is_dir();
1489 checks.push(json!({
1490 "name": "cacheRoot",
1491 "status": if cache_ok { "ok" } else { "fail" },
1492 }));
1493 if !cache_ok {
1494 all_ok = false;
1495 }
1496 }
1497
1498 let in_flight = TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed);
1499 let overloaded = in_flight >= MAX_CONCURRENT_TRANSFORMS;
1500 checks.push(json!({
1501 "name": "transformCapacity",
1502 "status": if overloaded { "fail" } else { "ok" },
1503 }));
1504 if overloaded {
1505 all_ok = false;
1506 }
1507
1508 let status_str = if all_ok { "ok" } else { "fail" };
1509 let mut body = serde_json::to_vec(&json!({
1510 "status": status_str,
1511 "service": "truss",
1512 "version": env!("CARGO_PKG_VERSION"),
1513 "uptimeSeconds": uptime_seconds(),
1514 "checks": checks,
1515 }))
1516 .expect("serialize health");
1517 body.push(b'\n');
1518
1519 HttpResponse::json("200 OK", body)
1520}
1521
1522fn handle_metrics_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1523 if let Err(response) = authorize_request(&request, config) {
1524 return response;
1525 }
1526
1527 HttpResponse::text(
1528 "200 OK",
1529 "text/plain; version=0.0.4; charset=utf-8",
1530 render_metrics_text().into_bytes(),
1531 )
1532}
1533
1534fn parse_public_get_request(
1535 query: &BTreeMap<String, String>,
1536 source_kind: PublicSourceKind,
1537) -> Result<(TransformSourcePayload, TransformOptions), HttpResponse> {
1538 validate_public_query_names(query, source_kind)?;
1539
1540 let source = match source_kind {
1541 PublicSourceKind::Path => TransformSourcePayload::Path {
1542 path: required_query_param(query, "path")?.to_string(),
1543 version: query.get("version").cloned(),
1544 },
1545 PublicSourceKind::Url => TransformSourcePayload::Url {
1546 url: required_query_param(query, "url")?.to_string(),
1547 version: query.get("version").cloned(),
1548 },
1549 };
1550
1551 let defaults = TransformOptions::default();
1552 let options = TransformOptions {
1553 width: parse_optional_integer_query(query, "width")?,
1554 height: parse_optional_integer_query(query, "height")?,
1555 fit: parse_optional_named(query.get("fit").map(String::as_str), "fit", Fit::from_str)?,
1556 position: parse_optional_named(
1557 query.get("position").map(String::as_str),
1558 "position",
1559 Position::from_str,
1560 )?,
1561 format: parse_optional_named(
1562 query.get("format").map(String::as_str),
1563 "format",
1564 MediaType::from_str,
1565 )?,
1566 quality: parse_optional_u8_query(query, "quality")?,
1567 background: parse_optional_named(
1568 query.get("background").map(String::as_str),
1569 "background",
1570 Rgba8::from_hex,
1571 )?,
1572 rotate: match query.get("rotate") {
1573 Some(value) => parse_named(value, "rotate", Rotation::from_str)?,
1574 None => defaults.rotate,
1575 },
1576 auto_orient: parse_optional_bool_query(query, "autoOrient")?
1577 .unwrap_or(defaults.auto_orient),
1578 strip_metadata: parse_optional_bool_query(query, "stripMetadata")?
1579 .unwrap_or(defaults.strip_metadata),
1580 preserve_exif: parse_optional_bool_query(query, "preserveExif")?
1581 .unwrap_or(defaults.preserve_exif),
1582 deadline: defaults.deadline,
1583 };
1584
1585 Ok((source, options))
1586}
1587
1588const SERVER_TRANSFORM_DEADLINE: Duration = Duration::from_secs(30);
1594
1595fn transform_source_bytes(
1596 source_bytes: Vec<u8>,
1597 options: TransformOptions,
1598 versioned_hash: Option<&str>,
1599 request: &HttpRequest,
1600 response_policy: ImageResponsePolicy,
1601 config: &ServerConfig,
1602) -> HttpResponse {
1603 let content_hash;
1607 let source_hash = match versioned_hash {
1608 Some(hash) => hash,
1609 None => {
1610 content_hash = hex::encode(Sha256::digest(&source_bytes));
1611 &content_hash
1612 }
1613 };
1614
1615 let cache = config
1617 .cache_root
1618 .as_ref()
1619 .map(|root| TransformCache::new(root.clone()).with_log_handler(config.log_handler.clone()));
1620
1621 if let Some(ref cache) = cache {
1622 if options.format.is_some() {
1625 let cache_key = compute_cache_key(source_hash, &options, None);
1626 if let CacheLookup::Hit {
1627 media_type,
1628 body,
1629 age,
1630 } = cache.get(&cache_key)
1631 {
1632 CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
1633 let etag = build_image_etag(&body);
1634 let mut headers = build_image_response_headers(
1635 media_type,
1636 &etag,
1637 response_policy,
1638 false,
1639 CacheHitStatus::Hit,
1640 );
1641 headers.push(("Age".to_string(), age.as_secs().to_string()));
1642 if matches!(response_policy, ImageResponsePolicy::PublicGet)
1643 && if_none_match_matches(request.header("if-none-match"), &etag)
1644 {
1645 return HttpResponse::empty("304 Not Modified", headers);
1646 }
1647 return HttpResponse::binary_with_headers(
1648 "200 OK",
1649 media_type.as_mime(),
1650 headers,
1651 body,
1652 );
1653 }
1654 }
1655 }
1656
1657 let in_flight = TRANSFORMS_IN_FLIGHT.fetch_add(1, Ordering::Relaxed);
1659 if in_flight >= MAX_CONCURRENT_TRANSFORMS {
1660 TRANSFORMS_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
1661 return service_unavailable_response("too many concurrent transforms; retry later");
1662 }
1663 let response = transform_source_bytes_inner(
1664 source_bytes,
1665 options,
1666 request,
1667 response_policy,
1668 cache.as_ref(),
1669 source_hash,
1670 );
1671 TRANSFORMS_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
1672 response
1673}
1674
1675fn transform_source_bytes_inner(
1676 source_bytes: Vec<u8>,
1677 mut options: TransformOptions,
1678 request: &HttpRequest,
1679 response_policy: ImageResponsePolicy,
1680 cache: Option<&TransformCache>,
1681 source_hash: &str,
1682) -> HttpResponse {
1683 if options.deadline.is_none() {
1684 options.deadline = Some(SERVER_TRANSFORM_DEADLINE);
1685 }
1686 let artifact = match sniff_artifact(RawArtifact::new(source_bytes, None)) {
1687 Ok(artifact) => artifact,
1688 Err(error) => return transform_error_response(error),
1689 };
1690 let negotiation_used = if options.format.is_none() {
1691 match negotiate_output_format(request.header("accept"), &artifact) {
1692 Ok(Some(format)) => {
1693 options.format = Some(format);
1694 true
1695 }
1696 Ok(None) => false,
1697 Err(response) => return response,
1698 }
1699 } else {
1700 false
1701 };
1702
1703 let negotiated_accept = if negotiation_used {
1705 request.header("accept")
1706 } else {
1707 None
1708 };
1709 let cache_key = compute_cache_key(source_hash, &options, negotiated_accept);
1710
1711 if let Some(cache) = cache
1712 && let CacheLookup::Hit {
1713 media_type,
1714 body,
1715 age,
1716 } = cache.get(&cache_key)
1717 {
1718 CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
1719 let etag = build_image_etag(&body);
1720 let mut headers = build_image_response_headers(
1721 media_type,
1722 &etag,
1723 response_policy,
1724 negotiation_used,
1725 CacheHitStatus::Hit,
1726 );
1727 headers.push(("Age".to_string(), age.as_secs().to_string()));
1728 if matches!(response_policy, ImageResponsePolicy::PublicGet)
1729 && if_none_match_matches(request.header("if-none-match"), &etag)
1730 {
1731 return HttpResponse::empty("304 Not Modified", headers);
1732 }
1733 return HttpResponse::binary_with_headers("200 OK", media_type.as_mime(), headers, body);
1734 }
1735
1736 if cache.is_some() {
1737 CACHE_MISSES_TOTAL.fetch_add(1, Ordering::Relaxed);
1738 }
1739
1740 let is_svg = artifact.media_type == MediaType::Svg;
1741 let result = if is_svg {
1742 match transform_svg(TransformRequest::new(artifact, options)) {
1743 Ok(result) => result,
1744 Err(error) => return transform_error_response(error),
1745 }
1746 } else {
1747 match transform_raster(TransformRequest::new(artifact, options)) {
1748 Ok(result) => result,
1749 Err(error) => return transform_error_response(error),
1750 }
1751 };
1752
1753 for warning in &result.warnings {
1754 let msg = format!("truss: {warning}");
1755 if let Some(c) = cache
1756 && let Some(handler) = &c.log_handler
1757 {
1758 handler(&msg);
1759 } else {
1760 eprintln!("{msg}");
1761 }
1762 }
1763
1764 let output = result.artifact;
1765
1766 if let Some(cache) = cache {
1768 cache.put(&cache_key, output.media_type, &output.bytes);
1769 }
1770
1771 let cache_hit_status = if cache.is_some() {
1772 CacheHitStatus::Miss
1773 } else {
1774 CacheHitStatus::Disabled
1775 };
1776
1777 let etag = build_image_etag(&output.bytes);
1778 let headers = build_image_response_headers(
1779 output.media_type,
1780 &etag,
1781 response_policy,
1782 negotiation_used,
1783 cache_hit_status,
1784 );
1785
1786 if matches!(response_policy, ImageResponsePolicy::PublicGet)
1787 && if_none_match_matches(request.header("if-none-match"), &etag)
1788 {
1789 return HttpResponse::empty("304 Not Modified", headers);
1790 }
1791
1792 HttpResponse::binary_with_headers("200 OK", output.media_type.as_mime(), headers, output.bytes)
1793}
1794
1795#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1796enum AcceptRange {
1797 Exact(&'static str),
1798 TypeWildcard(&'static str),
1799 Any,
1800}
1801
1802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1803struct AcceptPreference {
1804 range: AcceptRange,
1805 q_millis: u16,
1806 specificity: u8,
1807}
1808
1809fn negotiate_output_format(
1810 accept_header: Option<&str>,
1811 artifact: &Artifact,
1812) -> Result<Option<MediaType>, HttpResponse> {
1813 let Some(accept_header) = accept_header
1814 .map(str::trim)
1815 .filter(|value| !value.is_empty())
1816 else {
1817 return Ok(None);
1818 };
1819
1820 let (preferences, had_any_segment) = parse_accept_header(accept_header);
1821 if preferences.is_empty() {
1822 if had_any_segment {
1825 return Err(not_acceptable_response(
1826 "Accept does not allow any supported output media type",
1827 ));
1828 }
1829 return Ok(None);
1830 }
1831
1832 let mut best_candidate = None;
1833 let mut best_q = 0_u16;
1834
1835 for candidate in preferred_output_media_types(artifact).iter().copied() {
1836 let (candidate_q, _) = match_accept_preferences(candidate, &preferences);
1837 if candidate_q > best_q {
1838 best_q = candidate_q;
1839 best_candidate = Some(candidate);
1840 }
1841 }
1842
1843 if best_q == 0 {
1844 return Err(not_acceptable_response(
1845 "Accept does not allow any supported output media type",
1846 ));
1847 }
1848
1849 Ok(best_candidate)
1850}
1851
1852fn parse_accept_header(value: &str) -> (Vec<AcceptPreference>, bool) {
1857 let mut preferences = Vec::new();
1858 let mut had_any_segment = false;
1859 for segment in value.split(',') {
1860 let segment = segment.trim();
1861 if segment.is_empty() {
1862 continue;
1863 }
1864 had_any_segment = true;
1865 if let Some(pref) = parse_accept_segment(segment) {
1866 preferences.push(pref);
1867 }
1868 }
1869 (preferences, had_any_segment)
1870}
1871
1872fn parse_accept_segment(segment: &str) -> Option<AcceptPreference> {
1873 if segment.is_empty() {
1874 return None;
1875 }
1876
1877 let mut parts = segment.split(';');
1878 let media_range = parts.next()?.trim().to_ascii_lowercase();
1879 let (range, specificity) = parse_accept_range(&media_range)?;
1880 let mut q_millis = 1000_u16;
1881
1882 for parameter in parts {
1883 let Some((name, value)) = parameter.split_once('=') else {
1884 continue;
1885 };
1886 if name.trim().eq_ignore_ascii_case("q") {
1887 q_millis = parse_accept_qvalue(value.trim())?;
1888 }
1889 }
1890
1891 Some(AcceptPreference {
1892 range,
1893 q_millis,
1894 specificity,
1895 })
1896}
1897
1898fn parse_accept_range(value: &str) -> Option<(AcceptRange, u8)> {
1899 match value {
1900 "*/*" => Some((AcceptRange::Any, 0)),
1901 "image/*" => Some((AcceptRange::TypeWildcard("image"), 1)),
1902 "image/jpeg" => Some((AcceptRange::Exact("image/jpeg"), 2)),
1903 "image/png" => Some((AcceptRange::Exact("image/png"), 2)),
1904 "image/webp" => Some((AcceptRange::Exact("image/webp"), 2)),
1905 "image/avif" => Some((AcceptRange::Exact("image/avif"), 2)),
1906 "image/bmp" => Some((AcceptRange::Exact("image/bmp"), 2)),
1907 _ => None,
1908 }
1909}
1910
1911fn parse_accept_qvalue(value: &str) -> Option<u16> {
1912 let parsed = value.parse::<f32>().ok()?;
1913 if !(0.0..=1.0).contains(&parsed) {
1914 return None;
1915 }
1916
1917 Some((parsed * 1000.0).round() as u16)
1918}
1919
1920fn preferred_output_media_types(artifact: &Artifact) -> &'static [MediaType] {
1921 if artifact.metadata.has_alpha == Some(true) {
1922 &[
1923 MediaType::Avif,
1924 MediaType::Webp,
1925 MediaType::Png,
1926 MediaType::Jpeg,
1927 ]
1928 } else {
1929 &[
1930 MediaType::Avif,
1931 MediaType::Webp,
1932 MediaType::Jpeg,
1933 MediaType::Png,
1934 ]
1935 }
1936}
1937
1938fn match_accept_preferences(media_type: MediaType, preferences: &[AcceptPreference]) -> (u16, u8) {
1939 let mut best_q = 0_u16;
1940 let mut best_specificity = 0_u8;
1941
1942 for preference in preferences {
1943 if accept_range_matches(preference.range, media_type)
1944 && (preference.q_millis > best_q
1945 || (preference.q_millis == best_q && preference.specificity > best_specificity))
1946 {
1947 best_q = preference.q_millis;
1948 best_specificity = preference.specificity;
1949 }
1950 }
1951
1952 (best_q, best_specificity)
1953}
1954
1955fn accept_range_matches(range: AcceptRange, media_type: MediaType) -> bool {
1956 match range {
1957 AcceptRange::Exact(expected) => media_type.as_mime() == expected,
1958 AcceptRange::TypeWildcard(expected_type) => media_type
1959 .as_mime()
1960 .split('/')
1961 .next()
1962 .is_some_and(|actual_type| actual_type == expected_type),
1963 AcceptRange::Any => true,
1964 }
1965}
1966
1967fn build_image_etag(body: &[u8]) -> String {
1968 let digest = Sha256::digest(body);
1969 format!("\"sha256-{}\"", hex::encode(digest))
1970}
1971
1972#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1974enum CacheHitStatus {
1975 Hit,
1977 Miss,
1979 Disabled,
1981}
1982
1983fn build_image_response_headers(
1984 media_type: MediaType,
1985 etag: &str,
1986 response_policy: ImageResponsePolicy,
1987 negotiation_used: bool,
1988 cache_status: CacheHitStatus,
1989) -> Vec<(String, String)> {
1990 let mut headers = vec![
1991 (
1992 "Cache-Control".to_string(),
1993 match response_policy {
1994 ImageResponsePolicy::PublicGet => format!(
1995 "public, max-age={DEFAULT_PUBLIC_MAX_AGE_SECONDS}, stale-while-revalidate={DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS}"
1996 ),
1997 ImageResponsePolicy::PrivateTransform => "no-store".to_string(),
1998 },
1999 ),
2000 ("ETag".to_string(), etag.to_string()),
2001 ("X-Content-Type-Options".to_string(), "nosniff".to_string()),
2002 (
2003 "Content-Disposition".to_string(),
2004 format!("inline; filename=\"truss.{}\"", media_type.as_name()),
2005 ),
2006 ];
2007
2008 if negotiation_used {
2009 headers.push(("Vary".to_string(), "Accept".to_string()));
2010 }
2011
2012 if media_type == MediaType::Svg {
2015 headers.push(("Content-Security-Policy".to_string(), "sandbox".to_string()));
2016 }
2017
2018 let cache_status_value = match cache_status {
2020 CacheHitStatus::Hit => "\"truss\"; hit".to_string(),
2021 CacheHitStatus::Miss | CacheHitStatus::Disabled => "\"truss\"; fwd=miss".to_string(),
2022 };
2023 headers.push(("Cache-Status".to_string(), cache_status_value));
2024
2025 headers
2026}
2027
2028fn if_none_match_matches(value: Option<&str>, etag: &str) -> bool {
2029 let Some(value) = value else {
2030 return false;
2031 };
2032
2033 value
2034 .split(',')
2035 .map(str::trim)
2036 .any(|candidate| candidate == "*" || candidate == etag)
2037}
2038
2039fn parse_query_params(request: &HttpRequest) -> Result<BTreeMap<String, String>, HttpResponse> {
2040 let Some(query) = request.query() else {
2041 return Ok(BTreeMap::new());
2042 };
2043
2044 let mut params = BTreeMap::new();
2045 for (name, value) in url::form_urlencoded::parse(query.as_bytes()) {
2046 let name = name.into_owned();
2047 let value = value.into_owned();
2048 if params.insert(name.clone(), value).is_some() {
2049 return Err(bad_request_response(&format!(
2050 "query parameter `{name}` must not be repeated"
2051 )));
2052 }
2053 }
2054
2055 Ok(params)
2056}
2057
2058fn authorize_signed_request(
2059 request: &HttpRequest,
2060 query: &BTreeMap<String, String>,
2061 config: &ServerConfig,
2062) -> Result<(), HttpResponse> {
2063 let expected_key_id = config
2064 .signed_url_key_id
2065 .as_deref()
2066 .ok_or_else(|| service_unavailable_response("public signed URL key is not configured"))?;
2067 let secret = config.signed_url_secret.as_deref().ok_or_else(|| {
2068 service_unavailable_response("public signed URL secret is not configured")
2069 })?;
2070 let key_id = required_auth_query_param(query, "keyId")?;
2071 let expires = required_auth_query_param(query, "expires")?;
2072 let signature = required_auth_query_param(query, "signature")?;
2073
2074 if key_id != expected_key_id {
2075 return Err(signed_url_unauthorized_response(
2076 "signed URL is invalid or expired",
2077 ));
2078 }
2079
2080 let expires = expires.parse::<u64>().map_err(|_| {
2081 bad_request_response("query parameter `expires` must be a positive integer")
2082 })?;
2083 let now = std::time::SystemTime::now()
2084 .duration_since(std::time::UNIX_EPOCH)
2085 .map_err(|error| {
2086 internal_error_response(&format!("failed to read the current time: {error}"))
2087 })?
2088 .as_secs();
2089 if expires < now {
2090 return Err(signed_url_unauthorized_response(
2091 "signed URL is invalid or expired",
2092 ));
2093 }
2094
2095 let authority = canonical_request_authority(request, config)?;
2096 let canonical_query = canonical_query_without_signature(query);
2097 let canonical = format!(
2098 "{}\n{}\n{}\n{}",
2099 request.method,
2100 authority,
2101 request.path(),
2102 canonical_query
2103 );
2104
2105 let provided_signature = hex::decode(signature)
2106 .map_err(|_| signed_url_unauthorized_response("signed URL is invalid or expired"))?;
2107 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| {
2108 internal_error_response(&format!(
2109 "failed to initialize signed URL verification: {error}"
2110 ))
2111 })?;
2112 mac.update(canonical.as_bytes());
2113 mac.verify_slice(&provided_signature)
2114 .map_err(|_| signed_url_unauthorized_response("signed URL is invalid or expired"))
2115}
2116
2117fn canonical_request_authority(
2118 request: &HttpRequest,
2119 config: &ServerConfig,
2120) -> Result<String, HttpResponse> {
2121 if let Some(public_base_url) = &config.public_base_url {
2122 let parsed = Url::parse(public_base_url).map_err(|error| {
2123 internal_error_response(&format!(
2124 "configured public base URL is invalid at runtime: {error}"
2125 ))
2126 })?;
2127 return url_authority(&parsed).map_err(|message| internal_error_response(&message));
2128 }
2129
2130 request
2131 .header("host")
2132 .map(str::trim)
2133 .filter(|value| !value.is_empty())
2134 .map(str::to_string)
2135 .ok_or_else(|| bad_request_response("public GET requests require a Host header"))
2136}
2137
2138fn url_authority(url: &Url) -> Result<String, String> {
2139 let host = url
2140 .host_str()
2141 .ok_or_else(|| "configured public base URL must include a host".to_string())?;
2142 Ok(match url.port() {
2143 Some(port) => format!("{host}:{port}"),
2144 None => host.to_string(),
2145 })
2146}
2147
2148fn canonical_query_without_signature(query: &BTreeMap<String, String>) -> String {
2149 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
2150 for (name, value) in query {
2151 if name != "signature" {
2152 serializer.append_pair(name, value);
2153 }
2154 }
2155 serializer.finish()
2156}
2157
2158fn signed_source_query(source: SignedUrlSource) -> BTreeMap<String, String> {
2159 let mut query = BTreeMap::new();
2160 match source {
2161 SignedUrlSource::Path { path, version } => {
2162 query.insert("path".to_string(), path);
2163 if let Some(version) = version {
2164 query.insert("version".to_string(), version);
2165 }
2166 }
2167 SignedUrlSource::Url { url, version } => {
2168 query.insert("url".to_string(), url);
2169 if let Some(version) = version {
2170 query.insert("version".to_string(), version);
2171 }
2172 }
2173 }
2174 query
2175}
2176
2177fn extend_transform_query(query: &mut BTreeMap<String, String>, options: &TransformOptions) {
2178 if let Some(width) = options.width {
2179 query.insert("width".to_string(), width.to_string());
2180 }
2181 if let Some(height) = options.height {
2182 query.insert("height".to_string(), height.to_string());
2183 }
2184 if let Some(fit) = options.fit {
2185 query.insert("fit".to_string(), fit.as_name().to_string());
2186 }
2187 if let Some(position) = options.position {
2188 query.insert("position".to_string(), position.as_name().to_string());
2189 }
2190 if let Some(format) = options.format {
2191 query.insert("format".to_string(), format.as_name().to_string());
2192 }
2193 if let Some(quality) = options.quality {
2194 query.insert("quality".to_string(), quality.to_string());
2195 }
2196 if let Some(background) = options.background {
2197 query.insert("background".to_string(), encode_background(background));
2198 }
2199 if options.rotate != Rotation::Deg0 {
2200 query.insert(
2201 "rotate".to_string(),
2202 options.rotate.as_degrees().to_string(),
2203 );
2204 }
2205 if !options.auto_orient {
2206 query.insert("autoOrient".to_string(), "false".to_string());
2207 }
2208 if !options.strip_metadata {
2209 query.insert("stripMetadata".to_string(), "false".to_string());
2210 }
2211 if options.preserve_exif {
2212 query.insert("preserveExif".to_string(), "true".to_string());
2213 }
2214}
2215
2216fn encode_background(color: Rgba8) -> String {
2217 if color.a == u8::MAX {
2218 format!("{:02X}{:02X}{:02X}", color.r, color.g, color.b)
2219 } else {
2220 format!(
2221 "{:02X}{:02X}{:02X}{:02X}",
2222 color.r, color.g, color.b, color.a
2223 )
2224 }
2225}
2226
2227fn validate_public_query_names(
2228 query: &BTreeMap<String, String>,
2229 source_kind: PublicSourceKind,
2230) -> Result<(), HttpResponse> {
2231 for name in query.keys() {
2232 let allowed = matches!(
2233 name.as_str(),
2234 "keyId"
2235 | "expires"
2236 | "signature"
2237 | "version"
2238 | "width"
2239 | "height"
2240 | "fit"
2241 | "position"
2242 | "format"
2243 | "quality"
2244 | "background"
2245 | "rotate"
2246 | "autoOrient"
2247 | "stripMetadata"
2248 | "preserveExif"
2249 ) || matches!(
2250 (source_kind, name.as_str()),
2251 (PublicSourceKind::Path, "path") | (PublicSourceKind::Url, "url")
2252 );
2253
2254 if !allowed {
2255 return Err(bad_request_response(&format!(
2256 "query parameter `{name}` is not supported for this endpoint"
2257 )));
2258 }
2259 }
2260
2261 Ok(())
2262}
2263
2264fn required_query_param<'a>(
2265 query: &'a BTreeMap<String, String>,
2266 name: &str,
2267) -> Result<&'a str, HttpResponse> {
2268 query
2269 .get(name)
2270 .map(String::as_str)
2271 .filter(|value| !value.is_empty())
2272 .ok_or_else(|| bad_request_response(&format!("query parameter `{name}` is required")))
2273}
2274
2275fn required_auth_query_param<'a>(
2276 query: &'a BTreeMap<String, String>,
2277 name: &str,
2278) -> Result<&'a str, HttpResponse> {
2279 query
2280 .get(name)
2281 .map(String::as_str)
2282 .filter(|value| !value.is_empty())
2283 .ok_or_else(|| signed_url_unauthorized_response("signed URL is invalid or expired"))
2284}
2285
2286fn parse_optional_integer_query(
2287 query: &BTreeMap<String, String>,
2288 name: &str,
2289) -> Result<Option<u32>, HttpResponse> {
2290 match query.get(name) {
2291 Some(value) => value.parse::<u32>().map(Some).map_err(|_| {
2292 bad_request_response(&format!("query parameter `{name}` must be an integer"))
2293 }),
2294 None => Ok(None),
2295 }
2296}
2297
2298fn parse_optional_u8_query(
2299 query: &BTreeMap<String, String>,
2300 name: &str,
2301) -> Result<Option<u8>, HttpResponse> {
2302 match query.get(name) {
2303 Some(value) => value.parse::<u8>().map(Some).map_err(|_| {
2304 bad_request_response(&format!("query parameter `{name}` must be an integer"))
2305 }),
2306 None => Ok(None),
2307 }
2308}
2309
2310fn parse_optional_bool_query(
2311 query: &BTreeMap<String, String>,
2312 name: &str,
2313) -> Result<Option<bool>, HttpResponse> {
2314 match query.get(name).map(String::as_str) {
2315 Some("true") => Ok(Some(true)),
2316 Some("false") => Ok(Some(false)),
2317 Some(_) => Err(bad_request_response(&format!(
2318 "query parameter `{name}` must be `true` or `false`"
2319 ))),
2320 None => Ok(None),
2321 }
2322}
2323
2324fn parse_upload_request(
2325 body: &[u8],
2326 boundary: &str,
2327) -> Result<(Vec<u8>, TransformOptions), HttpResponse> {
2328 let parts = parse_multipart_form_data(body, boundary)?;
2329 let mut file_range = None;
2330 let mut options = None;
2331
2332 for part in &parts {
2333 match part.name.as_str() {
2334 "file" => {
2335 if file_range.is_some() {
2336 return Err(bad_request_response(
2337 "multipart upload must not include multiple `file` fields",
2338 ));
2339 }
2340 if part.body_range.is_empty() {
2341 return Err(bad_request_response(
2342 "multipart upload `file` field must not be empty",
2343 ));
2344 }
2345 file_range = Some(part.body_range.clone());
2346 }
2347 "options" => {
2348 if options.is_some() {
2349 return Err(bad_request_response(
2350 "multipart upload must not include multiple `options` fields",
2351 ));
2352 }
2353 let part_body = &body[part.body_range.clone()];
2354 if let Some(content_type) = part.content_type.as_deref()
2355 && !content_type_matches(content_type, "application/json")
2356 {
2357 return Err(bad_request_response(
2358 "multipart upload `options` field must use application/json when a content type is provided",
2359 ));
2360 }
2361 let payload = if part_body.is_empty() {
2362 TransformOptionsPayload::default()
2363 } else {
2364 serde_json::from_slice::<TransformOptionsPayload>(part_body).map_err(
2365 |error| {
2366 bad_request_response(&format!(
2367 "multipart upload `options` field must contain valid JSON: {error}"
2368 ))
2369 },
2370 )?
2371 };
2372 options = Some(payload.into_options()?);
2373 }
2374 field_name => {
2375 return Err(bad_request_response(&format!(
2376 "multipart upload contains an unsupported field `{field_name}`"
2377 )));
2378 }
2379 }
2380 }
2381
2382 let file_range = file_range
2383 .ok_or_else(|| bad_request_response("multipart upload requires a `file` field"))?;
2384
2385 Ok((body[file_range].to_vec(), options.unwrap_or_default()))
2386}
2387
2388fn record_http_metrics(route: RouteMetric, status: &str) {
2389 HTTP_REQUESTS_TOTAL.fetch_add(1, Ordering::Relaxed);
2390 route_counter(route).fetch_add(1, Ordering::Relaxed);
2391 status_counter(status).fetch_add(1, Ordering::Relaxed);
2392}
2393
2394fn render_metrics_text() -> String {
2395 let mut body = String::new();
2396 body.push_str(
2397 "# HELP truss_process_up Whether the server adapter considers the process alive.\n",
2398 );
2399 body.push_str("# TYPE truss_process_up gauge\n");
2400 body.push_str("truss_process_up 1\n");
2401
2402 body.push_str(
2403 "# HELP truss_transforms_in_flight Number of image transforms currently executing.\n",
2404 );
2405 body.push_str("# TYPE truss_transforms_in_flight gauge\n");
2406 body.push_str(&format!(
2407 "truss_transforms_in_flight {}\n",
2408 TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed)
2409 ));
2410
2411 body.push_str(
2412 "# HELP truss_transforms_max_concurrent Maximum allowed concurrent transforms.\n",
2413 );
2414 body.push_str("# TYPE truss_transforms_max_concurrent gauge\n");
2415 body.push_str(&format!(
2416 "truss_transforms_max_concurrent {MAX_CONCURRENT_TRANSFORMS}\n"
2417 ));
2418
2419 body.push_str("# HELP truss_http_requests_total Total parsed HTTP requests handled by the server adapter.\n");
2420 body.push_str("# TYPE truss_http_requests_total counter\n");
2421 body.push_str(&format!(
2422 "truss_http_requests_total {}\n",
2423 HTTP_REQUESTS_TOTAL.load(Ordering::Relaxed)
2424 ));
2425
2426 body.push_str(
2427 "# HELP truss_http_requests_by_route_total Total parsed HTTP requests handled by route.\n",
2428 );
2429 body.push_str("# TYPE truss_http_requests_by_route_total counter\n");
2430 for route in [
2431 RouteMetric::Health,
2432 RouteMetric::HealthLive,
2433 RouteMetric::HealthReady,
2434 RouteMetric::PublicByPath,
2435 RouteMetric::PublicByUrl,
2436 RouteMetric::Transform,
2437 RouteMetric::Upload,
2438 RouteMetric::Metrics,
2439 RouteMetric::Unknown,
2440 ] {
2441 body.push_str(&format!(
2442 "truss_http_requests_by_route_total{{route=\"{}\"}} {}\n",
2443 route.as_label(),
2444 route_counter(route).load(Ordering::Relaxed)
2445 ));
2446 }
2447
2448 body.push_str("# HELP truss_cache_hits_total Total transform cache hits.\n");
2449 body.push_str("# TYPE truss_cache_hits_total counter\n");
2450 body.push_str(&format!(
2451 "truss_cache_hits_total {}\n",
2452 CACHE_HITS_TOTAL.load(Ordering::Relaxed)
2453 ));
2454
2455 body.push_str("# HELP truss_cache_misses_total Total transform cache misses.\n");
2456 body.push_str("# TYPE truss_cache_misses_total counter\n");
2457 body.push_str(&format!(
2458 "truss_cache_misses_total {}\n",
2459 CACHE_MISSES_TOTAL.load(Ordering::Relaxed)
2460 ));
2461
2462 body.push_str("# HELP truss_origin_cache_hits_total Total origin response cache hits.\n");
2463 body.push_str("# TYPE truss_origin_cache_hits_total counter\n");
2464 body.push_str(&format!(
2465 "truss_origin_cache_hits_total {}\n",
2466 ORIGIN_CACHE_HITS_TOTAL.load(Ordering::Relaxed)
2467 ));
2468
2469 body.push_str("# HELP truss_origin_cache_misses_total Total origin response cache misses.\n");
2470 body.push_str("# TYPE truss_origin_cache_misses_total counter\n");
2471 body.push_str(&format!(
2472 "truss_origin_cache_misses_total {}\n",
2473 ORIGIN_CACHE_MISSES_TOTAL.load(Ordering::Relaxed)
2474 ));
2475
2476 body.push_str(
2477 "# HELP truss_http_responses_total Total HTTP responses emitted by status code.\n",
2478 );
2479 body.push_str("# TYPE truss_http_responses_total counter\n");
2480 for status in [
2481 "200", "400", "401", "403", "404", "406", "413", "415", "500", "501", "502", "503", "508",
2482 "other",
2483 ] {
2484 body.push_str(&format!(
2485 "truss_http_responses_total{{status=\"{status}\"}} {}\n",
2486 status_counter_value(status)
2487 ));
2488 }
2489
2490 body
2491}
2492
2493fn route_counter(route: RouteMetric) -> &'static AtomicU64 {
2494 match route {
2495 RouteMetric::Health => &HTTP_REQUESTS_HEALTH_TOTAL,
2496 RouteMetric::HealthLive => &HTTP_REQUESTS_HEALTH_LIVE_TOTAL,
2497 RouteMetric::HealthReady => &HTTP_REQUESTS_HEALTH_READY_TOTAL,
2498 RouteMetric::PublicByPath => &HTTP_REQUESTS_PUBLIC_BY_PATH_TOTAL,
2499 RouteMetric::PublicByUrl => &HTTP_REQUESTS_PUBLIC_BY_URL_TOTAL,
2500 RouteMetric::Transform => &HTTP_REQUESTS_TRANSFORM_TOTAL,
2501 RouteMetric::Upload => &HTTP_REQUESTS_UPLOAD_TOTAL,
2502 RouteMetric::Metrics => &HTTP_REQUESTS_METRICS_TOTAL,
2503 RouteMetric::Unknown => &HTTP_REQUESTS_UNKNOWN_TOTAL,
2504 }
2505}
2506
2507fn status_counter(status: &str) -> &'static AtomicU64 {
2508 match status_code(status) {
2509 Some("200") => &HTTP_RESPONSES_200_TOTAL,
2510 Some("400") => &HTTP_RESPONSES_400_TOTAL,
2511 Some("401") => &HTTP_RESPONSES_401_TOTAL,
2512 Some("403") => &HTTP_RESPONSES_403_TOTAL,
2513 Some("404") => &HTTP_RESPONSES_404_TOTAL,
2514 Some("406") => &HTTP_RESPONSES_406_TOTAL,
2515 Some("413") => &HTTP_RESPONSES_413_TOTAL,
2516 Some("415") => &HTTP_RESPONSES_415_TOTAL,
2517 Some("500") => &HTTP_RESPONSES_500_TOTAL,
2518 Some("501") => &HTTP_RESPONSES_501_TOTAL,
2519 Some("502") => &HTTP_RESPONSES_502_TOTAL,
2520 Some("503") => &HTTP_RESPONSES_503_TOTAL,
2521 Some("508") => &HTTP_RESPONSES_508_TOTAL,
2522 _ => &HTTP_RESPONSES_OTHER_TOTAL,
2523 }
2524}
2525
2526fn status_counter_value(status: &str) -> u64 {
2527 match status {
2528 "200" => HTTP_RESPONSES_200_TOTAL.load(Ordering::Relaxed),
2529 "400" => HTTP_RESPONSES_400_TOTAL.load(Ordering::Relaxed),
2530 "401" => HTTP_RESPONSES_401_TOTAL.load(Ordering::Relaxed),
2531 "403" => HTTP_RESPONSES_403_TOTAL.load(Ordering::Relaxed),
2532 "404" => HTTP_RESPONSES_404_TOTAL.load(Ordering::Relaxed),
2533 "406" => HTTP_RESPONSES_406_TOTAL.load(Ordering::Relaxed),
2534 "413" => HTTP_RESPONSES_413_TOTAL.load(Ordering::Relaxed),
2535 "415" => HTTP_RESPONSES_415_TOTAL.load(Ordering::Relaxed),
2536 "500" => HTTP_RESPONSES_500_TOTAL.load(Ordering::Relaxed),
2537 "501" => HTTP_RESPONSES_501_TOTAL.load(Ordering::Relaxed),
2538 "502" => HTTP_RESPONSES_502_TOTAL.load(Ordering::Relaxed),
2539 "503" => HTTP_RESPONSES_503_TOTAL.load(Ordering::Relaxed),
2540 "508" => HTTP_RESPONSES_508_TOTAL.load(Ordering::Relaxed),
2541 _ => HTTP_RESPONSES_OTHER_TOTAL.load(Ordering::Relaxed),
2542 }
2543}
2544
2545fn status_code(status: &str) -> Option<&str> {
2546 status.split_whitespace().next()
2547}
2548
2549fn authorize_request(request: &HttpRequest, config: &ServerConfig) -> Result<(), HttpResponse> {
2550 let expected = config.bearer_token.as_deref().ok_or_else(|| {
2551 service_unavailable_response("private API bearer token is not configured")
2552 })?;
2553 let provided = request
2554 .header("authorization")
2555 .and_then(|value| value.strip_prefix("Bearer "))
2556 .map(str::trim);
2557
2558 match provided {
2559 Some(token) if token == expected => Ok(()),
2560 _ => Err(auth_required_response("authorization required")),
2561 }
2562}
2563
2564fn parse_multipart_boundary(request: &HttpRequest) -> Result<String, HttpResponse> {
2565 let Some(content_type) = request.header("content-type") else {
2566 return Err(unsupported_media_type_response(
2567 "content-type must be multipart/form-data",
2568 ));
2569 };
2570
2571 let mut segments = content_type.split(';');
2572 let Some(media_type) = segments.next() else {
2573 return Err(unsupported_media_type_response(
2574 "content-type must be multipart/form-data",
2575 ));
2576 };
2577 if !content_type_matches(media_type, "multipart/form-data") {
2578 return Err(unsupported_media_type_response(
2579 "content-type must be multipart/form-data",
2580 ));
2581 }
2582
2583 for segment in segments {
2584 let Some((name, value)) = segment.split_once('=') else {
2585 return Err(bad_request_response(
2586 "multipart content-type parameters must use name=value syntax",
2587 ));
2588 };
2589 if name.trim().eq_ignore_ascii_case("boundary") {
2590 let boundary = value.trim().trim_matches('"');
2591 if boundary.is_empty() {
2592 return Err(bad_request_response(
2593 "multipart content-type boundary must not be empty",
2594 ));
2595 }
2596 return Ok(boundary.to_string());
2597 }
2598 }
2599
2600 Err(bad_request_response(
2601 "multipart content-type requires a boundary parameter",
2602 ))
2603}
2604
2605fn parse_multipart_form_data(
2606 body: &[u8],
2607 boundary: &str,
2608) -> Result<Vec<MultipartPart>, HttpResponse> {
2609 let opening = format!("--{boundary}").into_bytes();
2610 let delimiter = format!("\r\n--{boundary}").into_bytes();
2611
2612 if !body.starts_with(&opening) {
2613 return Err(bad_request_response(
2614 "multipart body does not start with the declared boundary",
2615 ));
2616 }
2617
2618 let mut cursor = 0;
2619 let mut parts = Vec::new();
2620
2621 loop {
2622 if !body[cursor..].starts_with(&opening) {
2623 return Err(bad_request_response(
2624 "multipart boundary sequence is malformed",
2625 ));
2626 }
2627 cursor += opening.len();
2628
2629 if body[cursor..].starts_with(b"--") {
2630 cursor += 2;
2631 if !body[cursor..].is_empty() && body[cursor..] != b"\r\n"[..] {
2632 return Err(bad_request_response(
2633 "multipart closing boundary has unexpected trailing data",
2634 ));
2635 }
2636 break;
2637 }
2638
2639 if !body[cursor..].starts_with(b"\r\n") {
2640 return Err(bad_request_response(
2641 "multipart boundary must be followed by CRLF",
2642 ));
2643 }
2644 cursor += 2;
2645
2646 let header_end = find_subslice(&body[cursor..], b"\r\n\r\n")
2647 .ok_or_else(|| bad_request_response("multipart part is missing a header terminator"))?;
2648 let header_bytes = &body[cursor..(cursor + header_end)];
2649 let headers = parse_part_headers(header_bytes)?;
2650 cursor += header_end + 4;
2651
2652 let body_end = find_subslice(&body[cursor..], &delimiter).ok_or_else(|| {
2653 bad_request_response("multipart part is missing the next boundary delimiter")
2654 })?;
2655 let body_range = cursor..(cursor + body_end);
2656 let part_name = parse_multipart_part_name(&headers)?;
2657 let content_type = header_value(&headers, "content-type").map(str::to_string);
2658 parts.push(MultipartPart {
2659 name: part_name,
2660 content_type,
2661 body_range,
2662 });
2663
2664 cursor += body_end + 2;
2665 }
2666
2667 Ok(parts)
2668}
2669
2670fn parse_part_headers(header_bytes: &[u8]) -> Result<Vec<(String, String)>, HttpResponse> {
2671 let header_text = std::str::from_utf8(header_bytes)
2672 .map_err(|_| bad_request_response("multipart part headers must be valid UTF-8"))?;
2673 parse_headers(header_text.split("\r\n"))
2674}
2675
2676fn parse_multipart_part_name(headers: &[(String, String)]) -> Result<String, HttpResponse> {
2677 let Some(disposition) = header_value(headers, "content-disposition") else {
2678 return Err(bad_request_response(
2679 "multipart part is missing a Content-Disposition header",
2680 ));
2681 };
2682
2683 let mut segments = disposition.split(';');
2684 let Some(kind) = segments.next() else {
2685 return Err(bad_request_response(
2686 "multipart Content-Disposition header is malformed",
2687 ));
2688 };
2689 if !kind.trim().eq_ignore_ascii_case("form-data") {
2690 return Err(bad_request_response(
2691 "multipart Content-Disposition header must use form-data",
2692 ));
2693 }
2694
2695 for segment in segments {
2696 let Some((name, value)) = segment.split_once('=') else {
2697 return Err(bad_request_response(
2698 "multipart Content-Disposition parameters must use name=value syntax",
2699 ));
2700 };
2701 if name.trim().eq_ignore_ascii_case("name") {
2702 let value = value.trim().trim_matches('"');
2703 if value.is_empty() {
2704 return Err(bad_request_response(
2705 "multipart part name must not be empty",
2706 ));
2707 }
2708 return Ok(value.to_string());
2709 }
2710 }
2711
2712 Err(bad_request_response(
2713 "multipart Content-Disposition header must include a name parameter",
2714 ))
2715}
2716
2717fn resolve_source_bytes(
2718 source: TransformSourcePayload,
2719 config: &ServerConfig,
2720) -> Result<Vec<u8>, HttpResponse> {
2721 match source {
2722 TransformSourcePayload::Path { path, .. } => {
2723 let path = resolve_storage_path(&config.storage_root, &path)?;
2724 let metadata = fs::metadata(&path).map_err(map_source_io_error)?;
2725 if metadata.len() > MAX_SOURCE_BYTES {
2726 return Err(payload_too_large_response("source file is too large"));
2727 }
2728
2729 fs::read(&path).map_err(map_source_io_error)
2730 }
2731 TransformSourcePayload::Url { url, .. } => read_remote_source_bytes(&url, config),
2732 }
2733}
2734
2735fn read_remote_source_bytes(url: &str, config: &ServerConfig) -> Result<Vec<u8>, HttpResponse> {
2736 let _ = prepare_remote_fetch_target(url, config)?;
2740
2741 let origin_cache = config
2743 .cache_root
2744 .as_ref()
2745 .map(|root| OriginCache::new(root).with_log_handler(config.log_handler.clone()));
2746
2747 if let Some(ref cache) = origin_cache
2748 && let Some(bytes) = cache.get(url)
2749 {
2750 ORIGIN_CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
2751 return Ok(bytes);
2752 }
2753
2754 if origin_cache.is_some() {
2755 ORIGIN_CACHE_MISSES_TOTAL.fetch_add(1, Ordering::Relaxed);
2756 }
2757
2758 let mut current_url = url.to_string();
2759
2760 for redirect_index in 0..=MAX_REMOTE_REDIRECTS {
2761 let target = prepare_remote_fetch_target(¤t_url, config)?;
2762 let agent = build_remote_agent(&target);
2763
2764 match agent.get(target.url.as_str()).call() {
2765 Ok(response) => {
2766 let status = response.status().as_u16();
2767 if is_redirect_status(status) {
2768 current_url = next_redirect_url(&target.url, &response, redirect_index)?;
2769 } else if status >= 400 {
2770 return Err(bad_gateway_response(&format!(
2771 "failed to fetch remote URL: upstream HTTP {status}"
2772 )));
2773 } else {
2774 let bytes = read_remote_response_body(target.url.as_str(), response)?;
2775 if let Some(cache) = origin_cache {
2776 cache.put(url, &bytes);
2777 }
2778 return Ok(bytes);
2779 }
2780 }
2781 Err(error) => {
2782 return Err(bad_gateway_response(&format!(
2783 "failed to fetch remote URL: {error}"
2784 )));
2785 }
2786 }
2787 }
2788
2789 Err(too_many_redirects_response(
2790 "remote URL exceeded the redirect limit",
2791 ))
2792}
2793
2794#[derive(Debug, Clone, PartialEq, Eq)]
2795struct RemoteFetchTarget {
2796 url: Url,
2797 netloc: String,
2798 addrs: Vec<SocketAddr>,
2799}
2800
2801#[derive(Debug, Clone, PartialEq, Eq)]
2802struct PinnedResolver {
2803 expected_netloc: String,
2804 addrs: Vec<SocketAddr>,
2805}
2806
2807impl ureq::unversioned::resolver::Resolver for PinnedResolver {
2808 fn resolve(
2809 &self,
2810 uri: &http::Uri,
2811 _config: &ureq::config::Config,
2812 _timeout: ureq::unversioned::transport::NextTimeout,
2813 ) -> Result<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error> {
2814 let authority = uri.authority().ok_or(ureq::Error::HostNotFound)?;
2815 let port = authority
2816 .port_u16()
2817 .or_else(|| match uri.scheme_str() {
2818 Some("https") => Some(443),
2819 Some("http") => Some(80),
2820 _ => None,
2821 })
2822 .ok_or(ureq::Error::HostNotFound)?;
2823 let requested_netloc = format!("{}:{}", authority.host(), port);
2824 if requested_netloc == self.expected_netloc {
2825 if self.addrs.is_empty() {
2826 return Err(ureq::Error::HostNotFound);
2827 }
2828 let mut result = ureq::unversioned::resolver::ResolvedSocketAddrs::from_fn(|_| {
2831 SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0)
2832 });
2833 for addr in self.addrs.iter().take(16) {
2834 result.push(*addr);
2835 }
2836 Ok(result)
2837 } else {
2838 Err(ureq::Error::HostNotFound)
2839 }
2840 }
2841}
2842
2843fn prepare_remote_fetch_target(
2844 value: &str,
2845 config: &ServerConfig,
2846) -> Result<RemoteFetchTarget, HttpResponse> {
2847 let url = parse_remote_url(value)?;
2848 let addrs = validate_remote_url(&url, config)?;
2849 let host = url
2850 .host_str()
2851 .ok_or_else(|| bad_request_response("remote URL must include a host"))?
2852 .to_string();
2853 let port = url
2854 .port_or_known_default()
2855 .ok_or_else(|| bad_request_response("remote URL must resolve to a known port"))?;
2856
2857 Ok(RemoteFetchTarget {
2858 url,
2859 netloc: format!("{host}:{port}"),
2860 addrs,
2861 })
2862}
2863
2864fn build_remote_agent(target: &RemoteFetchTarget) -> ureq::Agent {
2865 let config = ureq::config::Config::builder()
2866 .max_redirects(0)
2867 .http_status_as_error(false)
2868 .timeout_connect(Some(Duration::from_secs(10)))
2869 .timeout_recv_body(Some(Duration::from_secs(30)))
2870 .proxy(None)
2871 .max_idle_connections(0)
2872 .max_idle_connections_per_host(0)
2873 .build();
2874
2875 let resolver = PinnedResolver {
2878 expected_netloc: target.netloc.clone(),
2879 addrs: target.addrs.clone(),
2880 };
2881
2882 ureq::Agent::with_parts(
2883 config,
2884 ureq::unversioned::transport::DefaultConnector::default(),
2885 resolver,
2886 )
2887}
2888
2889fn next_redirect_url(
2890 current_url: &Url,
2891 response: &http::Response<ureq::Body>,
2892 redirect_index: usize,
2893) -> Result<String, HttpResponse> {
2894 if redirect_index == MAX_REMOTE_REDIRECTS {
2895 return Err(too_many_redirects_response(
2896 "remote URL exceeded the redirect limit",
2897 ));
2898 }
2899
2900 let location = response
2901 .headers()
2902 .get("Location")
2903 .and_then(|v: &http::HeaderValue| v.to_str().ok());
2904 let Some(location) = location else {
2905 return Err(bad_gateway_response(
2906 "remote redirect response is missing a Location header",
2907 ));
2908 };
2909 let next_url = current_url.join(location).map_err(|error| {
2910 bad_gateway_response(&format!(
2911 "remote redirect location could not be resolved: {error}"
2912 ))
2913 })?;
2914
2915 Ok(next_url.to_string())
2916}
2917
2918fn parse_remote_url(value: &str) -> Result<Url, HttpResponse> {
2919 Url::parse(value)
2920 .map_err(|error| bad_request_response(&format!("remote URL is invalid: {error}")))
2921}
2922
2923fn validate_remote_url(url: &Url, config: &ServerConfig) -> Result<Vec<SocketAddr>, HttpResponse> {
2924 match url.scheme() {
2925 "http" | "https" => {}
2926 _ => {
2927 return Err(bad_request_response(
2928 "remote URL must use the http or https scheme",
2929 ));
2930 }
2931 }
2932
2933 if !url.username().is_empty() || url.password().is_some() {
2934 return Err(bad_request_response(
2935 "remote URL must not embed user information",
2936 ));
2937 }
2938
2939 let Some(host) = url.host_str() else {
2940 return Err(bad_request_response("remote URL must include a host"));
2941 };
2942 let Some(port) = url.port_or_known_default() else {
2943 return Err(bad_request_response(
2944 "remote URL must resolve to a known port",
2945 ));
2946 };
2947
2948 if !config.allow_insecure_url_sources && port != 80 && port != 443 {
2949 return Err(forbidden_response(
2950 "remote URL port is not allowed by the current server policy",
2951 ));
2952 }
2953
2954 let addrs = url.socket_addrs(|| None).map_err(|error| {
2955 bad_gateway_response(&format!("failed to resolve remote host `{host}`: {error}"))
2956 })?;
2957 if addrs.is_empty() {
2958 return Err(bad_gateway_response(&format!(
2959 "failed to resolve remote host `{host}`"
2960 )));
2961 }
2962
2963 if !config.allow_insecure_url_sources
2964 && addrs
2965 .iter()
2966 .map(|addr| addr.ip())
2967 .any(is_disallowed_remote_ip)
2968 {
2969 return Err(forbidden_response(
2970 "remote URL resolves to a disallowed IP range",
2971 ));
2972 }
2973
2974 Ok(addrs)
2975}
2976
2977fn read_remote_response_body(
2978 url: &str,
2979 response: http::Response<ureq::Body>,
2980) -> Result<Vec<u8>, HttpResponse> {
2981 validate_remote_content_encoding(&response)?;
2982
2983 if response
2984 .headers()
2985 .get("Content-Length")
2986 .and_then(|v: &http::HeaderValue| v.to_str().ok())
2987 .and_then(|value: &str| value.parse::<u64>().ok())
2988 .is_some_and(|len| len > MAX_SOURCE_BYTES)
2989 {
2990 return Err(payload_too_large_response(&format!(
2991 "remote response exceeds {MAX_SOURCE_BYTES} bytes"
2992 )));
2993 }
2994
2995 let mut reader = response
2996 .into_body()
2997 .into_reader()
2998 .take(MAX_SOURCE_BYTES + 1);
2999 let mut bytes = Vec::new();
3000 reader.read_to_end(&mut bytes).map_err(|error| {
3001 bad_gateway_response(&format!("failed to read remote URL `{url}`: {error}"))
3002 })?;
3003
3004 if bytes.len() as u64 > MAX_SOURCE_BYTES {
3005 return Err(payload_too_large_response(&format!(
3006 "remote response exceeds {MAX_SOURCE_BYTES} bytes"
3007 )));
3008 }
3009
3010 Ok(bytes)
3011}
3012
3013fn validate_remote_content_encoding(
3014 response: &http::Response<ureq::Body>,
3015) -> Result<(), HttpResponse> {
3016 let Some(content_encoding) = response
3017 .headers()
3018 .get("Content-Encoding")
3019 .and_then(|v: &http::HeaderValue| v.to_str().ok())
3020 else {
3021 return Ok(());
3022 };
3023
3024 for encoding in content_encoding
3025 .split(',')
3026 .map(str::trim)
3027 .filter(|value| !value.is_empty())
3028 {
3029 if !matches!(encoding, "gzip" | "br" | "identity") {
3030 return Err(bad_gateway_response(&format!(
3031 "remote response uses unsupported content-encoding `{encoding}`"
3032 )));
3033 }
3034 }
3035
3036 Ok(())
3037}
3038
3039fn is_redirect_status(status: u16) -> bool {
3040 matches!(status, 301 | 302 | 303 | 307 | 308)
3041}
3042
3043fn is_disallowed_remote_ip(ip: IpAddr) -> bool {
3044 match ip {
3045 IpAddr::V4(ip) => is_disallowed_ipv4(ip),
3046 IpAddr::V6(ip) => is_disallowed_ipv6(ip),
3047 }
3048}
3049
3050fn is_disallowed_ipv4(ip: Ipv4Addr) -> bool {
3051 let octets = ip.octets();
3052 ip.is_private()
3053 || ip.is_loopback()
3054 || ip.is_link_local()
3055 || ip.is_broadcast()
3056 || ip.is_documentation()
3057 || ip.is_unspecified()
3058 || ip.is_multicast()
3059 || (octets[0] == 100 && (octets[1] & 0b1100_0000) == 64)
3060 || (octets[0] == 198 && matches!(octets[1], 18 | 19))
3061 || (octets[0] & 0b1111_0000) == 240
3062}
3063
3064fn is_disallowed_ipv6(ip: Ipv6Addr) -> bool {
3065 let segments = ip.segments();
3066 ip.is_loopback()
3067 || ip.is_unspecified()
3068 || ip.is_multicast()
3069 || ip.is_unique_local()
3070 || ip.is_unicast_link_local()
3071 || (segments[0] == 0x2001 && segments[1] == 0x0db8)
3072}
3073
3074fn env_flag(name: &str) -> bool {
3075 env::var(name)
3076 .map(|value| {
3077 matches!(
3078 value.as_str(),
3079 "1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON"
3080 )
3081 })
3082 .unwrap_or(false)
3083}
3084
3085fn validate_public_base_url(value: String) -> io::Result<String> {
3086 let parsed = Url::parse(&value).map_err(|error| {
3087 io::Error::new(
3088 io::ErrorKind::InvalidInput,
3089 format!("TRUSS_PUBLIC_BASE_URL must be a valid URL: {error}"),
3090 )
3091 })?;
3092
3093 match parsed.scheme() {
3094 "http" | "https" => Ok(parsed.to_string()),
3095 _ => Err(io::Error::new(
3096 io::ErrorKind::InvalidInput,
3097 "TRUSS_PUBLIC_BASE_URL must use http or https",
3098 )),
3099 }
3100}
3101
3102fn resolve_storage_path(storage_root: &Path, source_path: &str) -> Result<PathBuf, HttpResponse> {
3103 let trimmed = source_path.trim_start_matches('/');
3104 if trimmed.is_empty() {
3105 return Err(bad_request_response("source path must not be empty"));
3106 }
3107
3108 let mut relative_path = PathBuf::new();
3109 for component in Path::new(trimmed).components() {
3110 match component {
3111 Component::Normal(segment) => relative_path.push(segment),
3112 Component::CurDir
3113 | Component::ParentDir
3114 | Component::RootDir
3115 | Component::Prefix(_) => {
3116 return Err(bad_request_response(
3117 "source path must not contain root, current-directory, or parent-directory segments",
3118 ));
3119 }
3120 }
3121 }
3122
3123 if relative_path.as_os_str().is_empty() {
3124 return Err(bad_request_response("source path must not be empty"));
3125 }
3126
3127 let canonical_root = storage_root.canonicalize().map_err(|error| {
3128 internal_error_response(&format!("failed to resolve storage root: {error}"))
3129 })?;
3130 let candidate = storage_root.join(relative_path);
3131 let canonical_candidate = candidate.canonicalize().map_err(map_source_io_error)?;
3132
3133 if !canonical_candidate.starts_with(&canonical_root) {
3134 return Err(bad_request_response("source path escapes the storage root"));
3135 }
3136
3137 Ok(canonical_candidate)
3138}
3139
3140fn max_body_for_headers(headers: &[(String, String)]) -> usize {
3146 let is_multipart = headers.iter().any(|(name, value)| {
3147 name == "content-type" && content_type_matches(value, "multipart/form-data")
3148 });
3149 if is_multipart {
3150 MAX_UPLOAD_BODY_BYTES
3151 } else {
3152 MAX_REQUEST_BODY_BYTES
3153 }
3154}
3155
3156fn read_request<R>(stream: &mut R) -> Result<HttpRequest, HttpResponse>
3157where
3158 R: Read,
3159{
3160 let mut buffer = Vec::new();
3161 let mut chunk = [0_u8; 4096];
3162 let header_read_limit = MAX_HEADER_BYTES + MAX_UPLOAD_BODY_BYTES;
3166 let header_end = loop {
3167 let read = stream.read(&mut chunk).map_err(|error| {
3168 internal_error_response(&format!("failed to read request: {error}"))
3169 })?;
3170 if read == 0 {
3171 return Err(bad_request_response(
3172 "request ended before the HTTP headers were complete",
3173 ));
3174 }
3175
3176 buffer.extend_from_slice(&chunk[..read]);
3177
3178 if buffer.len() > header_read_limit {
3179 return Err(payload_too_large_response("request is too large"));
3180 }
3181
3182 if let Some(index) = find_header_terminator(&buffer) {
3183 break index;
3184 }
3185
3186 if buffer.len() > MAX_HEADER_BYTES {
3187 return Err(payload_too_large_response("request headers are too large"));
3188 }
3189 };
3190
3191 let header_text = std::str::from_utf8(&buffer[..header_end])
3192 .map_err(|_| bad_request_response("request headers must be valid UTF-8"))?;
3193 let mut lines = header_text.split("\r\n");
3194 let request_line = lines.next().unwrap_or_default();
3195 let (method, target, version) = parse_request_line(request_line)?;
3196 let headers = parse_headers(lines)?;
3197 let content_length = parse_content_length(&headers)?;
3198 let max_body = max_body_for_headers(&headers);
3199 if content_length > max_body {
3200 return Err(payload_too_large_response("request body is too large"));
3201 }
3202
3203 let mut body = buffer[(header_end + 4)..].to_vec();
3204 while body.len() < content_length {
3205 let read = stream.read(&mut chunk).map_err(|error| {
3206 internal_error_response(&format!("failed to read request: {error}"))
3207 })?;
3208 if read == 0 {
3209 return Err(bad_request_response("request body was truncated"));
3210 }
3211 body.extend_from_slice(&chunk[..read]);
3212 }
3213 body.truncate(content_length);
3214
3215 Ok(HttpRequest {
3216 method,
3217 target,
3218 version,
3219 headers,
3220 body,
3221 })
3222}
3223
3224fn parse_request_line(request_line: &str) -> Result<(String, String, String), HttpResponse> {
3225 let mut parts = request_line.split_whitespace();
3226 let method = parts
3227 .next()
3228 .ok_or_else(|| bad_request_response("request line is missing an HTTP method"))?;
3229 let target = parts
3230 .next()
3231 .ok_or_else(|| bad_request_response("request line is missing a target path"))?;
3232 let version = parts
3233 .next()
3234 .ok_or_else(|| bad_request_response("request line is missing an HTTP version"))?;
3235
3236 if parts.next().is_some() {
3237 return Err(bad_request_response("request line has too many fields"));
3238 }
3239
3240 Ok((method.to_string(), target.to_string(), version.to_string()))
3241}
3242
3243const SINGLETON_HEADERS: &[&str] = &[
3247 "host",
3248 "authorization",
3249 "content-length",
3250 "content-type",
3251 "transfer-encoding",
3252];
3253
3254fn parse_headers<'a, I>(lines: I) -> Result<Vec<(String, String)>, HttpResponse>
3255where
3256 I: Iterator<Item = &'a str>,
3257{
3258 let mut headers = Vec::new();
3259
3260 for line in lines {
3261 if line.is_empty() {
3262 continue;
3263 }
3264
3265 let Some((name, value)) = line.split_once(':') else {
3266 return Err(bad_request_response(
3267 "request headers must use `name: value` syntax",
3268 ));
3269 };
3270
3271 headers.push((name.trim().to_ascii_lowercase(), value.trim().to_string()));
3272 }
3273
3274 for &singleton in SINGLETON_HEADERS {
3275 let count = headers.iter().filter(|(name, _)| name == singleton).count();
3276 if count > 1 {
3277 return Err(bad_request_response(&format!(
3278 "duplicate `{singleton}` header is not allowed"
3279 )));
3280 }
3281 }
3282
3283 Ok(headers)
3284}
3285
3286fn parse_content_length(headers: &[(String, String)]) -> Result<usize, HttpResponse> {
3287 let Some(value) = headers
3290 .iter()
3291 .find_map(|(name, value)| (name == "content-length").then_some(value.as_str()))
3292 else {
3293 return Ok(0);
3294 };
3295
3296 value
3297 .parse::<usize>()
3298 .map_err(|_| bad_request_response("content-length must be a non-negative integer"))
3299}
3300
3301fn request_has_json_content_type(request: &HttpRequest) -> bool {
3302 request
3303 .header("content-type")
3304 .is_some_and(|value| content_type_matches(value, "application/json"))
3305}
3306
3307fn transform_error_response(error: TransformError) -> HttpResponse {
3308 match error {
3309 TransformError::InvalidInput(reason)
3310 | TransformError::InvalidOptions(reason)
3311 | TransformError::DecodeFailed(reason) => bad_request_response(&reason),
3312 TransformError::UnsupportedInputMediaType(reason) => {
3313 unsupported_media_type_response(&reason)
3314 }
3315 TransformError::UnsupportedOutputMediaType(media_type) => unsupported_media_type_response(
3316 &format!("output format `{}` is not supported", media_type.as_name()),
3317 ),
3318 TransformError::EncodeFailed(reason) => {
3319 internal_error_response(&format!("failed to encode transformed artifact: {reason}"))
3320 }
3321 TransformError::CapabilityMissing(reason) => not_implemented_response(&reason),
3322 TransformError::LimitExceeded(reason) => payload_too_large_response(&reason),
3323 }
3324}
3325
3326fn map_source_io_error(error: io::Error) -> HttpResponse {
3327 match error.kind() {
3328 io::ErrorKind::NotFound => not_found_response("source artifact was not found"),
3329 _ => internal_error_response(&format!("failed to access source artifact: {error}")),
3330 }
3331}
3332
3333fn parse_optional_named<T, F>(
3334 value: Option<&str>,
3335 field_name: &str,
3336 parser: F,
3337) -> Result<Option<T>, HttpResponse>
3338where
3339 F: Fn(&str) -> Result<T, String>,
3340{
3341 match value {
3342 Some(value) => parse_named(value, field_name, parser).map(Some),
3343 None => Ok(None),
3344 }
3345}
3346
3347fn parse_named<T, F>(value: &str, field_name: &str, parser: F) -> Result<T, HttpResponse>
3348where
3349 F: Fn(&str) -> Result<T, String>,
3350{
3351 parser(value).map_err(|reason| bad_request_response(&format!("{field_name}: {reason}")))
3352}
3353
3354fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
3355 headers
3356 .iter()
3357 .find_map(|(header_name, value)| (header_name == name).then_some(value.as_str()))
3358}
3359
3360fn content_type_matches(value: &str, expected: &str) -> bool {
3361 value
3362 .split(';')
3363 .next()
3364 .map(str::trim)
3365 .is_some_and(|value| value.eq_ignore_ascii_case(expected))
3366}
3367
3368fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
3369 haystack
3370 .windows(needle.len())
3371 .position(|window| window == needle)
3372}
3373
3374fn find_header_terminator(buffer: &[u8]) -> Option<usize> {
3375 buffer.windows(4).position(|window| window == b"\r\n\r\n")
3376}
3377
3378fn write_response(stream: &mut TcpStream, response: HttpResponse) -> io::Result<()> {
3379 let mut header = format!(
3380 "HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n",
3381 response.status,
3382 response.body.len()
3383 );
3384
3385 if let Some(content_type) = response.content_type {
3386 header.push_str(&format!("Content-Type: {content_type}\r\n"));
3387 }
3388
3389 for (name, value) in response.headers {
3390 header.push_str(&format!("{name}: {value}\r\n"));
3391 }
3392
3393 header.push_str("\r\n");
3394
3395 stream.write_all(header.as_bytes())?;
3396 stream.write_all(&response.body)?;
3397 stream.flush()
3398}
3399
3400fn bad_request_response(message: &str) -> HttpResponse {
3401 problem_response("400 Bad Request", 400, "Bad Request", message)
3402}
3403
3404fn auth_required_response(message: &str) -> HttpResponse {
3405 HttpResponse::problem_with_headers(
3406 "401 Unauthorized",
3407 vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3408 problem_detail_body(401, "Unauthorized", message),
3409 )
3410}
3411
3412fn signed_url_unauthorized_response(message: &str) -> HttpResponse {
3413 problem_response("401 Unauthorized", 401, "Unauthorized", message)
3414}
3415
3416fn not_found_response(message: &str) -> HttpResponse {
3417 problem_response("404 Not Found", 404, "Not Found", message)
3418}
3419
3420fn forbidden_response(message: &str) -> HttpResponse {
3421 problem_response("403 Forbidden", 403, "Forbidden", message)
3422}
3423
3424fn unsupported_media_type_response(message: &str) -> HttpResponse {
3425 problem_response(
3426 "415 Unsupported Media Type",
3427 415,
3428 "Unsupported Media Type",
3429 message,
3430 )
3431}
3432
3433fn not_acceptable_response(message: &str) -> HttpResponse {
3434 problem_response("406 Not Acceptable", 406, "Not Acceptable", message)
3435}
3436
3437fn payload_too_large_response(message: &str) -> HttpResponse {
3438 problem_response("413 Payload Too Large", 413, "Payload Too Large", message)
3439}
3440
3441fn internal_error_response(message: &str) -> HttpResponse {
3442 problem_response(
3443 "500 Internal Server Error",
3444 500,
3445 "Internal Server Error",
3446 message,
3447 )
3448}
3449
3450fn bad_gateway_response(message: &str) -> HttpResponse {
3451 problem_response("502 Bad Gateway", 502, "Bad Gateway", message)
3452}
3453
3454fn service_unavailable_response(message: &str) -> HttpResponse {
3455 problem_response(
3456 "503 Service Unavailable",
3457 503,
3458 "Service Unavailable",
3459 message,
3460 )
3461}
3462
3463fn too_many_redirects_response(message: &str) -> HttpResponse {
3464 problem_response("508 Loop Detected", 508, "Loop Detected", message)
3465}
3466
3467fn not_implemented_response(message: &str) -> HttpResponse {
3468 problem_response("501 Not Implemented", 501, "Not Implemented", message)
3469}
3470
3471fn problem_response(
3478 status: &'static str,
3479 status_code: u16,
3480 title: &str,
3481 detail: &str,
3482) -> HttpResponse {
3483 HttpResponse::problem(status, problem_detail_body(status_code, title, detail))
3484}
3485
3486fn problem_detail_body(status: u16, title: &str, detail: &str) -> Vec<u8> {
3488 let mut body = serde_json::to_vec(&json!({
3489 "type": "about:blank",
3490 "title": title,
3491 "status": status,
3492 "detail": detail,
3493 }))
3494 .expect("serialize problem detail body");
3495 body.push(b'\n');
3496 body
3497}
3498
3499#[cfg(test)]
3500mod tests {
3501 use super::{
3502 CacheHitStatus, DEFAULT_BIND_ADDR, HttpRequest, ImageResponsePolicy,
3503 MAX_CONCURRENT_TRANSFORMS, PinnedResolver, PublicSourceKind, ServerConfig, SignedUrlSource,
3504 TRANSFORMS_IN_FLIGHT, TransformSourcePayload, auth_required_response,
3505 authorize_signed_request, bad_request_response, bind_addr, build_image_etag,
3506 build_image_response_headers, canonical_query_without_signature, find_header_terminator,
3507 negotiate_output_format, parse_public_get_request, prepare_remote_fetch_target,
3508 read_request, resolve_storage_path, route_request, serve_once_with_config, sign_public_url,
3509 transform_source_bytes,
3510 };
3511 use crate::{
3512 Artifact, ArtifactMetadata, MediaType, RawArtifact, TransformOptions, sniff_artifact,
3513 };
3514 use hmac::{Hmac, Mac};
3515 use image::codecs::png::PngEncoder;
3516 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
3517 use sha2::Sha256;
3518 use std::collections::BTreeMap;
3519 use std::fs;
3520 use std::io::{Cursor, Read, Write};
3521 use std::net::{SocketAddr, TcpListener, TcpStream};
3522 use std::path::{Path, PathBuf};
3523 use std::sync::atomic::Ordering;
3524 use std::thread;
3525 use std::time::{Duration, SystemTime, UNIX_EPOCH};
3526
3527 fn png_bytes() -> Vec<u8> {
3528 let image = RgbaImage::from_pixel(4, 3, Rgba([10, 20, 30, 255]));
3529 let mut bytes = Vec::new();
3530 PngEncoder::new(&mut bytes)
3531 .write_image(&image, 4, 3, ColorType::Rgba8.into())
3532 .expect("encode png");
3533 bytes
3534 }
3535
3536 fn temp_dir(name: &str) -> PathBuf {
3537 let unique = SystemTime::now()
3538 .duration_since(UNIX_EPOCH)
3539 .expect("current time")
3540 .as_nanos();
3541 let path = std::env::temp_dir().join(format!("truss-server-{name}-{unique}"));
3542 fs::create_dir_all(&path).expect("create temp dir");
3543 path
3544 }
3545
3546 fn write_png(path: &Path) {
3547 fs::write(path, png_bytes()).expect("write png fixture");
3548 }
3549
3550 fn artifact_with_alpha(has_alpha: bool) -> Artifact {
3551 Artifact::new(
3552 png_bytes(),
3553 MediaType::Png,
3554 ArtifactMetadata {
3555 width: Some(4),
3556 height: Some(3),
3557 frame_count: 1,
3558 duration: None,
3559 has_alpha: Some(has_alpha),
3560 },
3561 )
3562 }
3563
3564 fn sign_public_query(
3565 method: &str,
3566 authority: &str,
3567 path: &str,
3568 query: &BTreeMap<String, String>,
3569 secret: &str,
3570 ) -> String {
3571 let canonical = format!(
3572 "{method}\n{authority}\n{path}\n{}",
3573 canonical_query_without_signature(query)
3574 );
3575 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("create hmac");
3576 mac.update(canonical.as_bytes());
3577 hex::encode(mac.finalize().into_bytes())
3578 }
3579
3580 type FixtureResponse = (String, Vec<(String, String)>, Vec<u8>);
3581
3582 fn read_fixture_request(stream: &mut TcpStream) {
3583 stream
3584 .set_nonblocking(false)
3585 .expect("configure fixture stream blocking mode");
3586 stream
3587 .set_read_timeout(Some(Duration::from_millis(100)))
3588 .expect("configure fixture stream timeout");
3589
3590 let deadline = std::time::Instant::now() + Duration::from_secs(2);
3591 let mut buffer = Vec::new();
3592 let mut chunk = [0_u8; 1024];
3593 let header_end = loop {
3594 let read = match stream.read(&mut chunk) {
3595 Ok(read) => read,
3596 Err(error)
3597 if matches!(
3598 error.kind(),
3599 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
3600 ) && std::time::Instant::now() < deadline =>
3601 {
3602 thread::sleep(Duration::from_millis(10));
3603 continue;
3604 }
3605 Err(error) => panic!("read fixture request headers: {error}"),
3606 };
3607 if read == 0 {
3608 panic!("fixture request ended before headers were complete");
3609 }
3610 buffer.extend_from_slice(&chunk[..read]);
3611 if let Some(index) = find_header_terminator(&buffer) {
3612 break index;
3613 }
3614 };
3615
3616 let header_text = std::str::from_utf8(&buffer[..header_end]).expect("fixture request utf8");
3617 let content_length = header_text
3618 .split("\r\n")
3619 .filter_map(|line| line.split_once(':'))
3620 .find_map(|(name, value)| {
3621 name.trim()
3622 .eq_ignore_ascii_case("content-length")
3623 .then_some(value.trim())
3624 })
3625 .map(|value| {
3626 value
3627 .parse::<usize>()
3628 .expect("fixture content-length should be numeric")
3629 })
3630 .unwrap_or(0);
3631
3632 let mut body = buffer.len().saturating_sub(header_end + 4);
3633 while body < content_length {
3634 let read = match stream.read(&mut chunk) {
3635 Ok(read) => read,
3636 Err(error)
3637 if matches!(
3638 error.kind(),
3639 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
3640 ) && std::time::Instant::now() < deadline =>
3641 {
3642 thread::sleep(Duration::from_millis(10));
3643 continue;
3644 }
3645 Err(error) => panic!("read fixture request body: {error}"),
3646 };
3647 if read == 0 {
3648 panic!("fixture request body was truncated");
3649 }
3650 body += read;
3651 }
3652 }
3653
3654 fn spawn_http_server(responses: Vec<FixtureResponse>) -> (String, thread::JoinHandle<()>) {
3655 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture server");
3656 listener
3657 .set_nonblocking(true)
3658 .expect("configure fixture server");
3659 let addr = listener.local_addr().expect("fixture server addr");
3660 let url = format!("http://{addr}/image");
3661
3662 let handle = thread::spawn(move || {
3663 for (status, headers, body) in responses {
3664 let deadline = std::time::Instant::now() + Duration::from_secs(10);
3665 let mut accepted = None;
3666 while std::time::Instant::now() < deadline {
3667 match listener.accept() {
3668 Ok(stream) => {
3669 accepted = Some(stream);
3670 break;
3671 }
3672 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
3673 thread::sleep(Duration::from_millis(10));
3674 }
3675 Err(error) => panic!("accept fixture request: {error}"),
3676 }
3677 }
3678
3679 let Some((mut stream, _)) = accepted else {
3680 break;
3681 };
3682 read_fixture_request(&mut stream);
3683 let mut header = format!(
3684 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
3685 body.len()
3686 );
3687 for (name, value) in headers {
3688 header.push_str(&format!("{name}: {value}\r\n"));
3689 }
3690 header.push_str("\r\n");
3691 stream
3692 .write_all(header.as_bytes())
3693 .expect("write fixture headers");
3694 stream.write_all(&body).expect("write fixture body");
3695 stream.flush().expect("flush fixture response");
3696 }
3697 });
3698
3699 (url, handle)
3700 }
3701
3702 fn transform_request(path: &str) -> HttpRequest {
3703 HttpRequest {
3704 method: "POST".to_string(),
3705 target: "/images:transform".to_string(),
3706 version: "HTTP/1.1".to_string(),
3707 headers: vec![
3708 ("authorization".to_string(), "Bearer secret".to_string()),
3709 ("content-type".to_string(), "application/json".to_string()),
3710 ],
3711 body: format!(
3712 "{{\"source\":{{\"kind\":\"path\",\"path\":\"{path}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
3713 )
3714 .into_bytes(),
3715 }
3716 }
3717
3718 fn transform_url_request(url: &str) -> HttpRequest {
3719 HttpRequest {
3720 method: "POST".to_string(),
3721 target: "/images:transform".to_string(),
3722 version: "HTTP/1.1".to_string(),
3723 headers: vec![
3724 ("authorization".to_string(), "Bearer secret".to_string()),
3725 ("content-type".to_string(), "application/json".to_string()),
3726 ],
3727 body: format!(
3728 "{{\"source\":{{\"kind\":\"url\",\"url\":\"{url}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
3729 )
3730 .into_bytes(),
3731 }
3732 }
3733
3734 fn upload_request(file_bytes: &[u8], options_json: Option<&str>) -> HttpRequest {
3735 let boundary = "truss-test-boundary";
3736 let mut body = Vec::new();
3737 body.extend_from_slice(
3738 format!(
3739 "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"image.png\"\r\nContent-Type: image/png\r\n\r\n"
3740 )
3741 .as_bytes(),
3742 );
3743 body.extend_from_slice(file_bytes);
3744 body.extend_from_slice(b"\r\n");
3745
3746 if let Some(options_json) = options_json {
3747 body.extend_from_slice(
3748 format!(
3749 "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{options_json}\r\n"
3750 )
3751 .as_bytes(),
3752 );
3753 }
3754
3755 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
3756
3757 HttpRequest {
3758 method: "POST".to_string(),
3759 target: "/images".to_string(),
3760 version: "HTTP/1.1".to_string(),
3761 headers: vec![
3762 ("authorization".to_string(), "Bearer secret".to_string()),
3763 (
3764 "content-type".to_string(),
3765 format!("multipart/form-data; boundary={boundary}"),
3766 ),
3767 ],
3768 body,
3769 }
3770 }
3771
3772 fn metrics_request(with_auth: bool) -> HttpRequest {
3773 let mut headers = Vec::new();
3774 if with_auth {
3775 headers.push(("authorization".to_string(), "Bearer secret".to_string()));
3776 }
3777
3778 HttpRequest {
3779 method: "GET".to_string(),
3780 target: "/metrics".to_string(),
3781 version: "HTTP/1.1".to_string(),
3782 headers,
3783 body: Vec::new(),
3784 }
3785 }
3786
3787 fn response_body(response: &super::HttpResponse) -> String {
3788 String::from_utf8(response.body.clone()).expect("utf8 response body")
3789 }
3790
3791 fn signed_public_request(target: &str, host: &str, secret: &str) -> HttpRequest {
3792 let (path, query) = target.split_once('?').expect("target has query");
3793 let mut query = url::form_urlencoded::parse(query.as_bytes())
3794 .into_owned()
3795 .collect::<BTreeMap<_, _>>();
3796 let signature = sign_public_query("GET", host, path, &query, secret);
3797 query.insert("signature".to_string(), signature);
3798 let final_query = url::form_urlencoded::Serializer::new(String::new())
3799 .extend_pairs(
3800 query
3801 .iter()
3802 .map(|(name, value)| (name.as_str(), value.as_str())),
3803 )
3804 .finish();
3805
3806 HttpRequest {
3807 method: "GET".to_string(),
3808 target: format!("{path}?{final_query}"),
3809 version: "HTTP/1.1".to_string(),
3810 headers: vec![("host".to_string(), host.to_string())],
3811 body: Vec::new(),
3812 }
3813 }
3814
3815 #[test]
3816 fn uses_default_bind_addr_when_env_is_missing() {
3817 unsafe { std::env::remove_var("TRUSS_BIND_ADDR") };
3820 assert_eq!(bind_addr(), DEFAULT_BIND_ADDR);
3821 }
3822
3823 #[test]
3824 fn authorize_signed_request_accepts_a_valid_signature() {
3825 let request = signed_public_request(
3826 "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
3827 "assets.example.com",
3828 "secret-value",
3829 );
3830 let query = super::parse_query_params(&request).expect("parse query");
3831 let config = ServerConfig::new(temp_dir("public-auth"), None)
3832 .with_signed_url_credentials("public-dev", "secret-value");
3833
3834 authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
3835 }
3836
3837 #[test]
3838 fn authorize_signed_request_uses_public_base_url_authority() {
3839 let request = signed_public_request(
3840 "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
3841 "cdn.example.com",
3842 "secret-value",
3843 );
3844 let query = super::parse_query_params(&request).expect("parse query");
3845 let mut config = ServerConfig::new(temp_dir("public-authority"), None)
3846 .with_signed_url_credentials("public-dev", "secret-value");
3847 config.public_base_url = Some("https://cdn.example.com".to_string());
3848
3849 authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
3850 }
3851
3852 #[test]
3853 fn negotiate_output_format_prefers_alpha_safe_formats_for_transparent_inputs() {
3854 let format =
3855 negotiate_output_format(Some("image/jpeg,image/png"), &artifact_with_alpha(true))
3856 .expect("negotiate output format")
3857 .expect("resolved output format");
3858
3859 assert_eq!(format, MediaType::Png);
3860 }
3861
3862 #[test]
3863 fn negotiate_output_format_prefers_avif_for_wildcard_accept() {
3864 let format = negotiate_output_format(Some("image/*"), &artifact_with_alpha(false))
3865 .expect("negotiate output format")
3866 .expect("resolved output format");
3867
3868 assert_eq!(format, MediaType::Avif);
3869 }
3870
3871 #[test]
3872 fn build_image_response_headers_include_cache_and_safety_metadata() {
3873 let headers = build_image_response_headers(
3874 MediaType::Webp,
3875 &build_image_etag(b"demo"),
3876 ImageResponsePolicy::PublicGet,
3877 true,
3878 CacheHitStatus::Disabled,
3879 );
3880
3881 assert!(headers.contains(&(
3882 "Cache-Control".to_string(),
3883 "public, max-age=3600, stale-while-revalidate=60".to_string()
3884 )));
3885 assert!(headers.contains(&("Vary".to_string(), "Accept".to_string())));
3886 assert!(headers.contains(&("X-Content-Type-Options".to_string(), "nosniff".to_string())));
3887 assert!(headers.contains(&(
3888 "Content-Disposition".to_string(),
3889 "inline; filename=\"truss.webp\"".to_string()
3890 )));
3891 assert!(headers.contains(&(
3892 "Cache-Status".to_string(),
3893 "\"truss\"; fwd=miss".to_string()
3894 )));
3895 }
3896
3897 #[test]
3898 fn build_image_response_headers_include_csp_sandbox_for_svg() {
3899 let headers = build_image_response_headers(
3900 MediaType::Svg,
3901 &build_image_etag(b"svg-data"),
3902 ImageResponsePolicy::PublicGet,
3903 true,
3904 CacheHitStatus::Disabled,
3905 );
3906
3907 assert!(headers.contains(&("Content-Security-Policy".to_string(), "sandbox".to_string())));
3908 }
3909
3910 #[test]
3911 fn build_image_response_headers_omit_csp_sandbox_for_raster() {
3912 let headers = build_image_response_headers(
3913 MediaType::Png,
3914 &build_image_etag(b"png-data"),
3915 ImageResponsePolicy::PublicGet,
3916 true,
3917 CacheHitStatus::Disabled,
3918 );
3919
3920 assert!(!headers.iter().any(|(k, _)| k == "Content-Security-Policy"));
3921 }
3922
3923 #[test]
3924 fn backpressure_rejects_when_at_capacity() {
3925 TRANSFORMS_IN_FLIGHT.store(MAX_CONCURRENT_TRANSFORMS, Ordering::Relaxed);
3927
3928 let request = HttpRequest {
3929 method: "POST".to_string(),
3930 target: "/transform".to_string(),
3931 version: "HTTP/1.1".to_string(),
3932 headers: Vec::new(),
3933 body: Vec::new(),
3934 };
3935
3936 let png_bytes = {
3937 let mut buf = Vec::new();
3938 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
3939 encoder
3940 .write_image(&[255, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8)
3941 .unwrap();
3942 buf
3943 };
3944
3945 let config = ServerConfig::new(std::env::temp_dir(), None);
3946 let response = transform_source_bytes(
3947 png_bytes,
3948 TransformOptions::default(),
3949 None,
3950 &request,
3951 ImageResponsePolicy::PrivateTransform,
3952 &config,
3953 );
3954
3955 assert!(response.status.contains("503"));
3957
3958 assert_eq!(
3960 TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed),
3961 MAX_CONCURRENT_TRANSFORMS
3962 );
3963
3964 TRANSFORMS_IN_FLIGHT.store(0, Ordering::Relaxed);
3966 }
3967
3968 #[test]
3969 fn compute_cache_key_is_deterministic() {
3970 let opts = TransformOptions {
3971 width: Some(300),
3972 height: Some(200),
3973 format: Some(MediaType::Webp),
3974 ..TransformOptions::default()
3975 };
3976 let key1 = super::compute_cache_key("source-abc", &opts, None);
3977 let key2 = super::compute_cache_key("source-abc", &opts, None);
3978 assert_eq!(key1, key2);
3979 assert_eq!(key1.len(), 64); }
3981
3982 #[test]
3983 fn compute_cache_key_differs_for_different_options() {
3984 let opts1 = TransformOptions {
3985 width: Some(300),
3986 format: Some(MediaType::Webp),
3987 ..TransformOptions::default()
3988 };
3989 let opts2 = TransformOptions {
3990 width: Some(400),
3991 format: Some(MediaType::Webp),
3992 ..TransformOptions::default()
3993 };
3994 let key1 = super::compute_cache_key("same-source", &opts1, None);
3995 let key2 = super::compute_cache_key("same-source", &opts2, None);
3996 assert_ne!(key1, key2);
3997 }
3998
3999 #[test]
4000 fn compute_cache_key_includes_accept_when_present() {
4001 let opts = TransformOptions::default();
4002 let key_no_accept = super::compute_cache_key("src", &opts, None);
4003 let key_with_accept = super::compute_cache_key("src", &opts, Some("image/webp"));
4004 assert_ne!(key_no_accept, key_with_accept);
4005 }
4006
4007 #[test]
4008 fn transform_cache_put_and_get_round_trips() {
4009 let dir = tempfile::tempdir().expect("create tempdir");
4010 let cache = super::TransformCache::new(dir.path().to_path_buf());
4011
4012 cache.put(
4013 "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
4014 MediaType::Png,
4015 b"png-data",
4016 );
4017 let result = cache.get("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
4018
4019 match result {
4020 super::CacheLookup::Hit {
4021 media_type, body, ..
4022 } => {
4023 assert_eq!(media_type, MediaType::Png);
4024 assert_eq!(body, b"png-data");
4025 }
4026 super::CacheLookup::Miss => panic!("expected cache hit"),
4027 }
4028 }
4029
4030 #[test]
4031 fn transform_cache_miss_for_unknown_key() {
4032 let dir = tempfile::tempdir().expect("create tempdir");
4033 let cache = super::TransformCache::new(dir.path().to_path_buf());
4034
4035 let result = cache.get("0000001234567890abcdef1234567890abcdef1234567890abcdef1234567890");
4036 assert!(matches!(result, super::CacheLookup::Miss));
4037 }
4038
4039 #[test]
4040 fn transform_cache_uses_sharded_layout() {
4041 let dir = tempfile::tempdir().expect("create tempdir");
4042 let cache = super::TransformCache::new(dir.path().to_path_buf());
4043
4044 let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
4045 cache.put(key, MediaType::Jpeg, b"jpeg-data");
4046
4047 let expected = dir.path().join("ab").join("cd").join("ef").join(key);
4049 assert!(
4050 expected.exists(),
4051 "sharded file should exist at {expected:?}"
4052 );
4053 }
4054
4055 #[test]
4056 fn transform_cache_expired_entry_is_miss() {
4057 let dir = tempfile::tempdir().expect("create tempdir");
4058 let mut cache = super::TransformCache::new(dir.path().to_path_buf());
4059 cache.ttl = Duration::from_secs(0); let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
4062 cache.put(key, MediaType::Png, b"data");
4063
4064 std::thread::sleep(Duration::from_millis(10));
4066
4067 let result = cache.get(key);
4068 assert!(matches!(result, super::CacheLookup::Miss));
4069 }
4070
4071 #[test]
4072 fn transform_cache_handles_corrupted_entry_as_miss() {
4073 let dir = tempfile::tempdir().expect("create tempdir");
4074 let cache = super::TransformCache::new(dir.path().to_path_buf());
4075
4076 let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
4077 let path = cache.entry_path(key);
4078 fs::create_dir_all(path.parent().unwrap()).unwrap();
4079 fs::write(&path, b"corrupted-data-without-header").unwrap();
4081
4082 let result = cache.get(key);
4083 assert!(matches!(result, super::CacheLookup::Miss));
4084 }
4085
4086 #[test]
4087 fn cache_status_header_reflects_hit() {
4088 let headers = build_image_response_headers(
4089 MediaType::Png,
4090 &build_image_etag(b"data"),
4091 ImageResponsePolicy::PublicGet,
4092 false,
4093 CacheHitStatus::Hit,
4094 );
4095 assert!(headers.contains(&("Cache-Status".to_string(), "\"truss\"; hit".to_string())));
4096 }
4097
4098 #[test]
4099 fn cache_status_header_reflects_miss() {
4100 let headers = build_image_response_headers(
4101 MediaType::Png,
4102 &build_image_etag(b"data"),
4103 ImageResponsePolicy::PublicGet,
4104 false,
4105 CacheHitStatus::Miss,
4106 );
4107 assert!(headers.contains(&(
4108 "Cache-Status".to_string(),
4109 "\"truss\"; fwd=miss".to_string()
4110 )));
4111 }
4112
4113 #[test]
4114 fn origin_cache_put_and_get_round_trips() {
4115 let dir = tempfile::tempdir().expect("create tempdir");
4116 let cache = super::OriginCache::new(dir.path());
4117
4118 cache.put("https://example.com/image.png", b"raw-source-bytes");
4119 let result = cache.get("https://example.com/image.png");
4120
4121 assert_eq!(result.as_deref(), Some(b"raw-source-bytes".as_ref()));
4122 }
4123
4124 #[test]
4125 fn origin_cache_miss_for_unknown_url() {
4126 let dir = tempfile::tempdir().expect("create tempdir");
4127 let cache = super::OriginCache::new(dir.path());
4128
4129 assert!(
4130 cache
4131 .get("https://unknown.example.com/missing.png")
4132 .is_none()
4133 );
4134 }
4135
4136 #[test]
4137 fn origin_cache_expired_entry_is_none() {
4138 let dir = tempfile::tempdir().expect("create tempdir");
4139 let mut cache = super::OriginCache::new(dir.path());
4140 cache.ttl = Duration::from_secs(0);
4141
4142 cache.put("https://example.com/img.png", b"data");
4143 std::thread::sleep(Duration::from_millis(10));
4144
4145 assert!(cache.get("https://example.com/img.png").is_none());
4146 }
4147
4148 #[test]
4149 fn origin_cache_uses_origin_subdirectory() {
4150 let dir = tempfile::tempdir().expect("create tempdir");
4151 let cache = super::OriginCache::new(dir.path());
4152
4153 cache.put("https://example.com/test.png", b"bytes");
4154
4155 let origin_dir = dir.path().join("origin");
4157 assert!(origin_dir.exists(), "origin subdirectory should exist");
4158 }
4159
4160 #[test]
4161 fn sign_public_url_builds_a_signed_path_url() {
4162 let url = sign_public_url(
4163 "https://cdn.example.com",
4164 SignedUrlSource::Path {
4165 path: "/image.png".to_string(),
4166 version: Some("v1".to_string()),
4167 },
4168 &crate::TransformOptions {
4169 format: Some(MediaType::Jpeg),
4170 width: Some(320),
4171 ..crate::TransformOptions::default()
4172 },
4173 "public-dev",
4174 "secret-value",
4175 4_102_444_800,
4176 )
4177 .expect("sign public URL");
4178
4179 assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
4180 assert!(url.contains("path=%2Fimage.png"));
4181 assert!(url.contains("version=v1"));
4182 assert!(url.contains("width=320"));
4183 assert!(url.contains("format=jpeg"));
4184 assert!(url.contains("keyId=public-dev"));
4185 assert!(url.contains("expires=4102444800"));
4186 assert!(url.contains("signature="));
4187 }
4188
4189 #[test]
4190 fn parse_public_get_request_rejects_unknown_query_parameters() {
4191 let query = BTreeMap::from([
4192 ("path".to_string(), "/image.png".to_string()),
4193 ("keyId".to_string(), "public-dev".to_string()),
4194 ("expires".to_string(), "4102444800".to_string()),
4195 ("signature".to_string(), "deadbeef".to_string()),
4196 ("unexpected".to_string(), "value".to_string()),
4197 ]);
4198
4199 let response = parse_public_get_request(&query, PublicSourceKind::Path)
4200 .expect_err("unknown query should fail");
4201
4202 assert_eq!(response.status, "400 Bad Request");
4203 assert!(response_body(&response).contains("is not supported"));
4204 }
4205
4206 #[test]
4207 fn prepare_remote_fetch_target_pins_the_validated_netloc() {
4208 let target = prepare_remote_fetch_target(
4209 "http://1.1.1.1/image.png",
4210 &ServerConfig::new(temp_dir("pin"), Some("secret".to_string())),
4211 )
4212 .expect("prepare remote target");
4213
4214 assert_eq!(target.netloc, "1.1.1.1:80");
4215 assert_eq!(target.addrs, vec![SocketAddr::from(([1, 1, 1, 1], 80))]);
4216 }
4217
4218 #[test]
4219 fn pinned_resolver_rejects_unexpected_netlocs() {
4220 use ureq::unversioned::resolver::Resolver;
4221
4222 let resolver = PinnedResolver {
4223 expected_netloc: "example.com:443".to_string(),
4224 addrs: vec![SocketAddr::from(([93, 184, 216, 34], 443))],
4225 };
4226
4227 let config = ureq::config::Config::builder().build();
4228 let timeout = ureq::unversioned::transport::NextTimeout {
4229 after: ureq::unversioned::transport::time::Duration::Exact(
4230 std::time::Duration::from_secs(30),
4231 ),
4232 reason: ureq::Timeout::Resolve,
4233 };
4234
4235 let uri: ureq::http::Uri = "https://example.com/path".parse().unwrap();
4236 let result = resolver
4237 .resolve(&uri, &config, timeout)
4238 .expect("resolve expected netloc");
4239 assert_eq!(&result[..], &[SocketAddr::from(([93, 184, 216, 34], 443))]);
4240
4241 let bad_uri: ureq::http::Uri = "https://proxy.example:8080/path".parse().unwrap();
4242 let timeout2 = ureq::unversioned::transport::NextTimeout {
4243 after: ureq::unversioned::transport::time::Duration::Exact(
4244 std::time::Duration::from_secs(30),
4245 ),
4246 reason: ureq::Timeout::Resolve,
4247 };
4248 let error = resolver
4249 .resolve(&bad_uri, &config, timeout2)
4250 .expect_err("unexpected netloc should fail");
4251 assert!(matches!(error, ureq::Error::HostNotFound));
4252 }
4253
4254 #[test]
4255 fn health_live_returns_status_service_version() {
4256 let request = HttpRequest {
4257 method: "GET".to_string(),
4258 target: "/health/live".to_string(),
4259 version: "HTTP/1.1".to_string(),
4260 headers: Vec::new(),
4261 body: Vec::new(),
4262 };
4263
4264 let response = route_request(request, &ServerConfig::new(temp_dir("live"), None));
4265
4266 assert_eq!(response.status, "200 OK");
4267 let body: serde_json::Value =
4268 serde_json::from_slice(&response.body).expect("parse live body");
4269 assert_eq!(body["status"], "ok");
4270 assert_eq!(body["service"], "truss");
4271 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
4272 }
4273
4274 #[test]
4275 fn health_ready_returns_ok_when_storage_exists() {
4276 let storage = temp_dir("ready-ok");
4277 let request = HttpRequest {
4278 method: "GET".to_string(),
4279 target: "/health/ready".to_string(),
4280 version: "HTTP/1.1".to_string(),
4281 headers: Vec::new(),
4282 body: Vec::new(),
4283 };
4284
4285 let response = route_request(request, &ServerConfig::new(storage, None));
4286
4287 assert_eq!(response.status, "200 OK");
4288 let body: serde_json::Value =
4289 serde_json::from_slice(&response.body).expect("parse ready body");
4290 assert_eq!(body["status"], "ok");
4291 let checks = body["checks"].as_array().expect("checks array");
4292 assert!(
4293 checks
4294 .iter()
4295 .any(|c| c["name"] == "storageRoot" && c["status"] == "ok")
4296 );
4297 }
4298
4299 #[test]
4300 fn health_ready_returns_503_when_storage_missing() {
4301 let request = HttpRequest {
4302 method: "GET".to_string(),
4303 target: "/health/ready".to_string(),
4304 version: "HTTP/1.1".to_string(),
4305 headers: Vec::new(),
4306 body: Vec::new(),
4307 };
4308
4309 let config = ServerConfig::new(PathBuf::from("/nonexistent-truss-test-dir"), None);
4310 let response = route_request(request, &config);
4311
4312 assert_eq!(response.status, "503 Service Unavailable");
4313 let body: serde_json::Value =
4314 serde_json::from_slice(&response.body).expect("parse ready fail body");
4315 assert_eq!(body["status"], "fail");
4316 let checks = body["checks"].as_array().expect("checks array");
4317 assert!(
4318 checks
4319 .iter()
4320 .any(|c| c["name"] == "storageRoot" && c["status"] == "fail")
4321 );
4322 }
4323
4324 #[test]
4325 fn health_ready_returns_503_when_cache_root_missing() {
4326 let storage = temp_dir("ready-cache-fail");
4327 let mut config = ServerConfig::new(storage, None);
4328 config.cache_root = Some(PathBuf::from("/nonexistent-truss-cache-dir"));
4329
4330 let request = HttpRequest {
4331 method: "GET".to_string(),
4332 target: "/health/ready".to_string(),
4333 version: "HTTP/1.1".to_string(),
4334 headers: Vec::new(),
4335 body: Vec::new(),
4336 };
4337
4338 let response = route_request(request, &config);
4339
4340 assert_eq!(response.status, "503 Service Unavailable");
4341 let body: serde_json::Value =
4342 serde_json::from_slice(&response.body).expect("parse ready cache body");
4343 assert_eq!(body["status"], "fail");
4344 let checks = body["checks"].as_array().expect("checks array");
4345 assert!(
4346 checks
4347 .iter()
4348 .any(|c| c["name"] == "cacheRoot" && c["status"] == "fail")
4349 );
4350 }
4351
4352 #[test]
4353 fn health_returns_comprehensive_diagnostic() {
4354 let storage = temp_dir("health-diag");
4355 let request = HttpRequest {
4356 method: "GET".to_string(),
4357 target: "/health".to_string(),
4358 version: "HTTP/1.1".to_string(),
4359 headers: Vec::new(),
4360 body: Vec::new(),
4361 };
4362
4363 let response = route_request(request, &ServerConfig::new(storage, None));
4364
4365 assert_eq!(response.status, "200 OK");
4366 let body: serde_json::Value =
4367 serde_json::from_slice(&response.body).expect("parse health body");
4368 assert_eq!(body["status"], "ok");
4369 assert_eq!(body["service"], "truss");
4370 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
4371 assert!(body["uptimeSeconds"].is_u64());
4372 assert!(body["checks"].is_array());
4373 }
4374
4375 #[test]
4376 fn unknown_path_returns_not_found() {
4377 let request = HttpRequest {
4378 method: "GET".to_string(),
4379 target: "/unknown".to_string(),
4380 version: "HTTP/1.1".to_string(),
4381 headers: Vec::new(),
4382 body: Vec::new(),
4383 };
4384
4385 let response = route_request(request, &ServerConfig::new(temp_dir("not-found"), None));
4386
4387 assert_eq!(response.status, "404 Not Found");
4388 assert_eq!(
4389 response.content_type.as_deref(),
4390 Some("application/problem+json")
4391 );
4392 let body = response_body(&response);
4393 assert!(body.contains("\"type\":\"about:blank\""));
4394 assert!(body.contains("\"title\":\"Not Found\""));
4395 assert!(body.contains("\"status\":404"));
4396 assert!(body.contains("not found"));
4397 }
4398
4399 #[test]
4400 fn transform_endpoint_requires_authentication() {
4401 let storage_root = temp_dir("auth");
4402 write_png(&storage_root.join("image.png"));
4403 let mut request = transform_request("/image.png");
4404 request.headers.retain(|(name, _)| name != "authorization");
4405
4406 let response = route_request(
4407 request,
4408 &ServerConfig::new(storage_root, Some("secret".to_string())),
4409 );
4410
4411 assert_eq!(response.status, "401 Unauthorized");
4412 assert!(response_body(&response).contains("authorization required"));
4413 }
4414
4415 #[test]
4416 fn transform_endpoint_returns_service_unavailable_without_configured_token() {
4417 let storage_root = temp_dir("token");
4418 write_png(&storage_root.join("image.png"));
4419
4420 let response = route_request(
4421 transform_request("/image.png"),
4422 &ServerConfig::new(storage_root, None),
4423 );
4424
4425 assert_eq!(response.status, "503 Service Unavailable");
4426 assert!(response_body(&response).contains("bearer token is not configured"));
4427 }
4428
4429 #[test]
4430 fn transform_endpoint_transforms_a_path_source() {
4431 let storage_root = temp_dir("transform");
4432 write_png(&storage_root.join("image.png"));
4433
4434 let response = route_request(
4435 transform_request("/image.png"),
4436 &ServerConfig::new(storage_root, Some("secret".to_string())),
4437 );
4438
4439 assert_eq!(response.status, "200 OK");
4440 assert_eq!(response.content_type.as_deref(), Some("image/jpeg"));
4441
4442 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
4443 assert_eq!(artifact.media_type, MediaType::Jpeg);
4444 assert_eq!(artifact.metadata.width, Some(4));
4445 assert_eq!(artifact.metadata.height, Some(3));
4446 }
4447
4448 #[test]
4449 fn transform_endpoint_rejects_private_url_sources_by_default() {
4450 let response = route_request(
4451 transform_url_request("http://127.0.0.1:8080/image.png"),
4452 &ServerConfig::new(temp_dir("url-blocked"), Some("secret".to_string())),
4453 );
4454
4455 assert_eq!(response.status, "403 Forbidden");
4456 assert!(response_body(&response).contains("port is not allowed"));
4457 }
4458
4459 #[test]
4460 fn transform_endpoint_transforms_a_url_source_when_insecure_allowance_is_enabled() {
4461 let (url, handle) = spawn_http_server(vec![(
4462 "200 OK".to_string(),
4463 vec![("Content-Type".to_string(), "image/png".to_string())],
4464 png_bytes(),
4465 )]);
4466
4467 let response = route_request(
4468 transform_url_request(&url),
4469 &ServerConfig::new(temp_dir("url"), Some("secret".to_string()))
4470 .with_insecure_url_sources(true),
4471 );
4472
4473 handle.join().expect("join fixture server");
4474
4475 assert_eq!(response.status, "200 OK");
4476 assert_eq!(response.content_type.as_deref(), Some("image/jpeg"));
4477
4478 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
4479 assert_eq!(artifact.media_type, MediaType::Jpeg);
4480 }
4481
4482 #[test]
4483 fn transform_endpoint_follows_remote_redirects() {
4484 let (redirect_url, handle) = spawn_http_server(vec![
4485 (
4486 "302 Found".to_string(),
4487 vec![("Location".to_string(), "/final-image".to_string())],
4488 Vec::new(),
4489 ),
4490 (
4491 "200 OK".to_string(),
4492 vec![("Content-Type".to_string(), "image/png".to_string())],
4493 png_bytes(),
4494 ),
4495 ]);
4496
4497 let response = route_request(
4498 transform_url_request(&redirect_url),
4499 &ServerConfig::new(temp_dir("redirect"), Some("secret".to_string()))
4500 .with_insecure_url_sources(true),
4501 );
4502
4503 handle.join().expect("join fixture server");
4504
4505 assert_eq!(response.status, "200 OK");
4506 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
4507 assert_eq!(artifact.media_type, MediaType::Jpeg);
4508 }
4509
4510 #[test]
4511 fn upload_endpoint_transforms_uploaded_file() {
4512 let response = route_request(
4513 upload_request(&png_bytes(), Some(r#"{"format":"jpeg"}"#)),
4514 &ServerConfig::new(temp_dir("upload"), Some("secret".to_string())),
4515 );
4516
4517 assert_eq!(response.status, "200 OK");
4518 assert_eq!(response.content_type.as_deref(), Some("image/jpeg"));
4519
4520 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
4521 assert_eq!(artifact.media_type, MediaType::Jpeg);
4522 }
4523
4524 #[test]
4525 fn upload_endpoint_requires_a_file_field() {
4526 let boundary = "truss-test-boundary";
4527 let request = HttpRequest {
4528 method: "POST".to_string(),
4529 target: "/images".to_string(),
4530 version: "HTTP/1.1".to_string(),
4531 headers: vec![
4532 ("authorization".to_string(), "Bearer secret".to_string()),
4533 (
4534 "content-type".to_string(),
4535 format!("multipart/form-data; boundary={boundary}"),
4536 ),
4537 ],
4538 body: format!(
4539 "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{{\"format\":\"jpeg\"}}\r\n--{boundary}--\r\n"
4540 )
4541 .into_bytes(),
4542 };
4543
4544 let response = route_request(
4545 request,
4546 &ServerConfig::new(temp_dir("upload-missing-file"), Some("secret".to_string())),
4547 );
4548
4549 assert_eq!(response.status, "400 Bad Request");
4550 assert!(response_body(&response).contains("requires a `file` field"));
4551 }
4552
4553 #[test]
4554 fn upload_endpoint_rejects_non_multipart_content_type() {
4555 let request = HttpRequest {
4556 method: "POST".to_string(),
4557 target: "/images".to_string(),
4558 version: "HTTP/1.1".to_string(),
4559 headers: vec![
4560 ("authorization".to_string(), "Bearer secret".to_string()),
4561 ("content-type".to_string(), "application/json".to_string()),
4562 ],
4563 body: br#"{"file":"not-really-json"}"#.to_vec(),
4564 };
4565
4566 let response = route_request(
4567 request,
4568 &ServerConfig::new(temp_dir("upload-content-type"), Some("secret".to_string())),
4569 );
4570
4571 assert_eq!(response.status, "415 Unsupported Media Type");
4572 assert!(response_body(&response).contains("multipart/form-data"));
4573 }
4574
4575 #[test]
4576 fn parse_upload_request_extracts_file_and_options() {
4577 let request = upload_request(&png_bytes(), Some(r#"{"width":8,"format":"jpeg"}"#));
4578 let boundary = super::parse_multipart_boundary(&request).expect("parse boundary");
4579 let (file_bytes, options) =
4580 super::parse_upload_request(&request.body, &boundary).expect("parse upload body");
4581
4582 assert_eq!(file_bytes, png_bytes());
4583 assert_eq!(options.width, Some(8));
4584 assert_eq!(options.format, Some(MediaType::Jpeg));
4585 }
4586
4587 #[test]
4588 fn metrics_endpoint_requires_authentication() {
4589 let response = route_request(
4590 metrics_request(false),
4591 &ServerConfig::new(temp_dir("metrics-auth"), Some("secret".to_string())),
4592 );
4593
4594 assert_eq!(response.status, "401 Unauthorized");
4595 assert!(response_body(&response).contains("authorization required"));
4596 }
4597
4598 #[test]
4599 fn metrics_endpoint_returns_prometheus_text() {
4600 super::record_http_metrics(super::RouteMetric::Health, "200 OK");
4601 let response = route_request(
4602 metrics_request(true),
4603 &ServerConfig::new(temp_dir("metrics"), Some("secret".to_string())),
4604 );
4605 let body = response_body(&response);
4606
4607 assert_eq!(response.status, "200 OK");
4608 assert_eq!(
4609 response.content_type.as_deref(),
4610 Some("text/plain; version=0.0.4; charset=utf-8")
4611 );
4612 assert!(body.contains("truss_http_requests_total"));
4613 assert!(body.contains("truss_http_requests_by_route_total{route=\"/health\"}"));
4614 assert!(body.contains("truss_http_responses_total{status=\"200\"}"));
4615 }
4616
4617 #[test]
4618 fn transform_endpoint_rejects_unsupported_remote_content_encoding() {
4619 let (url, handle) = spawn_http_server(vec![(
4620 "200 OK".to_string(),
4621 vec![
4622 ("Content-Type".to_string(), "image/png".to_string()),
4623 ("Content-Encoding".to_string(), "compress".to_string()),
4624 ],
4625 png_bytes(),
4626 )]);
4627
4628 let response = route_request(
4629 transform_url_request(&url),
4630 &ServerConfig::new(temp_dir("encoding"), Some("secret".to_string()))
4631 .with_insecure_url_sources(true),
4632 );
4633
4634 handle.join().expect("join fixture server");
4635
4636 assert_eq!(response.status, "502 Bad Gateway");
4637 assert!(response_body(&response).contains("unsupported content-encoding"));
4638 }
4639
4640 #[test]
4641 fn resolve_storage_path_rejects_parent_segments() {
4642 let storage_root = temp_dir("resolve");
4643 let response = resolve_storage_path(&storage_root, "../escape.png")
4644 .expect_err("parent segments should be rejected");
4645
4646 assert_eq!(response.status, "400 Bad Request");
4647 assert!(response_body(&response).contains("must not contain root"));
4648 }
4649
4650 #[test]
4651 fn read_request_parses_headers_and_body() {
4652 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{}";
4653 let mut cursor = Cursor::new(request_bytes);
4654 let request = read_request(&mut cursor).expect("parse request");
4655
4656 assert_eq!(request.method, "POST");
4657 assert_eq!(request.target, "/images:transform");
4658 assert_eq!(request.version, "HTTP/1.1");
4659 assert_eq!(request.header("host"), Some("localhost"));
4660 assert_eq!(request.body, b"{}");
4661 }
4662
4663 #[test]
4664 fn read_request_rejects_duplicate_content_length() {
4665 let request_bytes =
4666 b"POST /images:transform HTTP/1.1\r\nContent-Length: 2\r\nContent-Length: 2\r\n\r\n{}";
4667 let mut cursor = Cursor::new(request_bytes);
4668 let response = read_request(&mut cursor).expect_err("duplicate headers should fail");
4669
4670 assert_eq!(response.status, "400 Bad Request");
4671 assert!(response_body(&response).contains("content-length"));
4672 }
4673
4674 #[test]
4675 fn serve_once_handles_a_tcp_request() {
4676 let storage_root = temp_dir("serve-once");
4677 let config = ServerConfig::new(storage_root, None);
4678 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
4679 let addr = listener.local_addr().expect("read local addr");
4680
4681 let server = thread::spawn(move || serve_once_with_config(listener, &config));
4682
4683 let mut stream = TcpStream::connect(addr).expect("connect to test server");
4684 stream
4685 .write_all(b"GET /health/live HTTP/1.1\r\nHost: localhost\r\n\r\n")
4686 .expect("write request");
4687
4688 let mut response = String::new();
4689 stream.read_to_string(&mut response).expect("read response");
4690
4691 server
4692 .join()
4693 .expect("join test server thread")
4694 .expect("serve one request");
4695
4696 assert!(response.starts_with("HTTP/1.1 200 OK"));
4697 assert!(response.contains("Content-Type: application/json"));
4698 assert!(response.contains("\"status\":\"ok\""));
4699 assert!(response.contains("\"service\":\"truss\""));
4700 assert!(response.contains("\"version\":"));
4701 }
4702
4703 #[test]
4704 fn helper_error_responses_use_rfc7807_problem_details() {
4705 let response = auth_required_response("authorization required");
4706 let bad_request = bad_request_response("bad input");
4707
4708 assert_eq!(
4709 response.content_type.as_deref(),
4710 Some("application/problem+json"),
4711 "error responses must use application/problem+json"
4712 );
4713 assert_eq!(
4714 bad_request.content_type.as_deref(),
4715 Some("application/problem+json"),
4716 );
4717
4718 let auth_body = response_body(&response);
4719 assert!(auth_body.contains("authorization required"));
4720 assert!(auth_body.contains("\"type\":\"about:blank\""));
4721 assert!(auth_body.contains("\"title\":\"Unauthorized\""));
4722 assert!(auth_body.contains("\"status\":401"));
4723
4724 let bad_body = response_body(&bad_request);
4725 assert!(bad_body.contains("bad input"));
4726 assert!(bad_body.contains("\"type\":\"about:blank\""));
4727 assert!(bad_body.contains("\"title\":\"Bad Request\""));
4728 assert!(bad_body.contains("\"status\":400"));
4729 }
4730
4731 #[test]
4734 fn parse_headers_rejects_duplicate_host() {
4735 let lines = "Host: example.com\r\nHost: evil.com\r\n";
4736 let result = super::parse_headers(lines.split("\r\n"));
4737 assert!(result.is_err());
4738 }
4739
4740 #[test]
4741 fn parse_headers_rejects_duplicate_authorization() {
4742 let lines = "Authorization: Bearer a\r\nAuthorization: Bearer b\r\n";
4743 let result = super::parse_headers(lines.split("\r\n"));
4744 assert!(result.is_err());
4745 }
4746
4747 #[test]
4748 fn parse_headers_rejects_duplicate_content_type() {
4749 let lines = "Content-Type: application/json\r\nContent-Type: text/plain\r\n";
4750 let result = super::parse_headers(lines.split("\r\n"));
4751 assert!(result.is_err());
4752 }
4753
4754 #[test]
4755 fn parse_headers_rejects_duplicate_transfer_encoding() {
4756 let lines = "Transfer-Encoding: chunked\r\nTransfer-Encoding: gzip\r\n";
4757 let result = super::parse_headers(lines.split("\r\n"));
4758 assert!(result.is_err());
4759 }
4760
4761 #[test]
4762 fn parse_headers_allows_single_instances_of_singleton_headers() {
4763 let lines =
4764 "Host: example.com\r\nAuthorization: Bearer tok\r\nContent-Type: application/json\r\n";
4765 let result = super::parse_headers(lines.split("\r\n"));
4766 assert!(result.is_ok());
4767 assert_eq!(result.unwrap().len(), 3);
4768 }
4769
4770 #[test]
4773 fn max_body_for_multipart_uses_upload_limit() {
4774 let headers = vec![(
4775 "content-type".to_string(),
4776 "multipart/form-data; boundary=abc".to_string(),
4777 )];
4778 assert_eq!(
4779 super::max_body_for_headers(&headers),
4780 super::MAX_UPLOAD_BODY_BYTES
4781 );
4782 }
4783
4784 #[test]
4785 fn max_body_for_json_uses_default_limit() {
4786 let headers = vec![("content-type".to_string(), "application/json".to_string())];
4787 assert_eq!(
4788 super::max_body_for_headers(&headers),
4789 super::MAX_REQUEST_BODY_BYTES
4790 );
4791 }
4792
4793 #[test]
4794 fn max_body_for_no_content_type_uses_default_limit() {
4795 let headers: Vec<(String, String)> = vec![];
4796 assert_eq!(
4797 super::max_body_for_headers(&headers),
4798 super::MAX_REQUEST_BODY_BYTES
4799 );
4800 }
4801
4802 fn make_test_config() -> ServerConfig {
4805 ServerConfig::new(std::env::temp_dir(), None)
4806 }
4807
4808 #[test]
4809 fn versioned_source_hash_returns_none_without_version() {
4810 let source = TransformSourcePayload::Path {
4811 path: "/photos/hero.jpg".to_string(),
4812 version: None,
4813 };
4814 assert!(source.versioned_source_hash(&make_test_config()).is_none());
4815 }
4816
4817 #[test]
4818 fn versioned_source_hash_is_deterministic() {
4819 let cfg = make_test_config();
4820 let source = TransformSourcePayload::Path {
4821 path: "/photos/hero.jpg".to_string(),
4822 version: Some("v1".to_string()),
4823 };
4824 let hash1 = source.versioned_source_hash(&cfg).unwrap();
4825 let hash2 = source.versioned_source_hash(&cfg).unwrap();
4826 assert_eq!(hash1, hash2);
4827 assert_eq!(hash1.len(), 64);
4828 }
4829
4830 #[test]
4831 fn versioned_source_hash_differs_by_version() {
4832 let cfg = make_test_config();
4833 let v1 = TransformSourcePayload::Path {
4834 path: "/photos/hero.jpg".to_string(),
4835 version: Some("v1".to_string()),
4836 };
4837 let v2 = TransformSourcePayload::Path {
4838 path: "/photos/hero.jpg".to_string(),
4839 version: Some("v2".to_string()),
4840 };
4841 assert_ne!(
4842 v1.versioned_source_hash(&cfg).unwrap(),
4843 v2.versioned_source_hash(&cfg).unwrap()
4844 );
4845 }
4846
4847 #[test]
4848 fn versioned_source_hash_differs_by_kind() {
4849 let cfg = make_test_config();
4850 let path = TransformSourcePayload::Path {
4851 path: "example.com/image.jpg".to_string(),
4852 version: Some("v1".to_string()),
4853 };
4854 let url = TransformSourcePayload::Url {
4855 url: "example.com/image.jpg".to_string(),
4856 version: Some("v1".to_string()),
4857 };
4858 assert_ne!(
4859 path.versioned_source_hash(&cfg).unwrap(),
4860 url.versioned_source_hash(&cfg).unwrap()
4861 );
4862 }
4863
4864 #[test]
4865 fn versioned_source_hash_differs_by_storage_root() {
4866 let cfg1 = ServerConfig::new(PathBuf::from("/data/images"), None);
4867 let cfg2 = ServerConfig::new(PathBuf::from("/other/images"), None);
4868 let source = TransformSourcePayload::Path {
4869 path: "/photos/hero.jpg".to_string(),
4870 version: Some("v1".to_string()),
4871 };
4872 assert_ne!(
4873 source.versioned_source_hash(&cfg1).unwrap(),
4874 source.versioned_source_hash(&cfg2).unwrap()
4875 );
4876 }
4877
4878 #[test]
4879 fn versioned_source_hash_differs_by_insecure_flag() {
4880 let mut cfg1 = make_test_config();
4881 cfg1.allow_insecure_url_sources = false;
4882 let mut cfg2 = make_test_config();
4883 cfg2.allow_insecure_url_sources = true;
4884 let source = TransformSourcePayload::Url {
4885 url: "http://example.com/img.jpg".to_string(),
4886 version: Some("v1".to_string()),
4887 };
4888 assert_ne!(
4889 source.versioned_source_hash(&cfg1).unwrap(),
4890 source.versioned_source_hash(&cfg2).unwrap()
4891 );
4892 }
4893
4894 #[test]
4897 fn read_request_rejects_json_body_over_1mib() {
4898 let body = vec![b'x'; super::MAX_REQUEST_BODY_BYTES + 1];
4899 let content_length = body.len();
4900 let raw = format!(
4901 "POST /images:transform HTTP/1.1\r\n\
4902 Content-Type: application/json\r\n\
4903 Content-Length: {content_length}\r\n\r\n"
4904 );
4905 let mut data = raw.into_bytes();
4906 data.extend_from_slice(&body);
4907 let result = super::read_request(&mut data.as_slice());
4908 assert!(result.is_err());
4909 }
4910
4911 #[test]
4912 fn read_request_accepts_multipart_body_over_1mib() {
4913 let payload_size = super::MAX_REQUEST_BODY_BYTES + 100;
4915 let body_content = vec![b'A'; payload_size];
4916 let boundary = "test-boundary-123";
4917 let mut body = Vec::new();
4918 body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"big.jpg\"\r\n\r\n").as_bytes());
4919 body.extend_from_slice(&body_content);
4920 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
4921 let content_length = body.len();
4922 let raw = format!(
4923 "POST /images HTTP/1.1\r\n\
4924 Content-Type: multipart/form-data; boundary={boundary}\r\n\
4925 Content-Length: {content_length}\r\n\r\n"
4926 );
4927 let mut data = raw.into_bytes();
4928 data.extend_from_slice(&body);
4929 let result = super::read_request(&mut data.as_slice());
4930 assert!(
4931 result.is_ok(),
4932 "multipart upload over 1 MiB should be accepted"
4933 );
4934 }
4935}