1mod auth;
2mod cache;
3mod http_parse;
4mod metrics;
5mod multipart;
6mod negotiate;
7mod remote;
8mod response;
9
10use auth::{
11 authorize_request, authorize_request_headers, authorize_signed_request,
12 canonical_query_without_signature, extend_transform_query, parse_optional_bool_query,
13 parse_optional_float_query, parse_optional_integer_query, parse_optional_u8_query,
14 parse_query_params, required_query_param, signed_source_query, url_authority,
15 validate_public_query_names,
16};
17use cache::{CacheLookup, TransformCache, compute_cache_key, try_versioned_cache_lookup};
18use http_parse::{
19 HttpRequest, parse_named, parse_optional_named, read_request_body, read_request_headers,
20 request_has_json_content_type,
21};
22use metrics::{
23 CACHE_HITS_TOTAL, CACHE_MISSES_TOTAL, MAX_CONCURRENT_TRANSFORMS, RouteMetric,
24 TRANSFORMS_IN_FLIGHT, record_http_metrics, render_metrics_text, uptime_seconds,
25};
26use multipart::{parse_multipart_boundary, parse_upload_request};
27use negotiate::{
28 CacheHitStatus, ImageResponsePolicy, PublicSourceKind, build_image_etag,
29 build_image_response_headers, if_none_match_matches, negotiate_output_format,
30};
31use remote::resolve_source_bytes;
32use response::{
33 HttpResponse, NOT_FOUND_BODY, bad_request_response, service_unavailable_response,
34 transform_error_response, unsupported_media_type_response, write_response,
35};
36
37use crate::{
38 Fit, MediaType, Position, RawArtifact, Rgba8, Rotation, TransformOptions, TransformRequest,
39 sniff_artifact, transform_raster, transform_svg,
40};
41use hmac::{Hmac, Mac};
42use serde::Deserialize;
43use serde_json::json;
44use sha2::{Digest, Sha256};
45use std::collections::BTreeMap;
46use std::env;
47use std::fmt;
48use std::io;
49use std::net::{TcpListener, TcpStream};
50use std::path::PathBuf;
51use std::str::FromStr;
52use std::sync::Arc;
53use std::sync::atomic::Ordering;
54use std::time::Duration;
55use url::Url;
56
57pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
59
60pub const DEFAULT_STORAGE_ROOT: &str = ".";
62
63const DEFAULT_PUBLIC_MAX_AGE_SECONDS: u32 = 3600;
64const DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS: u32 = 60;
65const SOCKET_READ_TIMEOUT: Duration = Duration::from_secs(60);
66const SOCKET_WRITE_TIMEOUT: Duration = Duration::from_secs(60);
67const WORKER_THREADS: usize = 8;
69type HmacSha256 = Hmac<Sha256>;
70
71const KEEP_ALIVE_MAX_REQUESTS: usize = 100;
75
76const SERVER_TRANSFORM_DEADLINE: Duration = Duration::from_secs(30);
82
83#[derive(Clone, Copy)]
84struct PublicCacheControl {
85 max_age: u32,
86 stale_while_revalidate: u32,
87}
88
89#[derive(Clone, Copy)]
90struct ImageResponseConfig {
91 disable_accept_negotiation: bool,
92 public_cache_control: PublicCacheControl,
93}
94
95pub type LogHandler = Arc<dyn Fn(&str) + Send + Sync>;
106
107pub struct ServerConfig {
108 pub storage_root: PathBuf,
110 pub bearer_token: Option<String>,
112 pub public_base_url: Option<String>,
119 pub signed_url_key_id: Option<String>,
121 pub signed_url_secret: Option<String>,
123 pub allow_insecure_url_sources: bool,
129 pub cache_root: Option<PathBuf>,
136 pub public_max_age_seconds: u32,
141 pub public_stale_while_revalidate_seconds: u32,
146 pub disable_accept_negotiation: bool,
154 pub log_handler: Option<LogHandler>,
160}
161
162impl Clone for ServerConfig {
163 fn clone(&self) -> Self {
164 Self {
165 storage_root: self.storage_root.clone(),
166 bearer_token: self.bearer_token.clone(),
167 public_base_url: self.public_base_url.clone(),
168 signed_url_key_id: self.signed_url_key_id.clone(),
169 signed_url_secret: self.signed_url_secret.clone(),
170 allow_insecure_url_sources: self.allow_insecure_url_sources,
171 cache_root: self.cache_root.clone(),
172 public_max_age_seconds: self.public_max_age_seconds,
173 public_stale_while_revalidate_seconds: self.public_stale_while_revalidate_seconds,
174 disable_accept_negotiation: self.disable_accept_negotiation,
175 log_handler: self.log_handler.clone(),
176 }
177 }
178}
179
180impl fmt::Debug for ServerConfig {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 f.debug_struct("ServerConfig")
183 .field("storage_root", &self.storage_root)
184 .field("bearer_token", &self.bearer_token)
185 .field("public_base_url", &self.public_base_url)
186 .field("signed_url_key_id", &self.signed_url_key_id)
187 .field("signed_url_secret", &self.signed_url_secret)
188 .field(
189 "allow_insecure_url_sources",
190 &self.allow_insecure_url_sources,
191 )
192 .field("cache_root", &self.cache_root)
193 .field("public_max_age_seconds", &self.public_max_age_seconds)
194 .field(
195 "public_stale_while_revalidate_seconds",
196 &self.public_stale_while_revalidate_seconds,
197 )
198 .field(
199 "disable_accept_negotiation",
200 &self.disable_accept_negotiation,
201 )
202 .field("log_handler", &self.log_handler.as_ref().map(|_| ".."))
203 .finish()
204 }
205}
206
207impl PartialEq for ServerConfig {
208 fn eq(&self, other: &Self) -> bool {
209 self.storage_root == other.storage_root
210 && self.bearer_token == other.bearer_token
211 && self.public_base_url == other.public_base_url
212 && self.signed_url_key_id == other.signed_url_key_id
213 && self.signed_url_secret == other.signed_url_secret
214 && self.allow_insecure_url_sources == other.allow_insecure_url_sources
215 && self.cache_root == other.cache_root
216 && self.public_max_age_seconds == other.public_max_age_seconds
217 && self.public_stale_while_revalidate_seconds
218 == other.public_stale_while_revalidate_seconds
219 && self.disable_accept_negotiation == other.disable_accept_negotiation
220 }
221}
222
223impl Eq for ServerConfig {}
224
225impl ServerConfig {
226 pub fn new(storage_root: PathBuf, bearer_token: Option<String>) -> Self {
241 Self {
242 storage_root,
243 bearer_token,
244 public_base_url: None,
245 signed_url_key_id: None,
246 signed_url_secret: None,
247 allow_insecure_url_sources: false,
248 cache_root: None,
249 public_max_age_seconds: DEFAULT_PUBLIC_MAX_AGE_SECONDS,
250 public_stale_while_revalidate_seconds: DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
251 disable_accept_negotiation: false,
252 log_handler: None,
253 }
254 }
255
256 fn log(&self, msg: &str) {
259 if let Some(handler) = &self.log_handler {
260 handler(msg);
261 } else {
262 eprintln!("{msg}");
263 }
264 }
265
266 pub fn with_signed_url_credentials(
284 mut self,
285 key_id: impl Into<String>,
286 secret: impl Into<String>,
287 ) -> Self {
288 self.signed_url_key_id = Some(key_id.into());
289 self.signed_url_secret = Some(secret.into());
290 self
291 }
292
293 pub fn with_insecure_url_sources(mut self, allow_insecure_url_sources: bool) -> Self {
310 self.allow_insecure_url_sources = allow_insecure_url_sources;
311 self
312 }
313
314 pub fn with_cache_root(mut self, cache_root: impl Into<PathBuf>) -> Self {
330 self.cache_root = Some(cache_root.into());
331 self
332 }
333
334 pub fn from_env() -> io::Result<Self> {
379 let storage_root =
380 env::var("TRUSS_STORAGE_ROOT").unwrap_or_else(|_| DEFAULT_STORAGE_ROOT.to_string());
381 let storage_root = PathBuf::from(storage_root).canonicalize()?;
382 let bearer_token = env::var("TRUSS_BEARER_TOKEN")
383 .ok()
384 .filter(|value| !value.is_empty());
385 let public_base_url = env::var("TRUSS_PUBLIC_BASE_URL")
386 .ok()
387 .filter(|value| !value.is_empty())
388 .map(validate_public_base_url)
389 .transpose()?;
390 let signed_url_key_id = env::var("TRUSS_SIGNED_URL_KEY_ID")
391 .ok()
392 .filter(|value| !value.is_empty());
393 let signed_url_secret = env::var("TRUSS_SIGNED_URL_SECRET")
394 .ok()
395 .filter(|value| !value.is_empty());
396
397 if signed_url_key_id.is_some() != signed_url_secret.is_some() {
398 return Err(io::Error::new(
399 io::ErrorKind::InvalidInput,
400 "TRUSS_SIGNED_URL_KEY_ID and TRUSS_SIGNED_URL_SECRET must be set together",
401 ));
402 }
403
404 if signed_url_key_id.is_some() && public_base_url.is_none() {
405 eprintln!(
406 "truss: warning: TRUSS_SIGNED_URL_KEY_ID is set but TRUSS_PUBLIC_BASE_URL is not. \
407 Behind a reverse proxy or CDN the Host header may differ from the externally \
408 visible authority, causing signed URL verification to fail. Consider setting \
409 TRUSS_PUBLIC_BASE_URL to the canonical external origin."
410 );
411 }
412
413 let cache_root = env::var("TRUSS_CACHE_ROOT")
414 .ok()
415 .filter(|value| !value.is_empty())
416 .map(PathBuf::from);
417
418 let public_max_age_seconds = parse_optional_env_u32("TRUSS_PUBLIC_MAX_AGE")?
419 .unwrap_or(DEFAULT_PUBLIC_MAX_AGE_SECONDS);
420 let public_stale_while_revalidate_seconds =
421 parse_optional_env_u32("TRUSS_PUBLIC_STALE_WHILE_REVALIDATE")?
422 .unwrap_or(DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS);
423
424 Ok(Self {
425 storage_root,
426 bearer_token,
427 public_base_url,
428 signed_url_key_id,
429 signed_url_secret,
430 allow_insecure_url_sources: env_flag("TRUSS_ALLOW_INSECURE_URL_SOURCES"),
431 cache_root,
432 public_max_age_seconds,
433 public_stale_while_revalidate_seconds,
434 disable_accept_negotiation: env_flag("TRUSS_DISABLE_ACCEPT_NEGOTIATION"),
435 log_handler: None,
436 })
437 }
438}
439
440#[derive(Debug, Clone, PartialEq, Eq)]
442pub enum SignedUrlSource {
443 Path {
445 path: String,
447 version: Option<String>,
449 },
450 Url {
452 url: String,
454 version: Option<String>,
456 },
457}
458
459pub fn sign_public_url(
501 base_url: &str,
502 source: SignedUrlSource,
503 options: &TransformOptions,
504 key_id: &str,
505 secret: &str,
506 expires: u64,
507) -> Result<String, String> {
508 let base_url = Url::parse(base_url).map_err(|error| format!("base URL is invalid: {error}"))?;
509 match base_url.scheme() {
510 "http" | "https" => {}
511 _ => return Err("base URL must use the http or https scheme".to_string()),
512 }
513
514 let route_path = match source {
515 SignedUrlSource::Path { .. } => "/images/by-path",
516 SignedUrlSource::Url { .. } => "/images/by-url",
517 };
518 let mut endpoint = base_url
519 .join(route_path)
520 .map_err(|error| format!("failed to resolve the public endpoint URL: {error}"))?;
521 let authority = url_authority(&endpoint)?;
522 let mut query = signed_source_query(source);
523 extend_transform_query(&mut query, options);
524 query.insert("keyId".to_string(), key_id.to_string());
525 query.insert("expires".to_string(), expires.to_string());
526
527 let canonical = format!(
528 "GET\n{}\n{}\n{}",
529 authority,
530 endpoint.path(),
531 canonical_query_without_signature(&query)
532 );
533 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
534 .map_err(|error| format!("failed to initialize signed URL HMAC: {error}"))?;
535 mac.update(canonical.as_bytes());
536 query.insert(
537 "signature".to_string(),
538 hex::encode(mac.finalize().into_bytes()),
539 );
540
541 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
542 for (name, value) in query {
543 serializer.append_pair(&name, &value);
544 }
545 endpoint.set_query(Some(&serializer.finish()));
546 Ok(endpoint.into())
547}
548
549pub fn bind_addr() -> String {
554 env::var("TRUSS_BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string())
555}
556
557pub fn serve(listener: TcpListener) -> io::Result<()> {
569 let config = ServerConfig::from_env()?;
570 serve_with_config(listener, &config)
571}
572
573pub fn serve_with_config(listener: TcpListener, config: &ServerConfig) -> io::Result<()> {
583 let config = Arc::new(config.clone());
584 let (sender, receiver) = std::sync::mpsc::channel::<TcpStream>();
585
586 let receiver = Arc::new(std::sync::Mutex::new(receiver));
590 let mut workers = Vec::with_capacity(WORKER_THREADS);
591 for _ in 0..WORKER_THREADS {
592 let rx = Arc::clone(&receiver);
593 let cfg = Arc::clone(&config);
594 workers.push(std::thread::spawn(move || {
595 while let Ok(stream) = rx.lock().expect("worker lock poisoned").recv() {
596 if let Err(err) = handle_stream(stream, &cfg) {
597 cfg.log(&format!("failed to handle connection: {err}"));
598 }
599 }
600 }));
601 }
602
603 for stream in listener.incoming() {
604 match stream {
605 Ok(stream) => {
606 if sender.send(stream).is_err() {
607 break;
608 }
609 }
610 Err(err) => return Err(err),
611 }
612 }
613
614 drop(sender);
615 for worker in workers {
616 let _ = worker.join();
617 }
618
619 Ok(())
620}
621
622pub fn serve_once(listener: TcpListener) -> io::Result<()> {
632 let config = ServerConfig::from_env()?;
633 serve_once_with_config(listener, &config)
634}
635
636pub fn serve_once_with_config(listener: TcpListener, config: &ServerConfig) -> io::Result<()> {
643 let (stream, _) = listener.accept()?;
644 handle_stream(stream, config)
645}
646
647#[derive(Debug, Deserialize)]
648#[serde(deny_unknown_fields)]
649struct TransformImageRequestPayload {
650 source: TransformSourcePayload,
651 #[serde(default)]
652 options: TransformOptionsPayload,
653}
654
655#[derive(Debug, Deserialize)]
656#[serde(tag = "kind", rename_all = "lowercase")]
657enum TransformSourcePayload {
658 Path {
659 path: String,
660 version: Option<String>,
661 },
662 Url {
663 url: String,
664 version: Option<String>,
665 },
666}
667
668impl TransformSourcePayload {
669 fn versioned_source_hash(&self, config: &ServerConfig) -> Option<String> {
678 let (kind, reference, version) = match self {
679 Self::Path { path, version } => ("path", path.as_str(), version.as_deref()),
680 Self::Url { url, version } => ("url", url.as_str(), version.as_deref()),
681 };
682 let version = version?;
683 let mut id = String::new();
687 id.push_str(kind);
688 id.push('\n');
689 id.push_str(reference);
690 id.push('\n');
691 id.push_str(version);
692 id.push('\n');
693 id.push_str(config.storage_root.to_string_lossy().as_ref());
694 id.push('\n');
695 id.push_str(if config.allow_insecure_url_sources {
696 "insecure"
697 } else {
698 "strict"
699 });
700 Some(hex::encode(Sha256::digest(id.as_bytes())))
701 }
702}
703
704#[derive(Debug, Default, Deserialize)]
705#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
706struct TransformOptionsPayload {
707 width: Option<u32>,
708 height: Option<u32>,
709 fit: Option<String>,
710 position: Option<String>,
711 format: Option<String>,
712 quality: Option<u8>,
713 background: Option<String>,
714 rotate: Option<u16>,
715 auto_orient: Option<bool>,
716 strip_metadata: Option<bool>,
717 preserve_exif: Option<bool>,
718 blur: Option<f32>,
719}
720
721impl TransformOptionsPayload {
722 fn into_options(self) -> Result<TransformOptions, HttpResponse> {
723 let defaults = TransformOptions::default();
724
725 Ok(TransformOptions {
726 width: self.width,
727 height: self.height,
728 fit: parse_optional_named(self.fit.as_deref(), "fit", Fit::from_str)?,
729 position: parse_optional_named(
730 self.position.as_deref(),
731 "position",
732 Position::from_str,
733 )?,
734 format: parse_optional_named(self.format.as_deref(), "format", MediaType::from_str)?,
735 quality: self.quality,
736 background: parse_optional_named(
737 self.background.as_deref(),
738 "background",
739 Rgba8::from_hex,
740 )?,
741 rotate: match self.rotate {
742 Some(value) => parse_named(&value.to_string(), "rotate", Rotation::from_str)?,
743 None => defaults.rotate,
744 },
745 auto_orient: self.auto_orient.unwrap_or(defaults.auto_orient),
746 strip_metadata: self.strip_metadata.unwrap_or(defaults.strip_metadata),
747 preserve_exif: self.preserve_exif.unwrap_or(defaults.preserve_exif),
748 blur: self.blur,
749 deadline: defaults.deadline,
750 })
751 }
752}
753
754fn handle_stream(mut stream: TcpStream, config: &ServerConfig) -> io::Result<()> {
755 if let Err(err) = stream.set_read_timeout(Some(SOCKET_READ_TIMEOUT)) {
757 config.log(&format!("failed to set socket read timeout: {err}"));
758 }
759 if let Err(err) = stream.set_write_timeout(Some(SOCKET_WRITE_TIMEOUT)) {
760 config.log(&format!("failed to set socket write timeout: {err}"));
761 }
762
763 let mut requests_served: usize = 0;
764
765 loop {
766 let partial = match read_request_headers(&mut stream) {
767 Ok(partial) => partial,
768 Err(response) => {
769 if requests_served > 0 {
770 return Ok(());
771 }
772 let _ = write_response(&mut stream, response, true);
773 return Ok(());
774 }
775 };
776
777 let client_wants_close = partial
778 .headers
779 .iter()
780 .any(|(name, value)| name == "connection" && value.eq_ignore_ascii_case("close"));
781
782 let is_head = partial.method == "HEAD";
783
784 let requires_auth = matches!(
785 (partial.method.as_str(), partial.path()),
786 ("POST", "/images:transform") | ("POST", "/images")
787 );
788 if requires_auth && let Err(response) = authorize_request_headers(&partial.headers, config)
789 {
790 let _ = write_response(&mut stream, response, true);
791 return Ok(());
792 }
793
794 let request = match read_request_body(&mut stream, partial) {
795 Ok(request) => request,
796 Err(response) => {
797 let _ = write_response(&mut stream, response, true);
798 return Ok(());
799 }
800 };
801 let route = classify_route(&request);
802 let mut response = route_request(request, config);
803 record_http_metrics(route, response.status);
804
805 if is_head {
806 response.body = Vec::new();
807 }
808
809 requests_served += 1;
810 let close_after = client_wants_close || requests_served >= KEEP_ALIVE_MAX_REQUESTS;
811
812 write_response(&mut stream, response, close_after)?;
813
814 if close_after {
815 return Ok(());
816 }
817 }
818}
819
820fn route_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
821 let method = request.method.clone();
822 let path = request.path().to_string();
823
824 match (method.as_str(), path.as_str()) {
825 ("GET" | "HEAD", "/health") => handle_health(config),
826 ("GET" | "HEAD", "/health/live") => handle_health_live(),
827 ("GET" | "HEAD", "/health/ready") => handle_health_ready(config),
828 ("GET" | "HEAD", "/images/by-path") => handle_public_path_request(request, config),
829 ("GET" | "HEAD", "/images/by-url") => handle_public_url_request(request, config),
830 ("POST", "/images:transform") => handle_transform_request(request, config),
831 ("POST", "/images") => handle_upload_request(request, config),
832 ("GET" | "HEAD", "/metrics") => handle_metrics_request(request, config),
833 _ => HttpResponse::problem("404 Not Found", NOT_FOUND_BODY.as_bytes().to_vec()),
834 }
835}
836
837fn classify_route(request: &HttpRequest) -> RouteMetric {
838 match (request.method.as_str(), request.path()) {
839 ("GET" | "HEAD", "/health") => RouteMetric::Health,
840 ("GET" | "HEAD", "/health/live") => RouteMetric::HealthLive,
841 ("GET" | "HEAD", "/health/ready") => RouteMetric::HealthReady,
842 ("GET" | "HEAD", "/images/by-path") => RouteMetric::PublicByPath,
843 ("GET" | "HEAD", "/images/by-url") => RouteMetric::PublicByUrl,
844 ("POST", "/images:transform") => RouteMetric::Transform,
845 ("POST", "/images") => RouteMetric::Upload,
846 ("GET" | "HEAD", "/metrics") => RouteMetric::Metrics,
847 _ => RouteMetric::Unknown,
848 }
849}
850
851fn handle_transform_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
852 if let Err(response) = authorize_request(&request, config) {
853 return response;
854 }
855
856 if !request_has_json_content_type(&request) {
857 return unsupported_media_type_response("content-type must be application/json");
858 }
859
860 let payload: TransformImageRequestPayload = match serde_json::from_slice(&request.body) {
861 Ok(payload) => payload,
862 Err(error) => {
863 return bad_request_response(&format!("request body must be valid JSON: {error}"));
864 }
865 };
866 let options = match payload.options.into_options() {
867 Ok(options) => options,
868 Err(response) => return response,
869 };
870
871 let versioned_hash = payload.source.versioned_source_hash(config);
872 if let Some(response) = try_versioned_cache_lookup(
873 versioned_hash.as_deref(),
874 &options,
875 &request,
876 ImageResponsePolicy::PrivateTransform,
877 config,
878 ) {
879 return response;
880 }
881
882 let source_bytes = match resolve_source_bytes(payload.source, config) {
883 Ok(bytes) => bytes,
884 Err(response) => return response,
885 };
886 transform_source_bytes(
887 source_bytes,
888 options,
889 versioned_hash.as_deref(),
890 &request,
891 ImageResponsePolicy::PrivateTransform,
892 config,
893 )
894}
895
896fn handle_public_path_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
897 handle_public_get_request(request, config, PublicSourceKind::Path)
898}
899
900fn handle_public_url_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
901 handle_public_get_request(request, config, PublicSourceKind::Url)
902}
903
904fn handle_public_get_request(
905 request: HttpRequest,
906 config: &ServerConfig,
907 source_kind: PublicSourceKind,
908) -> HttpResponse {
909 let query = match parse_query_params(&request) {
910 Ok(query) => query,
911 Err(response) => return response,
912 };
913 if let Err(response) = authorize_signed_request(&request, &query, config) {
914 return response;
915 }
916 let (source, options) = match parse_public_get_request(&query, source_kind) {
917 Ok(parsed) => parsed,
918 Err(response) => return response,
919 };
920
921 let versioned_hash = source.versioned_source_hash(config);
922 if let Some(response) = try_versioned_cache_lookup(
923 versioned_hash.as_deref(),
924 &options,
925 &request,
926 ImageResponsePolicy::PublicGet,
927 config,
928 ) {
929 return response;
930 }
931
932 let source_bytes = match resolve_source_bytes(source, config) {
933 Ok(bytes) => bytes,
934 Err(response) => return response,
935 };
936
937 transform_source_bytes(
938 source_bytes,
939 options,
940 versioned_hash.as_deref(),
941 &request,
942 ImageResponsePolicy::PublicGet,
943 config,
944 )
945}
946
947fn handle_upload_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
948 if let Err(response) = authorize_request(&request, config) {
949 return response;
950 }
951
952 let boundary = match parse_multipart_boundary(&request) {
953 Ok(boundary) => boundary,
954 Err(response) => return response,
955 };
956 let (file_bytes, options) = match parse_upload_request(&request.body, &boundary) {
957 Ok(parts) => parts,
958 Err(response) => return response,
959 };
960 transform_source_bytes(
961 file_bytes,
962 options,
963 None,
964 &request,
965 ImageResponsePolicy::PrivateTransform,
966 config,
967 )
968}
969
970fn handle_health_live() -> HttpResponse {
972 let body = serde_json::to_vec(&json!({
973 "status": "ok",
974 "service": "truss",
975 "version": env!("CARGO_PKG_VERSION"),
976 }))
977 .expect("serialize liveness");
978 let mut body = body;
979 body.push(b'\n');
980 HttpResponse::json("200 OK", body)
981}
982
983fn handle_health_ready(config: &ServerConfig) -> HttpResponse {
986 let mut checks: Vec<serde_json::Value> = Vec::new();
987 let mut all_ok = true;
988
989 let storage_ok = config.storage_root.is_dir();
990 checks.push(json!({
991 "name": "storageRoot",
992 "status": if storage_ok { "ok" } else { "fail" },
993 }));
994 if !storage_ok {
995 all_ok = false;
996 }
997
998 if let Some(cache_root) = &config.cache_root {
999 let cache_ok = cache_root.is_dir();
1000 checks.push(json!({
1001 "name": "cacheRoot",
1002 "status": if cache_ok { "ok" } else { "fail" },
1003 }));
1004 if !cache_ok {
1005 all_ok = false;
1006 }
1007 }
1008
1009 let in_flight = TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed);
1010 let overloaded = in_flight >= MAX_CONCURRENT_TRANSFORMS;
1011 checks.push(json!({
1012 "name": "transformCapacity",
1013 "status": if overloaded { "fail" } else { "ok" },
1014 }));
1015 if overloaded {
1016 all_ok = false;
1017 }
1018
1019 let status_str = if all_ok { "ok" } else { "fail" };
1020 let mut body = serde_json::to_vec(&json!({
1021 "status": status_str,
1022 "checks": checks,
1023 }))
1024 .expect("serialize readiness");
1025 body.push(b'\n');
1026
1027 if all_ok {
1028 HttpResponse::json("200 OK", body)
1029 } else {
1030 HttpResponse::json("503 Service Unavailable", body)
1031 }
1032}
1033
1034fn handle_health(config: &ServerConfig) -> HttpResponse {
1036 let mut checks: Vec<serde_json::Value> = Vec::new();
1037 let mut all_ok = true;
1038
1039 let storage_ok = config.storage_root.is_dir();
1040 checks.push(json!({
1041 "name": "storageRoot",
1042 "status": if storage_ok { "ok" } else { "fail" },
1043 }));
1044 if !storage_ok {
1045 all_ok = false;
1046 }
1047
1048 if let Some(cache_root) = &config.cache_root {
1049 let cache_ok = cache_root.is_dir();
1050 checks.push(json!({
1051 "name": "cacheRoot",
1052 "status": if cache_ok { "ok" } else { "fail" },
1053 }));
1054 if !cache_ok {
1055 all_ok = false;
1056 }
1057 }
1058
1059 let in_flight = TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed);
1060 let overloaded = in_flight >= MAX_CONCURRENT_TRANSFORMS;
1061 checks.push(json!({
1062 "name": "transformCapacity",
1063 "status": if overloaded { "fail" } else { "ok" },
1064 }));
1065 if overloaded {
1066 all_ok = false;
1067 }
1068
1069 let status_str = if all_ok { "ok" } else { "fail" };
1070 let mut body = serde_json::to_vec(&json!({
1071 "status": status_str,
1072 "service": "truss",
1073 "version": env!("CARGO_PKG_VERSION"),
1074 "uptimeSeconds": uptime_seconds(),
1075 "checks": checks,
1076 }))
1077 .expect("serialize health");
1078 body.push(b'\n');
1079
1080 HttpResponse::json("200 OK", body)
1081}
1082
1083fn handle_metrics_request(request: HttpRequest, config: &ServerConfig) -> HttpResponse {
1084 if let Err(response) = authorize_request(&request, config) {
1085 return response;
1086 }
1087
1088 HttpResponse::text(
1089 "200 OK",
1090 "text/plain; version=0.0.4; charset=utf-8",
1091 render_metrics_text().into_bytes(),
1092 )
1093}
1094
1095fn parse_public_get_request(
1096 query: &BTreeMap<String, String>,
1097 source_kind: PublicSourceKind,
1098) -> Result<(TransformSourcePayload, TransformOptions), HttpResponse> {
1099 validate_public_query_names(query, source_kind)?;
1100
1101 let source = match source_kind {
1102 PublicSourceKind::Path => TransformSourcePayload::Path {
1103 path: required_query_param(query, "path")?.to_string(),
1104 version: query.get("version").cloned(),
1105 },
1106 PublicSourceKind::Url => TransformSourcePayload::Url {
1107 url: required_query_param(query, "url")?.to_string(),
1108 version: query.get("version").cloned(),
1109 },
1110 };
1111
1112 let defaults = TransformOptions::default();
1113 let options = TransformOptions {
1114 width: parse_optional_integer_query(query, "width")?,
1115 height: parse_optional_integer_query(query, "height")?,
1116 fit: parse_optional_named(query.get("fit").map(String::as_str), "fit", Fit::from_str)?,
1117 position: parse_optional_named(
1118 query.get("position").map(String::as_str),
1119 "position",
1120 Position::from_str,
1121 )?,
1122 format: parse_optional_named(
1123 query.get("format").map(String::as_str),
1124 "format",
1125 MediaType::from_str,
1126 )?,
1127 quality: parse_optional_u8_query(query, "quality")?,
1128 background: parse_optional_named(
1129 query.get("background").map(String::as_str),
1130 "background",
1131 Rgba8::from_hex,
1132 )?,
1133 rotate: match query.get("rotate") {
1134 Some(value) => parse_named(value, "rotate", Rotation::from_str)?,
1135 None => defaults.rotate,
1136 },
1137 auto_orient: parse_optional_bool_query(query, "autoOrient")?
1138 .unwrap_or(defaults.auto_orient),
1139 strip_metadata: parse_optional_bool_query(query, "stripMetadata")?
1140 .unwrap_or(defaults.strip_metadata),
1141 preserve_exif: parse_optional_bool_query(query, "preserveExif")?
1142 .unwrap_or(defaults.preserve_exif),
1143 blur: parse_optional_float_query(query, "blur")?,
1144 deadline: defaults.deadline,
1145 };
1146
1147 Ok((source, options))
1148}
1149
1150fn transform_source_bytes(
1151 source_bytes: Vec<u8>,
1152 options: TransformOptions,
1153 versioned_hash: Option<&str>,
1154 request: &HttpRequest,
1155 response_policy: ImageResponsePolicy,
1156 config: &ServerConfig,
1157) -> HttpResponse {
1158 let content_hash;
1159 let source_hash = match versioned_hash {
1160 Some(hash) => hash,
1161 None => {
1162 content_hash = hex::encode(Sha256::digest(&source_bytes));
1163 &content_hash
1164 }
1165 };
1166
1167 let cache = config
1168 .cache_root
1169 .as_ref()
1170 .map(|root| TransformCache::new(root.clone()).with_log_handler(config.log_handler.clone()));
1171
1172 if let Some(ref cache) = cache
1173 && options.format.is_some()
1174 {
1175 let cache_key = compute_cache_key(source_hash, &options, None);
1176 if let CacheLookup::Hit {
1177 media_type,
1178 body,
1179 age,
1180 } = cache.get(&cache_key)
1181 {
1182 CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
1183 let etag = build_image_etag(&body);
1184 let mut headers = build_image_response_headers(
1185 media_type,
1186 &etag,
1187 response_policy,
1188 false,
1189 CacheHitStatus::Hit,
1190 config.public_max_age_seconds,
1191 config.public_stale_while_revalidate_seconds,
1192 );
1193 headers.push(("Age".to_string(), age.as_secs().to_string()));
1194 if matches!(response_policy, ImageResponsePolicy::PublicGet)
1195 && if_none_match_matches(request.header("if-none-match"), &etag)
1196 {
1197 return HttpResponse::empty("304 Not Modified", headers);
1198 }
1199 return HttpResponse::binary_with_headers(
1200 "200 OK",
1201 media_type.as_mime(),
1202 headers,
1203 body,
1204 );
1205 }
1206 }
1207
1208 let in_flight = TRANSFORMS_IN_FLIGHT.fetch_add(1, Ordering::Relaxed);
1209 if in_flight >= MAX_CONCURRENT_TRANSFORMS {
1210 TRANSFORMS_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
1211 return service_unavailable_response("too many concurrent transforms; retry later");
1212 }
1213 let response = transform_source_bytes_inner(
1214 source_bytes,
1215 options,
1216 request,
1217 response_policy,
1218 cache.as_ref(),
1219 source_hash,
1220 ImageResponseConfig {
1221 disable_accept_negotiation: config.disable_accept_negotiation,
1222 public_cache_control: PublicCacheControl {
1223 max_age: config.public_max_age_seconds,
1224 stale_while_revalidate: config.public_stale_while_revalidate_seconds,
1225 },
1226 },
1227 );
1228 TRANSFORMS_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
1229 response
1230}
1231
1232fn transform_source_bytes_inner(
1233 source_bytes: Vec<u8>,
1234 mut options: TransformOptions,
1235 request: &HttpRequest,
1236 response_policy: ImageResponsePolicy,
1237 cache: Option<&TransformCache>,
1238 source_hash: &str,
1239 response_config: ImageResponseConfig,
1240) -> HttpResponse {
1241 if options.deadline.is_none() {
1242 options.deadline = Some(SERVER_TRANSFORM_DEADLINE);
1243 }
1244 let artifact = match sniff_artifact(RawArtifact::new(source_bytes, None)) {
1245 Ok(artifact) => artifact,
1246 Err(error) => return transform_error_response(error),
1247 };
1248 let negotiation_used =
1249 if options.format.is_none() && !response_config.disable_accept_negotiation {
1250 match negotiate_output_format(request.header("accept"), &artifact) {
1251 Ok(Some(format)) => {
1252 options.format = Some(format);
1253 true
1254 }
1255 Ok(None) => false,
1256 Err(response) => return response,
1257 }
1258 } else {
1259 false
1260 };
1261
1262 let negotiated_accept = if negotiation_used {
1263 request.header("accept")
1264 } else {
1265 None
1266 };
1267 let cache_key = compute_cache_key(source_hash, &options, negotiated_accept);
1268
1269 if let Some(cache) = cache
1270 && let CacheLookup::Hit {
1271 media_type,
1272 body,
1273 age,
1274 } = cache.get(&cache_key)
1275 {
1276 CACHE_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
1277 let etag = build_image_etag(&body);
1278 let mut headers = build_image_response_headers(
1279 media_type,
1280 &etag,
1281 response_policy,
1282 negotiation_used,
1283 CacheHitStatus::Hit,
1284 response_config.public_cache_control.max_age,
1285 response_config.public_cache_control.stale_while_revalidate,
1286 );
1287 headers.push(("Age".to_string(), age.as_secs().to_string()));
1288 if matches!(response_policy, ImageResponsePolicy::PublicGet)
1289 && if_none_match_matches(request.header("if-none-match"), &etag)
1290 {
1291 return HttpResponse::empty("304 Not Modified", headers);
1292 }
1293 return HttpResponse::binary_with_headers("200 OK", media_type.as_mime(), headers, body);
1294 }
1295
1296 if cache.is_some() {
1297 CACHE_MISSES_TOTAL.fetch_add(1, Ordering::Relaxed);
1298 }
1299
1300 let is_svg = artifact.media_type == MediaType::Svg;
1301 let result = if is_svg {
1302 match transform_svg(TransformRequest::new(artifact, options)) {
1303 Ok(result) => result,
1304 Err(error) => return transform_error_response(error),
1305 }
1306 } else {
1307 match transform_raster(TransformRequest::new(artifact, options)) {
1308 Ok(result) => result,
1309 Err(error) => return transform_error_response(error),
1310 }
1311 };
1312
1313 for warning in &result.warnings {
1314 let msg = format!("truss: {warning}");
1315 if let Some(c) = cache
1316 && let Some(handler) = &c.log_handler
1317 {
1318 handler(&msg);
1319 } else {
1320 eprintln!("{msg}");
1321 }
1322 }
1323
1324 let output = result.artifact;
1325
1326 if let Some(cache) = cache {
1327 cache.put(&cache_key, output.media_type, &output.bytes);
1328 }
1329
1330 let cache_hit_status = if cache.is_some() {
1331 CacheHitStatus::Miss
1332 } else {
1333 CacheHitStatus::Disabled
1334 };
1335
1336 let etag = build_image_etag(&output.bytes);
1337 let headers = build_image_response_headers(
1338 output.media_type,
1339 &etag,
1340 response_policy,
1341 negotiation_used,
1342 cache_hit_status,
1343 response_config.public_cache_control.max_age,
1344 response_config.public_cache_control.stale_while_revalidate,
1345 );
1346
1347 if matches!(response_policy, ImageResponsePolicy::PublicGet)
1348 && if_none_match_matches(request.header("if-none-match"), &etag)
1349 {
1350 return HttpResponse::empty("304 Not Modified", headers);
1351 }
1352
1353 HttpResponse::binary_with_headers("200 OK", output.media_type.as_mime(), headers, output.bytes)
1354}
1355
1356fn env_flag(name: &str) -> bool {
1357 env::var(name)
1358 .map(|value| {
1359 matches!(
1360 value.as_str(),
1361 "1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON"
1362 )
1363 })
1364 .unwrap_or(false)
1365}
1366
1367fn parse_optional_env_u32(name: &str) -> io::Result<Option<u32>> {
1368 match env::var(name) {
1369 Ok(value) if !value.is_empty() => value.parse::<u32>().map(Some).map_err(|_| {
1370 io::Error::new(
1371 io::ErrorKind::InvalidInput,
1372 format!("{name} must be a non-negative integer"),
1373 )
1374 }),
1375 _ => Ok(None),
1376 }
1377}
1378
1379fn validate_public_base_url(value: String) -> io::Result<String> {
1380 let parsed = Url::parse(&value).map_err(|error| {
1381 io::Error::new(
1382 io::ErrorKind::InvalidInput,
1383 format!("TRUSS_PUBLIC_BASE_URL must be a valid URL: {error}"),
1384 )
1385 })?;
1386
1387 match parsed.scheme() {
1388 "http" | "https" => Ok(parsed.to_string()),
1389 _ => Err(io::Error::new(
1390 io::ErrorKind::InvalidInput,
1391 "TRUSS_PUBLIC_BASE_URL must use http or https",
1392 )),
1393 }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398 use super::http_parse::{
1399 HttpRequest, find_header_terminator, read_request_body, read_request_headers,
1400 resolve_storage_path,
1401 };
1402 use super::multipart::parse_multipart_form_data;
1403 use super::remote::{PinnedResolver, prepare_remote_fetch_target};
1404 use super::response::auth_required_response;
1405 use super::response::{HttpResponse, bad_request_response};
1406 use super::{
1407 CacheHitStatus, DEFAULT_BIND_ADDR, DEFAULT_PUBLIC_MAX_AGE_SECONDS,
1408 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS, ImageResponsePolicy,
1409 MAX_CONCURRENT_TRANSFORMS, PublicSourceKind, ServerConfig, SignedUrlSource,
1410 TRANSFORMS_IN_FLIGHT, TransformSourcePayload, authorize_signed_request, bind_addr,
1411 build_image_etag, build_image_response_headers, canonical_query_without_signature,
1412 negotiate_output_format, parse_public_get_request, route_request, serve_once_with_config,
1413 sign_public_url, transform_source_bytes,
1414 };
1415 use crate::{
1416 Artifact, ArtifactMetadata, MediaType, RawArtifact, TransformOptions, sniff_artifact,
1417 };
1418 use hmac::{Hmac, Mac};
1419 use image::codecs::png::PngEncoder;
1420 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
1421 use sha2::Sha256;
1422 use std::collections::BTreeMap;
1423 use std::fs;
1424 use std::io::{Cursor, Read, Write};
1425 use std::net::{SocketAddr, TcpListener, TcpStream};
1426 use std::path::{Path, PathBuf};
1427 use std::sync::atomic::Ordering;
1428 use std::thread;
1429 use std::time::{Duration, SystemTime, UNIX_EPOCH};
1430
1431 fn read_request<R: Read>(stream: &mut R) -> Result<HttpRequest, HttpResponse> {
1434 let partial = read_request_headers(stream)?;
1435 read_request_body(stream, partial)
1436 }
1437
1438 fn png_bytes() -> Vec<u8> {
1439 let image = RgbaImage::from_pixel(4, 3, Rgba([10, 20, 30, 255]));
1440 let mut bytes = Vec::new();
1441 PngEncoder::new(&mut bytes)
1442 .write_image(&image, 4, 3, ColorType::Rgba8.into())
1443 .expect("encode png");
1444 bytes
1445 }
1446
1447 fn temp_dir(name: &str) -> PathBuf {
1448 let unique = SystemTime::now()
1449 .duration_since(UNIX_EPOCH)
1450 .expect("current time")
1451 .as_nanos();
1452 let path = std::env::temp_dir().join(format!("truss-server-{name}-{unique}"));
1453 fs::create_dir_all(&path).expect("create temp dir");
1454 path
1455 }
1456
1457 fn write_png(path: &Path) {
1458 fs::write(path, png_bytes()).expect("write png fixture");
1459 }
1460
1461 fn artifact_with_alpha(has_alpha: bool) -> Artifact {
1462 Artifact::new(
1463 png_bytes(),
1464 MediaType::Png,
1465 ArtifactMetadata {
1466 width: Some(4),
1467 height: Some(3),
1468 frame_count: 1,
1469 duration: None,
1470 has_alpha: Some(has_alpha),
1471 },
1472 )
1473 }
1474
1475 fn sign_public_query(
1476 method: &str,
1477 authority: &str,
1478 path: &str,
1479 query: &BTreeMap<String, String>,
1480 secret: &str,
1481 ) -> String {
1482 let canonical = format!(
1483 "{method}\n{authority}\n{path}\n{}",
1484 canonical_query_without_signature(query)
1485 );
1486 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("create hmac");
1487 mac.update(canonical.as_bytes());
1488 hex::encode(mac.finalize().into_bytes())
1489 }
1490
1491 type FixtureResponse = (String, Vec<(String, String)>, Vec<u8>);
1492
1493 fn read_fixture_request(stream: &mut TcpStream) {
1494 stream
1495 .set_nonblocking(false)
1496 .expect("configure fixture stream blocking mode");
1497 stream
1498 .set_read_timeout(Some(Duration::from_millis(100)))
1499 .expect("configure fixture stream timeout");
1500
1501 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1502 let mut buffer = Vec::new();
1503 let mut chunk = [0_u8; 1024];
1504 let header_end = loop {
1505 let read = match stream.read(&mut chunk) {
1506 Ok(read) => read,
1507 Err(error)
1508 if matches!(
1509 error.kind(),
1510 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
1511 ) && std::time::Instant::now() < deadline =>
1512 {
1513 thread::sleep(Duration::from_millis(10));
1514 continue;
1515 }
1516 Err(error) => panic!("read fixture request headers: {error}"),
1517 };
1518 if read == 0 {
1519 panic!("fixture request ended before headers were complete");
1520 }
1521 buffer.extend_from_slice(&chunk[..read]);
1522 if let Some(index) = find_header_terminator(&buffer) {
1523 break index;
1524 }
1525 };
1526
1527 let header_text = std::str::from_utf8(&buffer[..header_end]).expect("fixture request utf8");
1528 let content_length = header_text
1529 .split("\r\n")
1530 .filter_map(|line| line.split_once(':'))
1531 .find_map(|(name, value)| {
1532 name.trim()
1533 .eq_ignore_ascii_case("content-length")
1534 .then_some(value.trim())
1535 })
1536 .map(|value| {
1537 value
1538 .parse::<usize>()
1539 .expect("fixture content-length should be numeric")
1540 })
1541 .unwrap_or(0);
1542
1543 let mut body = buffer.len().saturating_sub(header_end + 4);
1544 while body < content_length {
1545 let read = match stream.read(&mut chunk) {
1546 Ok(read) => read,
1547 Err(error)
1548 if matches!(
1549 error.kind(),
1550 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
1551 ) && std::time::Instant::now() < deadline =>
1552 {
1553 thread::sleep(Duration::from_millis(10));
1554 continue;
1555 }
1556 Err(error) => panic!("read fixture request body: {error}"),
1557 };
1558 if read == 0 {
1559 panic!("fixture request body was truncated");
1560 }
1561 body += read;
1562 }
1563 }
1564
1565 fn spawn_http_server(responses: Vec<FixtureResponse>) -> (String, thread::JoinHandle<()>) {
1566 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture server");
1567 listener
1568 .set_nonblocking(true)
1569 .expect("configure fixture server");
1570 let addr = listener.local_addr().expect("fixture server addr");
1571 let url = format!("http://{addr}/image");
1572
1573 let handle = thread::spawn(move || {
1574 for (status, headers, body) in responses {
1575 let deadline = std::time::Instant::now() + Duration::from_secs(10);
1576 let mut accepted = None;
1577 while std::time::Instant::now() < deadline {
1578 match listener.accept() {
1579 Ok(stream) => {
1580 accepted = Some(stream);
1581 break;
1582 }
1583 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1584 thread::sleep(Duration::from_millis(10));
1585 }
1586 Err(error) => panic!("accept fixture request: {error}"),
1587 }
1588 }
1589
1590 let Some((mut stream, _)) = accepted else {
1591 break;
1592 };
1593 read_fixture_request(&mut stream);
1594 let mut header = format!(
1595 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
1596 body.len()
1597 );
1598 for (name, value) in headers {
1599 header.push_str(&format!("{name}: {value}\r\n"));
1600 }
1601 header.push_str("\r\n");
1602 stream
1603 .write_all(header.as_bytes())
1604 .expect("write fixture headers");
1605 stream.write_all(&body).expect("write fixture body");
1606 stream.flush().expect("flush fixture response");
1607 }
1608 });
1609
1610 (url, handle)
1611 }
1612
1613 fn transform_request(path: &str) -> HttpRequest {
1614 HttpRequest {
1615 method: "POST".to_string(),
1616 target: "/images:transform".to_string(),
1617 version: "HTTP/1.1".to_string(),
1618 headers: vec![
1619 ("authorization".to_string(), "Bearer secret".to_string()),
1620 ("content-type".to_string(), "application/json".to_string()),
1621 ],
1622 body: format!(
1623 "{{\"source\":{{\"kind\":\"path\",\"path\":\"{path}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
1624 )
1625 .into_bytes(),
1626 }
1627 }
1628
1629 fn transform_url_request(url: &str) -> HttpRequest {
1630 HttpRequest {
1631 method: "POST".to_string(),
1632 target: "/images:transform".to_string(),
1633 version: "HTTP/1.1".to_string(),
1634 headers: vec![
1635 ("authorization".to_string(), "Bearer secret".to_string()),
1636 ("content-type".to_string(), "application/json".to_string()),
1637 ],
1638 body: format!(
1639 "{{\"source\":{{\"kind\":\"url\",\"url\":\"{url}\"}},\"options\":{{\"format\":\"jpeg\"}}}}"
1640 )
1641 .into_bytes(),
1642 }
1643 }
1644
1645 fn upload_request(file_bytes: &[u8], options_json: Option<&str>) -> HttpRequest {
1646 let boundary = "truss-test-boundary";
1647 let mut body = Vec::new();
1648 body.extend_from_slice(
1649 format!(
1650 "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"image.png\"\r\nContent-Type: image/png\r\n\r\n"
1651 )
1652 .as_bytes(),
1653 );
1654 body.extend_from_slice(file_bytes);
1655 body.extend_from_slice(b"\r\n");
1656
1657 if let Some(options_json) = options_json {
1658 body.extend_from_slice(
1659 format!(
1660 "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{options_json}\r\n"
1661 )
1662 .as_bytes(),
1663 );
1664 }
1665
1666 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
1667
1668 HttpRequest {
1669 method: "POST".to_string(),
1670 target: "/images".to_string(),
1671 version: "HTTP/1.1".to_string(),
1672 headers: vec![
1673 ("authorization".to_string(), "Bearer secret".to_string()),
1674 (
1675 "content-type".to_string(),
1676 format!("multipart/form-data; boundary={boundary}"),
1677 ),
1678 ],
1679 body,
1680 }
1681 }
1682
1683 fn metrics_request(with_auth: bool) -> HttpRequest {
1684 let mut headers = Vec::new();
1685 if with_auth {
1686 headers.push(("authorization".to_string(), "Bearer secret".to_string()));
1687 }
1688
1689 HttpRequest {
1690 method: "GET".to_string(),
1691 target: "/metrics".to_string(),
1692 version: "HTTP/1.1".to_string(),
1693 headers,
1694 body: Vec::new(),
1695 }
1696 }
1697
1698 fn response_body(response: &HttpResponse) -> String {
1699 String::from_utf8(response.body.clone()).expect("utf8 response body")
1700 }
1701
1702 fn signed_public_request(target: &str, host: &str, secret: &str) -> HttpRequest {
1703 let (path, query) = target.split_once('?').expect("target has query");
1704 let mut query = url::form_urlencoded::parse(query.as_bytes())
1705 .into_owned()
1706 .collect::<BTreeMap<_, _>>();
1707 let signature = sign_public_query("GET", host, path, &query, secret);
1708 query.insert("signature".to_string(), signature);
1709 let final_query = url::form_urlencoded::Serializer::new(String::new())
1710 .extend_pairs(
1711 query
1712 .iter()
1713 .map(|(name, value)| (name.as_str(), value.as_str())),
1714 )
1715 .finish();
1716
1717 HttpRequest {
1718 method: "GET".to_string(),
1719 target: format!("{path}?{final_query}"),
1720 version: "HTTP/1.1".to_string(),
1721 headers: vec![("host".to_string(), host.to_string())],
1722 body: Vec::new(),
1723 }
1724 }
1725
1726 #[test]
1727 fn uses_default_bind_addr_when_env_is_missing() {
1728 unsafe { std::env::remove_var("TRUSS_BIND_ADDR") };
1729 assert_eq!(bind_addr(), DEFAULT_BIND_ADDR);
1730 }
1731
1732 #[test]
1733 fn authorize_signed_request_accepts_a_valid_signature() {
1734 let request = signed_public_request(
1735 "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
1736 "assets.example.com",
1737 "secret-value",
1738 );
1739 let query = super::auth::parse_query_params(&request).expect("parse query");
1740 let config = ServerConfig::new(temp_dir("public-auth"), None)
1741 .with_signed_url_credentials("public-dev", "secret-value");
1742
1743 authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
1744 }
1745
1746 #[test]
1747 fn authorize_signed_request_uses_public_base_url_authority() {
1748 let request = signed_public_request(
1749 "/images/by-path?path=%2Fimage.png&keyId=public-dev&expires=4102444800&format=jpeg",
1750 "cdn.example.com",
1751 "secret-value",
1752 );
1753 let query = super::auth::parse_query_params(&request).expect("parse query");
1754 let mut config = ServerConfig::new(temp_dir("public-authority"), None)
1755 .with_signed_url_credentials("public-dev", "secret-value");
1756 config.public_base_url = Some("https://cdn.example.com".to_string());
1757
1758 authorize_signed_request(&request, &query, &config).expect("signed auth should pass");
1759 }
1760
1761 #[test]
1762 fn negotiate_output_format_prefers_alpha_safe_formats_for_transparent_inputs() {
1763 let format =
1764 negotiate_output_format(Some("image/jpeg,image/png"), &artifact_with_alpha(true))
1765 .expect("negotiate output format")
1766 .expect("resolved output format");
1767
1768 assert_eq!(format, MediaType::Png);
1769 }
1770
1771 #[test]
1772 fn negotiate_output_format_prefers_avif_for_wildcard_accept() {
1773 let format = negotiate_output_format(Some("image/*"), &artifact_with_alpha(false))
1774 .expect("negotiate output format")
1775 .expect("resolved output format");
1776
1777 assert_eq!(format, MediaType::Avif);
1778 }
1779
1780 #[test]
1781 fn build_image_response_headers_include_cache_and_safety_metadata() {
1782 let headers = build_image_response_headers(
1783 MediaType::Webp,
1784 &build_image_etag(b"demo"),
1785 ImageResponsePolicy::PublicGet,
1786 true,
1787 CacheHitStatus::Disabled,
1788 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
1789 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
1790 );
1791
1792 assert!(headers.contains(&(
1793 "Cache-Control".to_string(),
1794 "public, max-age=3600, stale-while-revalidate=60".to_string()
1795 )));
1796 assert!(headers.contains(&("Vary".to_string(), "Accept".to_string())));
1797 assert!(headers.contains(&("X-Content-Type-Options".to_string(), "nosniff".to_string())));
1798 assert!(headers.contains(&(
1799 "Content-Disposition".to_string(),
1800 "inline; filename=\"truss.webp\"".to_string()
1801 )));
1802 assert!(headers.contains(&(
1803 "Cache-Status".to_string(),
1804 "\"truss\"; fwd=miss".to_string()
1805 )));
1806 }
1807
1808 #[test]
1809 fn build_image_response_headers_include_csp_sandbox_for_svg() {
1810 let headers = build_image_response_headers(
1811 MediaType::Svg,
1812 &build_image_etag(b"svg-data"),
1813 ImageResponsePolicy::PublicGet,
1814 true,
1815 CacheHitStatus::Disabled,
1816 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
1817 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
1818 );
1819
1820 assert!(headers.contains(&("Content-Security-Policy".to_string(), "sandbox".to_string())));
1821 }
1822
1823 #[test]
1824 fn build_image_response_headers_omit_csp_sandbox_for_raster() {
1825 let headers = build_image_response_headers(
1826 MediaType::Png,
1827 &build_image_etag(b"png-data"),
1828 ImageResponsePolicy::PublicGet,
1829 true,
1830 CacheHitStatus::Disabled,
1831 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
1832 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
1833 );
1834
1835 assert!(!headers.iter().any(|(k, _)| k == "Content-Security-Policy"));
1836 }
1837
1838 struct InFlightGuard {
1841 previous: u64,
1842 }
1843
1844 impl InFlightGuard {
1845 fn set(value: u64) -> Self {
1846 let previous = TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed);
1847 TRANSFORMS_IN_FLIGHT.store(value, Ordering::Relaxed);
1848 Self { previous }
1849 }
1850 }
1851
1852 impl Drop for InFlightGuard {
1853 fn drop(&mut self) {
1854 TRANSFORMS_IN_FLIGHT.store(self.previous, Ordering::Relaxed);
1855 }
1856 }
1857
1858 #[test]
1859 fn backpressure_rejects_when_at_capacity() {
1860 let _guard = InFlightGuard::set(MAX_CONCURRENT_TRANSFORMS);
1861
1862 let request = HttpRequest {
1863 method: "POST".to_string(),
1864 target: "/transform".to_string(),
1865 version: "HTTP/1.1".to_string(),
1866 headers: Vec::new(),
1867 body: Vec::new(),
1868 };
1869
1870 let png_bytes = {
1871 let mut buf = Vec::new();
1872 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
1873 encoder
1874 .write_image(&[255, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8)
1875 .unwrap();
1876 buf
1877 };
1878
1879 let config = ServerConfig::new(std::env::temp_dir(), None);
1880 let response = transform_source_bytes(
1881 png_bytes,
1882 TransformOptions::default(),
1883 None,
1884 &request,
1885 ImageResponsePolicy::PrivateTransform,
1886 &config,
1887 );
1888
1889 assert!(response.status.contains("503"));
1890
1891 assert_eq!(
1892 TRANSFORMS_IN_FLIGHT.load(Ordering::Relaxed),
1893 MAX_CONCURRENT_TRANSFORMS
1894 );
1895 }
1896
1897 #[test]
1898 fn compute_cache_key_is_deterministic() {
1899 let opts = TransformOptions {
1900 width: Some(300),
1901 height: Some(200),
1902 format: Some(MediaType::Webp),
1903 ..TransformOptions::default()
1904 };
1905 let key1 = super::cache::compute_cache_key("source-abc", &opts, None);
1906 let key2 = super::cache::compute_cache_key("source-abc", &opts, None);
1907 assert_eq!(key1, key2);
1908 assert_eq!(key1.len(), 64);
1909 }
1910
1911 #[test]
1912 fn compute_cache_key_differs_for_different_options() {
1913 let opts1 = TransformOptions {
1914 width: Some(300),
1915 format: Some(MediaType::Webp),
1916 ..TransformOptions::default()
1917 };
1918 let opts2 = TransformOptions {
1919 width: Some(400),
1920 format: Some(MediaType::Webp),
1921 ..TransformOptions::default()
1922 };
1923 let key1 = super::cache::compute_cache_key("same-source", &opts1, None);
1924 let key2 = super::cache::compute_cache_key("same-source", &opts2, None);
1925 assert_ne!(key1, key2);
1926 }
1927
1928 #[test]
1929 fn compute_cache_key_includes_accept_when_present() {
1930 let opts = TransformOptions::default();
1931 let key_no_accept = super::cache::compute_cache_key("src", &opts, None);
1932 let key_with_accept = super::cache::compute_cache_key("src", &opts, Some("image/webp"));
1933 assert_ne!(key_no_accept, key_with_accept);
1934 }
1935
1936 #[test]
1937 fn transform_cache_put_and_get_round_trips() {
1938 let dir = tempfile::tempdir().expect("create tempdir");
1939 let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
1940
1941 cache.put(
1942 "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
1943 MediaType::Png,
1944 b"png-data",
1945 );
1946 let result = cache.get("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
1947
1948 match result {
1949 super::cache::CacheLookup::Hit {
1950 media_type, body, ..
1951 } => {
1952 assert_eq!(media_type, MediaType::Png);
1953 assert_eq!(body, b"png-data");
1954 }
1955 super::cache::CacheLookup::Miss => panic!("expected cache hit"),
1956 }
1957 }
1958
1959 #[test]
1960 fn transform_cache_miss_for_unknown_key() {
1961 let dir = tempfile::tempdir().expect("create tempdir");
1962 let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
1963
1964 let result = cache.get("0000001234567890abcdef1234567890abcdef1234567890abcdef1234567890");
1965 assert!(matches!(result, super::cache::CacheLookup::Miss));
1966 }
1967
1968 #[test]
1969 fn transform_cache_uses_sharded_layout() {
1970 let dir = tempfile::tempdir().expect("create tempdir");
1971 let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
1972
1973 let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
1974 cache.put(key, MediaType::Jpeg, b"jpeg-data");
1975
1976 let expected = dir.path().join("ab").join("cd").join("ef").join(key);
1977 assert!(
1978 expected.exists(),
1979 "sharded file should exist at {expected:?}"
1980 );
1981 }
1982
1983 #[test]
1984 fn transform_cache_expired_entry_is_miss() {
1985 let dir = tempfile::tempdir().expect("create tempdir");
1986 let mut cache = super::cache::TransformCache::new(dir.path().to_path_buf());
1987 cache.ttl = Duration::from_secs(0);
1988
1989 let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
1990 cache.put(key, MediaType::Png, b"data");
1991
1992 std::thread::sleep(Duration::from_millis(10));
1993
1994 let result = cache.get(key);
1995 assert!(matches!(result, super::cache::CacheLookup::Miss));
1996 }
1997
1998 #[test]
1999 fn transform_cache_handles_corrupted_entry_as_miss() {
2000 let dir = tempfile::tempdir().expect("create tempdir");
2001 let cache = super::cache::TransformCache::new(dir.path().to_path_buf());
2002
2003 let key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2004 let path = cache.entry_path(key);
2005 fs::create_dir_all(path.parent().unwrap()).unwrap();
2006 fs::write(&path, b"corrupted-data-without-header").unwrap();
2007
2008 let result = cache.get(key);
2009 assert!(matches!(result, super::cache::CacheLookup::Miss));
2010 }
2011
2012 #[test]
2013 fn cache_status_header_reflects_hit() {
2014 let headers = build_image_response_headers(
2015 MediaType::Png,
2016 &build_image_etag(b"data"),
2017 ImageResponsePolicy::PublicGet,
2018 false,
2019 CacheHitStatus::Hit,
2020 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2021 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2022 );
2023 assert!(headers.contains(&("Cache-Status".to_string(), "\"truss\"; hit".to_string())));
2024 }
2025
2026 #[test]
2027 fn cache_status_header_reflects_miss() {
2028 let headers = build_image_response_headers(
2029 MediaType::Png,
2030 &build_image_etag(b"data"),
2031 ImageResponsePolicy::PublicGet,
2032 false,
2033 CacheHitStatus::Miss,
2034 DEFAULT_PUBLIC_MAX_AGE_SECONDS,
2035 DEFAULT_PUBLIC_STALE_WHILE_REVALIDATE_SECONDS,
2036 );
2037 assert!(headers.contains(&(
2038 "Cache-Status".to_string(),
2039 "\"truss\"; fwd=miss".to_string()
2040 )));
2041 }
2042
2043 #[test]
2044 fn origin_cache_put_and_get_round_trips() {
2045 let dir = tempfile::tempdir().expect("create tempdir");
2046 let cache = super::cache::OriginCache::new(dir.path());
2047
2048 cache.put("https://example.com/image.png", b"raw-source-bytes");
2049 let result = cache.get("https://example.com/image.png");
2050
2051 assert_eq!(result.as_deref(), Some(b"raw-source-bytes".as_ref()));
2052 }
2053
2054 #[test]
2055 fn origin_cache_miss_for_unknown_url() {
2056 let dir = tempfile::tempdir().expect("create tempdir");
2057 let cache = super::cache::OriginCache::new(dir.path());
2058
2059 assert!(
2060 cache
2061 .get("https://unknown.example.com/missing.png")
2062 .is_none()
2063 );
2064 }
2065
2066 #[test]
2067 fn origin_cache_expired_entry_is_none() {
2068 let dir = tempfile::tempdir().expect("create tempdir");
2069 let mut cache = super::cache::OriginCache::new(dir.path());
2070 cache.ttl = Duration::from_secs(0);
2071
2072 cache.put("https://example.com/img.png", b"data");
2073 std::thread::sleep(Duration::from_millis(10));
2074
2075 assert!(cache.get("https://example.com/img.png").is_none());
2076 }
2077
2078 #[test]
2079 fn origin_cache_uses_origin_subdirectory() {
2080 let dir = tempfile::tempdir().expect("create tempdir");
2081 let cache = super::cache::OriginCache::new(dir.path());
2082
2083 cache.put("https://example.com/test.png", b"bytes");
2084
2085 let origin_dir = dir.path().join("origin");
2086 assert!(origin_dir.exists(), "origin subdirectory should exist");
2087 }
2088
2089 #[test]
2090 fn sign_public_url_builds_a_signed_path_url() {
2091 let url = sign_public_url(
2092 "https://cdn.example.com",
2093 SignedUrlSource::Path {
2094 path: "/image.png".to_string(),
2095 version: Some("v1".to_string()),
2096 },
2097 &crate::TransformOptions {
2098 format: Some(MediaType::Jpeg),
2099 width: Some(320),
2100 ..crate::TransformOptions::default()
2101 },
2102 "public-dev",
2103 "secret-value",
2104 4_102_444_800,
2105 )
2106 .expect("sign public URL");
2107
2108 assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
2109 assert!(url.contains("path=%2Fimage.png"));
2110 assert!(url.contains("version=v1"));
2111 assert!(url.contains("width=320"));
2112 assert!(url.contains("format=jpeg"));
2113 assert!(url.contains("keyId=public-dev"));
2114 assert!(url.contains("expires=4102444800"));
2115 assert!(url.contains("signature="));
2116 }
2117
2118 #[test]
2119 fn parse_public_get_request_rejects_unknown_query_parameters() {
2120 let query = BTreeMap::from([
2121 ("path".to_string(), "/image.png".to_string()),
2122 ("keyId".to_string(), "public-dev".to_string()),
2123 ("expires".to_string(), "4102444800".to_string()),
2124 ("signature".to_string(), "deadbeef".to_string()),
2125 ("unexpected".to_string(), "value".to_string()),
2126 ]);
2127
2128 let response = parse_public_get_request(&query, PublicSourceKind::Path)
2129 .expect_err("unknown query should fail");
2130
2131 assert_eq!(response.status, "400 Bad Request");
2132 assert!(response_body(&response).contains("is not supported"));
2133 }
2134
2135 #[test]
2136 fn prepare_remote_fetch_target_pins_the_validated_netloc() {
2137 let target = prepare_remote_fetch_target(
2138 "http://1.1.1.1/image.png",
2139 &ServerConfig::new(temp_dir("pin"), Some("secret".to_string())),
2140 )
2141 .expect("prepare remote target");
2142
2143 assert_eq!(target.netloc, "1.1.1.1:80");
2144 assert_eq!(target.addrs, vec![SocketAddr::from(([1, 1, 1, 1], 80))]);
2145 }
2146
2147 #[test]
2148 fn pinned_resolver_rejects_unexpected_netlocs() {
2149 use ureq::unversioned::resolver::Resolver;
2150
2151 let resolver = PinnedResolver {
2152 expected_netloc: "example.com:443".to_string(),
2153 addrs: vec![SocketAddr::from(([93, 184, 216, 34], 443))],
2154 };
2155
2156 let config = ureq::config::Config::builder().build();
2157 let timeout = ureq::unversioned::transport::NextTimeout {
2158 after: ureq::unversioned::transport::time::Duration::Exact(
2159 std::time::Duration::from_secs(30),
2160 ),
2161 reason: ureq::Timeout::Resolve,
2162 };
2163
2164 let uri: ureq::http::Uri = "https://example.com/path".parse().unwrap();
2165 let result = resolver
2166 .resolve(&uri, &config, timeout)
2167 .expect("resolve expected netloc");
2168 assert_eq!(&result[..], &[SocketAddr::from(([93, 184, 216, 34], 443))]);
2169
2170 let bad_uri: ureq::http::Uri = "https://proxy.example:8080/path".parse().unwrap();
2171 let timeout2 = ureq::unversioned::transport::NextTimeout {
2172 after: ureq::unversioned::transport::time::Duration::Exact(
2173 std::time::Duration::from_secs(30),
2174 ),
2175 reason: ureq::Timeout::Resolve,
2176 };
2177 let error = resolver
2178 .resolve(&bad_uri, &config, timeout2)
2179 .expect_err("unexpected netloc should fail");
2180 assert!(matches!(error, ureq::Error::HostNotFound));
2181 }
2182
2183 #[test]
2184 fn health_live_returns_status_service_version() {
2185 let request = HttpRequest {
2186 method: "GET".to_string(),
2187 target: "/health/live".to_string(),
2188 version: "HTTP/1.1".to_string(),
2189 headers: Vec::new(),
2190 body: Vec::new(),
2191 };
2192
2193 let response = route_request(request, &ServerConfig::new(temp_dir("live"), None));
2194
2195 assert_eq!(response.status, "200 OK");
2196 let body: serde_json::Value =
2197 serde_json::from_slice(&response.body).expect("parse live body");
2198 assert_eq!(body["status"], "ok");
2199 assert_eq!(body["service"], "truss");
2200 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2201 }
2202
2203 #[test]
2204 fn health_ready_returns_ok_when_storage_exists() {
2205 let storage = temp_dir("ready-ok");
2206 let request = HttpRequest {
2207 method: "GET".to_string(),
2208 target: "/health/ready".to_string(),
2209 version: "HTTP/1.1".to_string(),
2210 headers: Vec::new(),
2211 body: Vec::new(),
2212 };
2213
2214 let response = route_request(request, &ServerConfig::new(storage, None));
2215
2216 assert_eq!(response.status, "200 OK");
2217 let body: serde_json::Value =
2218 serde_json::from_slice(&response.body).expect("parse ready body");
2219 assert_eq!(body["status"], "ok");
2220 let checks = body["checks"].as_array().expect("checks array");
2221 assert!(
2222 checks
2223 .iter()
2224 .any(|c| c["name"] == "storageRoot" && c["status"] == "ok")
2225 );
2226 }
2227
2228 #[test]
2229 fn health_ready_returns_503_when_storage_missing() {
2230 let request = HttpRequest {
2231 method: "GET".to_string(),
2232 target: "/health/ready".to_string(),
2233 version: "HTTP/1.1".to_string(),
2234 headers: Vec::new(),
2235 body: Vec::new(),
2236 };
2237
2238 let config = ServerConfig::new(PathBuf::from("/nonexistent-truss-test-dir"), None);
2239 let response = route_request(request, &config);
2240
2241 assert_eq!(response.status, "503 Service Unavailable");
2242 let body: serde_json::Value =
2243 serde_json::from_slice(&response.body).expect("parse ready fail body");
2244 assert_eq!(body["status"], "fail");
2245 let checks = body["checks"].as_array().expect("checks array");
2246 assert!(
2247 checks
2248 .iter()
2249 .any(|c| c["name"] == "storageRoot" && c["status"] == "fail")
2250 );
2251 }
2252
2253 #[test]
2254 fn health_ready_returns_503_when_cache_root_missing() {
2255 let storage = temp_dir("ready-cache-fail");
2256 let mut config = ServerConfig::new(storage, None);
2257 config.cache_root = Some(PathBuf::from("/nonexistent-truss-cache-dir"));
2258
2259 let request = HttpRequest {
2260 method: "GET".to_string(),
2261 target: "/health/ready".to_string(),
2262 version: "HTTP/1.1".to_string(),
2263 headers: Vec::new(),
2264 body: Vec::new(),
2265 };
2266
2267 let response = route_request(request, &config);
2268
2269 assert_eq!(response.status, "503 Service Unavailable");
2270 let body: serde_json::Value =
2271 serde_json::from_slice(&response.body).expect("parse ready cache body");
2272 assert_eq!(body["status"], "fail");
2273 let checks = body["checks"].as_array().expect("checks array");
2274 assert!(
2275 checks
2276 .iter()
2277 .any(|c| c["name"] == "cacheRoot" && c["status"] == "fail")
2278 );
2279 }
2280
2281 #[test]
2282 fn health_returns_comprehensive_diagnostic() {
2283 let storage = temp_dir("health-diag");
2284 let request = HttpRequest {
2285 method: "GET".to_string(),
2286 target: "/health".to_string(),
2287 version: "HTTP/1.1".to_string(),
2288 headers: Vec::new(),
2289 body: Vec::new(),
2290 };
2291
2292 let response = route_request(request, &ServerConfig::new(storage, None));
2293
2294 assert_eq!(response.status, "200 OK");
2295 let body: serde_json::Value =
2296 serde_json::from_slice(&response.body).expect("parse health body");
2297 assert_eq!(body["status"], "ok");
2298 assert_eq!(body["service"], "truss");
2299 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2300 assert!(body["uptimeSeconds"].is_u64());
2301 assert!(body["checks"].is_array());
2302 }
2303
2304 #[test]
2305 fn unknown_path_returns_not_found() {
2306 let request = HttpRequest {
2307 method: "GET".to_string(),
2308 target: "/unknown".to_string(),
2309 version: "HTTP/1.1".to_string(),
2310 headers: Vec::new(),
2311 body: Vec::new(),
2312 };
2313
2314 let response = route_request(request, &ServerConfig::new(temp_dir("not-found"), None));
2315
2316 assert_eq!(response.status, "404 Not Found");
2317 assert_eq!(
2318 response.content_type.as_deref(),
2319 Some("application/problem+json")
2320 );
2321 let body = response_body(&response);
2322 assert!(body.contains("\"type\":\"about:blank\""));
2323 assert!(body.contains("\"title\":\"Not Found\""));
2324 assert!(body.contains("\"status\":404"));
2325 assert!(body.contains("not found"));
2326 }
2327
2328 #[test]
2329 fn transform_endpoint_requires_authentication() {
2330 let storage_root = temp_dir("auth");
2331 write_png(&storage_root.join("image.png"));
2332 let mut request = transform_request("/image.png");
2333 request.headers.retain(|(name, _)| name != "authorization");
2334
2335 let response = route_request(
2336 request,
2337 &ServerConfig::new(storage_root, Some("secret".to_string())),
2338 );
2339
2340 assert_eq!(response.status, "401 Unauthorized");
2341 assert!(response_body(&response).contains("authorization required"));
2342 }
2343
2344 #[test]
2345 fn transform_endpoint_returns_service_unavailable_without_configured_token() {
2346 let storage_root = temp_dir("token");
2347 write_png(&storage_root.join("image.png"));
2348
2349 let response = route_request(
2350 transform_request("/image.png"),
2351 &ServerConfig::new(storage_root, None),
2352 );
2353
2354 assert_eq!(response.status, "503 Service Unavailable");
2355 assert!(response_body(&response).contains("bearer token is not configured"));
2356 }
2357
2358 #[test]
2359 fn transform_endpoint_transforms_a_path_source() {
2360 let storage_root = temp_dir("transform");
2361 write_png(&storage_root.join("image.png"));
2362
2363 let response = route_request(
2364 transform_request("/image.png"),
2365 &ServerConfig::new(storage_root, Some("secret".to_string())),
2366 );
2367
2368 assert_eq!(response.status, "200 OK");
2369 assert_eq!(response.content_type.as_deref(), Some("image/jpeg"));
2370
2371 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2372 assert_eq!(artifact.media_type, MediaType::Jpeg);
2373 assert_eq!(artifact.metadata.width, Some(4));
2374 assert_eq!(artifact.metadata.height, Some(3));
2375 }
2376
2377 #[test]
2378 fn transform_endpoint_rejects_private_url_sources_by_default() {
2379 let response = route_request(
2380 transform_url_request("http://127.0.0.1:8080/image.png"),
2381 &ServerConfig::new(temp_dir("url-blocked"), Some("secret".to_string())),
2382 );
2383
2384 assert_eq!(response.status, "403 Forbidden");
2385 assert!(response_body(&response).contains("port is not allowed"));
2386 }
2387
2388 #[test]
2389 fn transform_endpoint_transforms_a_url_source_when_insecure_allowance_is_enabled() {
2390 let (url, handle) = spawn_http_server(vec![(
2391 "200 OK".to_string(),
2392 vec![("Content-Type".to_string(), "image/png".to_string())],
2393 png_bytes(),
2394 )]);
2395
2396 let response = route_request(
2397 transform_url_request(&url),
2398 &ServerConfig::new(temp_dir("url"), Some("secret".to_string()))
2399 .with_insecure_url_sources(true),
2400 );
2401
2402 handle.join().expect("join fixture server");
2403
2404 assert_eq!(response.status, "200 OK");
2405 assert_eq!(response.content_type.as_deref(), Some("image/jpeg"));
2406
2407 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2408 assert_eq!(artifact.media_type, MediaType::Jpeg);
2409 }
2410
2411 #[test]
2412 fn transform_endpoint_follows_remote_redirects() {
2413 let (redirect_url, handle) = spawn_http_server(vec![
2414 (
2415 "302 Found".to_string(),
2416 vec![("Location".to_string(), "/final-image".to_string())],
2417 Vec::new(),
2418 ),
2419 (
2420 "200 OK".to_string(),
2421 vec![("Content-Type".to_string(), "image/png".to_string())],
2422 png_bytes(),
2423 ),
2424 ]);
2425
2426 let response = route_request(
2427 transform_url_request(&redirect_url),
2428 &ServerConfig::new(temp_dir("redirect"), Some("secret".to_string()))
2429 .with_insecure_url_sources(true),
2430 );
2431
2432 handle.join().expect("join fixture server");
2433
2434 assert_eq!(response.status, "200 OK");
2435 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2436 assert_eq!(artifact.media_type, MediaType::Jpeg);
2437 }
2438
2439 #[test]
2440 fn upload_endpoint_transforms_uploaded_file() {
2441 let response = route_request(
2442 upload_request(&png_bytes(), Some(r#"{"format":"jpeg"}"#)),
2443 &ServerConfig::new(temp_dir("upload"), Some("secret".to_string())),
2444 );
2445
2446 assert_eq!(response.status, "200 OK");
2447 assert_eq!(response.content_type.as_deref(), Some("image/jpeg"));
2448
2449 let artifact = sniff_artifact(RawArtifact::new(response.body, None)).expect("sniff output");
2450 assert_eq!(artifact.media_type, MediaType::Jpeg);
2451 }
2452
2453 #[test]
2454 fn upload_endpoint_requires_a_file_field() {
2455 let boundary = "truss-test-boundary";
2456 let request = HttpRequest {
2457 method: "POST".to_string(),
2458 target: "/images".to_string(),
2459 version: "HTTP/1.1".to_string(),
2460 headers: vec![
2461 ("authorization".to_string(), "Bearer secret".to_string()),
2462 (
2463 "content-type".to_string(),
2464 format!("multipart/form-data; boundary={boundary}"),
2465 ),
2466 ],
2467 body: format!(
2468 "--{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n{{\"format\":\"jpeg\"}}\r\n--{boundary}--\r\n"
2469 )
2470 .into_bytes(),
2471 };
2472
2473 let response = route_request(
2474 request,
2475 &ServerConfig::new(temp_dir("upload-missing-file"), Some("secret".to_string())),
2476 );
2477
2478 assert_eq!(response.status, "400 Bad Request");
2479 assert!(response_body(&response).contains("requires a `file` field"));
2480 }
2481
2482 #[test]
2483 fn upload_endpoint_rejects_non_multipart_content_type() {
2484 let request = HttpRequest {
2485 method: "POST".to_string(),
2486 target: "/images".to_string(),
2487 version: "HTTP/1.1".to_string(),
2488 headers: vec![
2489 ("authorization".to_string(), "Bearer secret".to_string()),
2490 ("content-type".to_string(), "application/json".to_string()),
2491 ],
2492 body: br#"{"file":"not-really-json"}"#.to_vec(),
2493 };
2494
2495 let response = route_request(
2496 request,
2497 &ServerConfig::new(temp_dir("upload-content-type"), Some("secret".to_string())),
2498 );
2499
2500 assert_eq!(response.status, "415 Unsupported Media Type");
2501 assert!(response_body(&response).contains("multipart/form-data"));
2502 }
2503
2504 #[test]
2505 fn parse_upload_request_extracts_file_and_options() {
2506 let request = upload_request(&png_bytes(), Some(r#"{"width":8,"format":"jpeg"}"#));
2507 let boundary =
2508 super::multipart::parse_multipart_boundary(&request).expect("parse boundary");
2509 let (file_bytes, options) =
2510 super::multipart::parse_upload_request(&request.body, &boundary)
2511 .expect("parse upload body");
2512
2513 assert_eq!(file_bytes, png_bytes());
2514 assert_eq!(options.width, Some(8));
2515 assert_eq!(options.format, Some(MediaType::Jpeg));
2516 }
2517
2518 #[test]
2519 fn metrics_endpoint_requires_authentication() {
2520 let response = route_request(
2521 metrics_request(false),
2522 &ServerConfig::new(temp_dir("metrics-auth"), Some("secret".to_string())),
2523 );
2524
2525 assert_eq!(response.status, "401 Unauthorized");
2526 assert!(response_body(&response).contains("authorization required"));
2527 }
2528
2529 #[test]
2530 fn metrics_endpoint_returns_prometheus_text() {
2531 super::metrics::record_http_metrics(super::metrics::RouteMetric::Health, "200 OK");
2532 let response = route_request(
2533 metrics_request(true),
2534 &ServerConfig::new(temp_dir("metrics"), Some("secret".to_string())),
2535 );
2536 let body = response_body(&response);
2537
2538 assert_eq!(response.status, "200 OK");
2539 assert_eq!(
2540 response.content_type.as_deref(),
2541 Some("text/plain; version=0.0.4; charset=utf-8")
2542 );
2543 assert!(body.contains("truss_http_requests_total"));
2544 assert!(body.contains("truss_http_requests_by_route_total{route=\"/health\"}"));
2545 assert!(body.contains("truss_http_responses_total{status=\"200\"}"));
2546 }
2547
2548 #[test]
2549 fn transform_endpoint_rejects_unsupported_remote_content_encoding() {
2550 let (url, handle) = spawn_http_server(vec![(
2551 "200 OK".to_string(),
2552 vec![
2553 ("Content-Type".to_string(), "image/png".to_string()),
2554 ("Content-Encoding".to_string(), "compress".to_string()),
2555 ],
2556 png_bytes(),
2557 )]);
2558
2559 let response = route_request(
2560 transform_url_request(&url),
2561 &ServerConfig::new(temp_dir("encoding"), Some("secret".to_string()))
2562 .with_insecure_url_sources(true),
2563 );
2564
2565 handle.join().expect("join fixture server");
2566
2567 assert_eq!(response.status, "502 Bad Gateway");
2568 assert!(response_body(&response).contains("unsupported content-encoding"));
2569 }
2570
2571 #[test]
2572 fn resolve_storage_path_rejects_parent_segments() {
2573 let storage_root = temp_dir("resolve");
2574 let response = resolve_storage_path(&storage_root, "../escape.png")
2575 .expect_err("parent segments should be rejected");
2576
2577 assert_eq!(response.status, "400 Bad Request");
2578 assert!(response_body(&response).contains("must not contain root"));
2579 }
2580
2581 #[test]
2582 fn read_request_parses_headers_and_body() {
2583 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{}";
2584 let mut cursor = Cursor::new(request_bytes);
2585 let request = read_request(&mut cursor).expect("parse request");
2586
2587 assert_eq!(request.method, "POST");
2588 assert_eq!(request.target, "/images:transform");
2589 assert_eq!(request.version, "HTTP/1.1");
2590 assert_eq!(request.header("host"), Some("localhost"));
2591 assert_eq!(request.body, b"{}");
2592 }
2593
2594 #[test]
2595 fn read_request_rejects_duplicate_content_length() {
2596 let request_bytes =
2597 b"POST /images:transform HTTP/1.1\r\nContent-Length: 2\r\nContent-Length: 2\r\n\r\n{}";
2598 let mut cursor = Cursor::new(request_bytes);
2599 let response = read_request(&mut cursor).expect_err("duplicate headers should fail");
2600
2601 assert_eq!(response.status, "400 Bad Request");
2602 assert!(response_body(&response).contains("content-length"));
2603 }
2604
2605 #[test]
2606 fn serve_once_handles_a_tcp_request() {
2607 let storage_root = temp_dir("serve-once");
2608 let config = ServerConfig::new(storage_root, None);
2609 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
2610 let addr = listener.local_addr().expect("read local addr");
2611
2612 let server = thread::spawn(move || serve_once_with_config(listener, &config));
2613
2614 let mut stream = TcpStream::connect(addr).expect("connect to test server");
2615 stream
2616 .write_all(b"GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
2617 .expect("write request");
2618
2619 let mut response = String::new();
2620 stream.read_to_string(&mut response).expect("read response");
2621
2622 server
2623 .join()
2624 .expect("join test server thread")
2625 .expect("serve one request");
2626
2627 assert!(response.starts_with("HTTP/1.1 200 OK"));
2628 assert!(response.contains("Content-Type: application/json"));
2629 assert!(response.contains("\"status\":\"ok\""));
2630 assert!(response.contains("\"service\":\"truss\""));
2631 assert!(response.contains("\"version\":"));
2632 }
2633
2634 #[test]
2635 fn helper_error_responses_use_rfc7807_problem_details() {
2636 let response = auth_required_response("authorization required");
2637 let bad_request = bad_request_response("bad input");
2638
2639 assert_eq!(
2640 response.content_type.as_deref(),
2641 Some("application/problem+json"),
2642 "error responses must use application/problem+json"
2643 );
2644 assert_eq!(
2645 bad_request.content_type.as_deref(),
2646 Some("application/problem+json"),
2647 );
2648
2649 let auth_body = response_body(&response);
2650 assert!(auth_body.contains("authorization required"));
2651 assert!(auth_body.contains("\"type\":\"about:blank\""));
2652 assert!(auth_body.contains("\"title\":\"Unauthorized\""));
2653 assert!(auth_body.contains("\"status\":401"));
2654
2655 let bad_body = response_body(&bad_request);
2656 assert!(bad_body.contains("bad input"));
2657 assert!(bad_body.contains("\"type\":\"about:blank\""));
2658 assert!(bad_body.contains("\"title\":\"Bad Request\""));
2659 assert!(bad_body.contains("\"status\":400"));
2660 }
2661
2662 #[test]
2663 fn parse_headers_rejects_duplicate_host() {
2664 let lines = "Host: example.com\r\nHost: evil.com\r\n";
2665 let result = super::http_parse::parse_headers(lines.split("\r\n"));
2666 assert!(result.is_err());
2667 }
2668
2669 #[test]
2670 fn parse_headers_rejects_duplicate_authorization() {
2671 let lines = "Authorization: Bearer a\r\nAuthorization: Bearer b\r\n";
2672 let result = super::http_parse::parse_headers(lines.split("\r\n"));
2673 assert!(result.is_err());
2674 }
2675
2676 #[test]
2677 fn parse_headers_rejects_duplicate_content_type() {
2678 let lines = "Content-Type: application/json\r\nContent-Type: text/plain\r\n";
2679 let result = super::http_parse::parse_headers(lines.split("\r\n"));
2680 assert!(result.is_err());
2681 }
2682
2683 #[test]
2684 fn parse_headers_rejects_duplicate_transfer_encoding() {
2685 let lines = "Transfer-Encoding: chunked\r\nTransfer-Encoding: gzip\r\n";
2686 let result = super::http_parse::parse_headers(lines.split("\r\n"));
2687 assert!(result.is_err());
2688 }
2689
2690 #[test]
2691 fn parse_headers_rejects_single_transfer_encoding() {
2692 let lines = "Host: example.com\r\nTransfer-Encoding: chunked\r\n";
2693 let result = super::http_parse::parse_headers(lines.split("\r\n"));
2694 let err = result.unwrap_err();
2695 assert!(
2696 err.status.starts_with("501"),
2697 "expected 501 status, got: {}",
2698 err.status
2699 );
2700 assert!(
2701 String::from_utf8_lossy(&err.body).contains("Transfer-Encoding"),
2702 "error response should mention Transfer-Encoding"
2703 );
2704 }
2705
2706 #[test]
2707 fn parse_headers_rejects_transfer_encoding_identity() {
2708 let lines = "Transfer-Encoding: identity\r\n";
2709 let result = super::http_parse::parse_headers(lines.split("\r\n"));
2710 assert!(result.is_err());
2711 }
2712
2713 #[test]
2714 fn parse_headers_allows_single_instances_of_singleton_headers() {
2715 let lines =
2716 "Host: example.com\r\nAuthorization: Bearer tok\r\nContent-Type: application/json\r\n";
2717 let result = super::http_parse::parse_headers(lines.split("\r\n"));
2718 assert!(result.is_ok());
2719 assert_eq!(result.unwrap().len(), 3);
2720 }
2721
2722 #[test]
2723 fn max_body_for_multipart_uses_upload_limit() {
2724 let headers = vec![(
2725 "content-type".to_string(),
2726 "multipart/form-data; boundary=abc".to_string(),
2727 )];
2728 assert_eq!(
2729 super::http_parse::max_body_for_headers(&headers),
2730 super::http_parse::MAX_UPLOAD_BODY_BYTES
2731 );
2732 }
2733
2734 #[test]
2735 fn max_body_for_json_uses_default_limit() {
2736 let headers = vec![("content-type".to_string(), "application/json".to_string())];
2737 assert_eq!(
2738 super::http_parse::max_body_for_headers(&headers),
2739 super::http_parse::MAX_REQUEST_BODY_BYTES
2740 );
2741 }
2742
2743 #[test]
2744 fn max_body_for_no_content_type_uses_default_limit() {
2745 let headers: Vec<(String, String)> = vec![];
2746 assert_eq!(
2747 super::http_parse::max_body_for_headers(&headers),
2748 super::http_parse::MAX_REQUEST_BODY_BYTES
2749 );
2750 }
2751
2752 fn make_test_config() -> ServerConfig {
2753 ServerConfig::new(std::env::temp_dir(), None)
2754 }
2755
2756 #[test]
2757 fn versioned_source_hash_returns_none_without_version() {
2758 let source = TransformSourcePayload::Path {
2759 path: "/photos/hero.jpg".to_string(),
2760 version: None,
2761 };
2762 assert!(source.versioned_source_hash(&make_test_config()).is_none());
2763 }
2764
2765 #[test]
2766 fn versioned_source_hash_is_deterministic() {
2767 let cfg = make_test_config();
2768 let source = TransformSourcePayload::Path {
2769 path: "/photos/hero.jpg".to_string(),
2770 version: Some("v1".to_string()),
2771 };
2772 let hash1 = source.versioned_source_hash(&cfg).unwrap();
2773 let hash2 = source.versioned_source_hash(&cfg).unwrap();
2774 assert_eq!(hash1, hash2);
2775 assert_eq!(hash1.len(), 64);
2776 }
2777
2778 #[test]
2779 fn versioned_source_hash_differs_by_version() {
2780 let cfg = make_test_config();
2781 let v1 = TransformSourcePayload::Path {
2782 path: "/photos/hero.jpg".to_string(),
2783 version: Some("v1".to_string()),
2784 };
2785 let v2 = TransformSourcePayload::Path {
2786 path: "/photos/hero.jpg".to_string(),
2787 version: Some("v2".to_string()),
2788 };
2789 assert_ne!(
2790 v1.versioned_source_hash(&cfg).unwrap(),
2791 v2.versioned_source_hash(&cfg).unwrap()
2792 );
2793 }
2794
2795 #[test]
2796 fn versioned_source_hash_differs_by_kind() {
2797 let cfg = make_test_config();
2798 let path = TransformSourcePayload::Path {
2799 path: "example.com/image.jpg".to_string(),
2800 version: Some("v1".to_string()),
2801 };
2802 let url = TransformSourcePayload::Url {
2803 url: "example.com/image.jpg".to_string(),
2804 version: Some("v1".to_string()),
2805 };
2806 assert_ne!(
2807 path.versioned_source_hash(&cfg).unwrap(),
2808 url.versioned_source_hash(&cfg).unwrap()
2809 );
2810 }
2811
2812 #[test]
2813 fn versioned_source_hash_differs_by_storage_root() {
2814 let cfg1 = ServerConfig::new(PathBuf::from("/data/images"), None);
2815 let cfg2 = ServerConfig::new(PathBuf::from("/other/images"), None);
2816 let source = TransformSourcePayload::Path {
2817 path: "/photos/hero.jpg".to_string(),
2818 version: Some("v1".to_string()),
2819 };
2820 assert_ne!(
2821 source.versioned_source_hash(&cfg1).unwrap(),
2822 source.versioned_source_hash(&cfg2).unwrap()
2823 );
2824 }
2825
2826 #[test]
2827 fn versioned_source_hash_differs_by_insecure_flag() {
2828 let mut cfg1 = make_test_config();
2829 cfg1.allow_insecure_url_sources = false;
2830 let mut cfg2 = make_test_config();
2831 cfg2.allow_insecure_url_sources = true;
2832 let source = TransformSourcePayload::Url {
2833 url: "http://example.com/img.jpg".to_string(),
2834 version: Some("v1".to_string()),
2835 };
2836 assert_ne!(
2837 source.versioned_source_hash(&cfg1).unwrap(),
2838 source.versioned_source_hash(&cfg2).unwrap()
2839 );
2840 }
2841
2842 #[test]
2843 fn read_request_rejects_json_body_over_1mib() {
2844 let body = vec![b'x'; super::http_parse::MAX_REQUEST_BODY_BYTES + 1];
2845 let content_length = body.len();
2846 let raw = format!(
2847 "POST /images:transform HTTP/1.1\r\n\
2848 Content-Type: application/json\r\n\
2849 Content-Length: {content_length}\r\n\r\n"
2850 );
2851 let mut data = raw.into_bytes();
2852 data.extend_from_slice(&body);
2853 let result = read_request(&mut data.as_slice());
2854 assert!(result.is_err());
2855 }
2856
2857 #[test]
2858 fn read_request_accepts_multipart_body_over_1mib() {
2859 let payload_size = super::http_parse::MAX_REQUEST_BODY_BYTES + 100;
2860 let body_content = vec![b'A'; payload_size];
2861 let boundary = "test-boundary-123";
2862 let mut body = Vec::new();
2863 body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"big.jpg\"\r\n\r\n").as_bytes());
2864 body.extend_from_slice(&body_content);
2865 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
2866 let content_length = body.len();
2867 let raw = format!(
2868 "POST /images HTTP/1.1\r\n\
2869 Content-Type: multipart/form-data; boundary={boundary}\r\n\
2870 Content-Length: {content_length}\r\n\r\n"
2871 );
2872 let mut data = raw.into_bytes();
2873 data.extend_from_slice(&body);
2874 let result = read_request(&mut data.as_slice());
2875 assert!(
2876 result.is_ok(),
2877 "multipart upload over 1 MiB should be accepted"
2878 );
2879 }
2880
2881 #[test]
2882 fn multipart_boundary_in_payload_does_not_split_part() {
2883 let boundary = "abc123";
2884 let fake_boundary_in_payload = format!("\r\n--{boundary}NOTREAL");
2885 let part_body = format!("before{fake_boundary_in_payload}after");
2886 let body = format!(
2887 "--{boundary}\r\n\
2888 Content-Disposition: form-data; name=\"file\"\r\n\
2889 Content-Type: application/octet-stream\r\n\r\n\
2890 {part_body}\r\n\
2891 --{boundary}--\r\n"
2892 );
2893
2894 let parts = parse_multipart_form_data(body.as_bytes(), boundary)
2895 .expect("should parse despite boundary-like string in payload");
2896 assert_eq!(parts.len(), 1, "should have exactly one part");
2897
2898 let part_data = &body.as_bytes()[parts[0].body_range.clone()];
2899 let part_text = std::str::from_utf8(part_data).unwrap();
2900 assert!(
2901 part_text.contains("NOTREAL"),
2902 "part body should contain the full fake boundary string"
2903 );
2904 }
2905
2906 #[test]
2907 fn multipart_normal_two_parts_still_works() {
2908 let boundary = "testboundary";
2909 let body = format!(
2910 "--{boundary}\r\n\
2911 Content-Disposition: form-data; name=\"field1\"\r\n\r\n\
2912 value1\r\n\
2913 --{boundary}\r\n\
2914 Content-Disposition: form-data; name=\"field2\"\r\n\r\n\
2915 value2\r\n\
2916 --{boundary}--\r\n"
2917 );
2918
2919 let parts = parse_multipart_form_data(body.as_bytes(), boundary)
2920 .expect("should parse two normal parts");
2921 assert_eq!(parts.len(), 2);
2922 assert_eq!(parts[0].name, "field1");
2923 assert_eq!(parts[1].name, "field2");
2924 }
2925}