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