1#![cfg_attr(
30 not(test),
31 deny(
32 clippy::unwrap_used,
33 clippy::expect_used,
34 clippy::panic,
35 clippy::unreachable,
36 clippy::todo,
37 clippy::unimplemented,
38 clippy::indexing_slicing,
39 clippy::string_slice,
40 clippy::arithmetic_side_effects,
41 )
42)]
43
44use axum::extract::{FromRequest, FromRequestParts};
45use axum::response::{IntoResponse, Response};
46
47macro_rules! impl_extractor_deref {
48 ($extractor:ident) => {
49 impl<T> std::ops::Deref for $extractor<T> {
50 type Target = T;
51
52 fn deref(&self) -> &Self::Target {
53 &self.0
54 }
55 }
56
57 impl<T> std::ops::DerefMut for $extractor<T> {
58 fn deref_mut(&mut self) -> &mut Self::Target {
59 &mut self.0
60 }
61 }
62 };
63}
64
65#[derive(Debug, Clone, Copy, Default)]
70pub struct Form<T>(pub T);
71
72impl_extractor_deref!(Form);
73
74impl<S, T> FromRequest<S> for Form<T>
75where
76 S: Send + Sync,
77 axum::extract::Form<T>: FromRequest<S, Rejection = axum::extract::rejection::FormRejection>,
78{
79 type Rejection = crate::AutumnError;
80
81 async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
82 axum::extract::Form::from_request(req, state)
83 .await
84 .map(|axum::extract::Form(value)| Self(value))
85 .map_err(|err| rejection_to_error(err.status(), err.body_text()))
86 }
87}
88
89#[derive(Debug, Clone, Copy, Default)]
95pub struct Json<T>(pub T);
96
97impl_extractor_deref!(Json);
98
99impl<S, T> FromRequest<S> for Json<T>
100where
101 S: Send + Sync,
102 axum::extract::Json<T>: FromRequest<S, Rejection = axum::extract::rejection::JsonRejection>,
103{
104 type Rejection = crate::AutumnError;
105
106 async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
107 axum::extract::Json::from_request(req, state)
108 .await
109 .map(|axum::extract::Json(value)| Self(value))
110 .map_err(|err| rejection_to_error(err.status(), err.body_text()))
111 }
112}
113
114impl<T> IntoResponse for Json<T>
126where
127 T: crate::classify::JsonSink,
128{
129 fn into_response(self) -> Response {
130 axum::Json(self.0).into_response()
131 }
132}
133
134#[cfg(feature = "csv")]
157pub struct Csv<T>(pub T);
158
159#[cfg(feature = "csv")]
160impl_extractor_deref!(Csv);
161
162#[cfg(feature = "csv")]
163impl<S, T> FromRequest<S> for Csv<Vec<T>>
164where
165 S: Send + Sync,
166 T: for<'de> serde::Deserialize<'de>,
167{
168 type Rejection = crate::AutumnError;
169
170 async fn from_request(
171 req: axum::extract::Request,
172 _state: &S,
173 ) -> Result<Self, Self::Rejection> {
174 let bytes = axum::body::to_bytes(req.into_body(), usize::MAX)
175 .await
176 .map_err(|err| {
177 crate::AutumnError::bad_request_msg(format!("Failed to read body: {err}"))
178 })?;
179
180 let mut reader = ::csv::Reader::from_reader(bytes.as_ref());
181 let mut results = Vec::new();
182 for result in reader.deserialize::<T>() {
183 let record = result.map_err(|err| {
184 crate::AutumnError::bad_request_msg(format!("CSV parse error: {err}"))
185 })?;
186 results.push(record);
187 }
188 Ok(Self(results))
189 }
190}
191
192#[cfg(feature = "csv")]
193impl<T> IntoResponse for Csv<Vec<T>>
194where
195 T: serde::Serialize,
196{
197 fn into_response(self) -> Response {
198 let mut writer = ::csv::Writer::from_writer(Vec::new());
199 for record in &self.0 {
200 if let Err(e) = writer.serialize(record) {
201 return rejection_to_error(
202 http::StatusCode::INTERNAL_SERVER_ERROR,
203 format!("CSV serialization failed: {e}"),
204 )
205 .into_response();
206 }
207 }
208 let bytes = match writer.into_inner() {
209 Ok(b) => b,
210 Err(e) => {
211 return rejection_to_error(
212 http::StatusCode::INTERNAL_SERVER_ERROR,
213 format!("CSV writer flush failed: {e}"),
214 )
215 .into_response();
216 }
217 };
218
219 let mut res = bytes.into_response();
220 res.headers_mut().insert(
221 http::header::CONTENT_TYPE,
222 http::HeaderValue::from_static("text/csv; charset=utf-8"),
223 );
224 res
225 }
226}
227
228#[derive(Debug, Clone, Copy, Default)]
233pub struct Path<T>(pub T);
234
235impl_extractor_deref!(Path);
236
237impl<S, T> FromRequestParts<S> for Path<T>
238where
239 S: Send + Sync,
240 axum::extract::Path<T>:
241 FromRequestParts<S, Rejection = axum::extract::rejection::PathRejection>,
242{
243 type Rejection = crate::AutumnError;
244
245 async fn from_request_parts(
246 parts: &mut axum::http::request::Parts,
247 state: &S,
248 ) -> Result<Self, Self::Rejection> {
249 axum::extract::Path::from_request_parts(parts, state)
250 .await
251 .map(|axum::extract::Path(value)| Self(value))
252 .map_err(|err| rejection_to_error(err.status(), err.body_text()))
253 }
254}
255
256#[derive(Debug, Clone, Copy, Default)]
287pub struct Query<T>(pub T);
288
289impl_extractor_deref!(Query);
290
291impl<S, T> FromRequestParts<S> for Query<T>
292where
293 S: Send + Sync,
294 T: serde::de::DeserializeOwned,
295{
296 type Rejection = crate::AutumnError;
297
298 async fn from_request_parts(
299 parts: &mut axum::http::request::Parts,
300 _state: &S,
301 ) -> Result<Self, Self::Rejection> {
302 let query = parts.uri.query().unwrap_or_default();
303 crate::query_string::from_query_str::<T>(query)
304 .map(Self)
305 .map_err(|err| {
306 rejection_to_error(
307 http::StatusCode::BAD_REQUEST,
308 format!("Failed to deserialize query string: {err}"),
309 )
310 })
311 }
312}
313
314fn rejection_to_error(status: http::StatusCode, body_text: String) -> crate::AutumnError {
315 crate::AutumnError::bad_request_msg(body_text).with_status(status)
316}
317
318#[cfg(feature = "multipart")]
329pub struct Multipart {
330 inner: axum::extract::Multipart,
331 config: crate::security::config::UploadConfig,
332}
333
334#[cfg(feature = "multipart")]
335impl Multipart {
336 pub(crate) async fn from_request_with_route_limit<S>(
341 req: axum::extract::Request,
342 state: &S,
343 ) -> crate::AutumnResult<Self>
344 where
345 S: Send + Sync,
346 {
347 let config = req
348 .extensions()
349 .get::<crate::security::config::UploadConfig>()
350 .cloned()
351 .unwrap_or_default();
352 let inner = axum::extract::Multipart::from_request(req, state)
353 .await
354 .map_err(|err| multipart_rejection_to_error(&err))?;
355 Ok(Self { inner, config })
356 }
357
358 pub async fn next_field(&mut self) -> crate::AutumnResult<Option<MultipartField<'_>>> {
365 let Some(mut field) = self
366 .inner
367 .next_field()
368 .await
369 .map_err(|err| multipart_error_to_error(&err))?
370 else {
371 return Ok(None);
372 };
373
374 let needs_sniff = field.file_name().is_some()
381 && (!self.config.allowed_mime_types.is_empty()
382 || self.config.reject_on_content_type_mismatch);
383
384 let filename_is_empty = field.file_name().is_some_and(str::is_empty);
395
396 if !needs_sniff {
397 return Ok(Some(MultipartField::new(
407 field,
408 self.config.max_file_size_bytes,
409 )));
410 }
411
412 let mut prefix: Vec<u8> = Vec::with_capacity(SNIFF_PREFIX_BYTES);
416 while prefix.len() < SNIFF_PREFIX_BYTES {
417 match field
418 .chunk()
419 .await
420 .map_err(|err| multipart_error_to_error(&err))?
421 {
422 Some(chunk) => {
423 prefix.extend_from_slice(&chunk);
424 if prefix.len() > self.config.max_file_size_bytes {
427 return Err(file_too_large_error(self.config.max_file_size_bytes));
428 }
429 }
430 None => break,
431 }
432 }
433
434 let declared_essence = field.content_type().map(content_type_essence);
440 let sniffed = sniff_content_type(&prefix);
441
442 let is_empty_file_input = filename_is_empty && prefix.is_empty();
449 if !is_empty_file_input {
450 if !self.config.allowed_mime_types.is_empty() {
454 enforce_upload_allow_list(
455 &self.config.allowed_mime_types,
456 &prefix,
457 declared_essence,
458 sniffed,
459 )?;
460 }
461 if self.config.reject_on_content_type_mismatch {
462 enforce_content_type_match(declared_essence, sniffed)?;
463 }
464 }
465
466 Ok(Some(MultipartField {
467 inner: field,
468 max_file_size_bytes: self.config.max_file_size_bytes,
469 prefix,
470 sniffed_content_type: sniffed,
471 is_empty_optional_input: is_empty_file_input,
472 }))
473 }
474}
475
476#[cfg(feature = "multipart")]
480fn sniff_content_type(prefix: &[u8]) -> Option<&'static str> {
481 infer::get(prefix).map(|kind| kind.mime_type())
482}
483
484#[cfg(feature = "multipart")]
487fn content_type_essence(raw: &str) -> &str {
488 raw.split(';').next().unwrap_or("").trim()
489}
490
491#[cfg(feature = "multipart")]
497fn prefix_looks_like_markup(prefix: &[u8]) -> bool {
498 let bytes = prefix.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(prefix);
499 bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'<')
500}
501
502#[cfg(feature = "multipart")]
505fn truncate_for_error(value: &str) -> String {
506 const MAX_CHARS: usize = 128;
507 value.chars().take(MAX_CHARS).collect()
508}
509
510#[cfg(feature = "multipart")]
516fn is_signatureless_text_type(essence: &str) -> bool {
517 let lower = essence.to_ascii_lowercase();
519 lower.starts_with("text/") || matches!(lower.as_str(), "application/json" | "application/csv")
520}
521
522#[cfg(feature = "multipart")]
535fn enforce_upload_allow_list(
536 allowed: &[String],
537 prefix: &[u8],
538 declared_essence: Option<&str>,
539 sniffed: Option<&str>,
540) -> crate::AutumnResult<()> {
541 let in_list = |value: &str| {
542 allowed
543 .iter()
544 .any(|entry| entry.eq_ignore_ascii_case(value))
545 };
546 if let Some(sniffed) = sniffed {
547 if !in_list(sniffed) {
548 return Err(crate::AutumnError::bad_request_msg(format!(
549 "upload content type not allowed: sniffed={sniffed}"
550 )));
551 }
552 } else if prefix_looks_like_markup(prefix) {
553 return Err(crate::AutumnError::bad_request_msg(
554 "upload rejected: unrecognized file content looks like markup",
555 ));
556 } else if !matches!(
557 declared_essence,
558 Some(essence) if is_signatureless_text_type(essence) && in_list(essence)
559 ) {
560 return Err(crate::AutumnError::bad_request_msg(format!(
561 "upload content type could not be verified from its content: declared={}",
562 declared_essence.map_or_else(String::new, truncate_for_error)
563 )));
564 }
565 Ok(())
566}
567
568#[cfg(feature = "multipart")]
573fn enforce_content_type_match(
574 declared_essence: Option<&str>,
575 sniffed: Option<&str>,
576) -> crate::AutumnResult<()> {
577 let Some(declared_essence) = declared_essence else {
578 return Err(crate::AutumnError::bad_request_msg(
579 "content-type mismatch check enabled but the upload declared no content type",
580 ));
581 };
582 match sniffed {
583 Some(sniffed) if declared_essence.eq_ignore_ascii_case(sniffed) => Ok(()),
584 Some(sniffed) => Err(crate::AutumnError::bad_request_msg(format!(
585 "declared content type {} does not match sniffed content type {sniffed}",
586 truncate_for_error(declared_essence)
587 ))),
588 None => Err(crate::AutumnError::bad_request_msg(format!(
589 "cannot verify declared content type {}: file content is unrecognized",
590 truncate_for_error(declared_essence)
591 ))),
592 }
593}
594
595#[cfg(feature = "multipart")]
596impl<S> axum::extract::FromRequest<S> for Multipart
597where
598 S: Send + Sync,
599 axum::extract::Multipart:
600 axum::extract::FromRequest<S, Rejection = axum::extract::multipart::MultipartRejection>,
601{
602 type Rejection = crate::AutumnError;
603
604 async fn from_request(
605 mut req: axum::extract::Request,
606 state: &S,
607 ) -> Result<Self, Self::Rejection> {
608 let max_request_size_bytes = req
609 .extensions()
610 .get::<crate::security::config::UploadConfig>()
611 .map_or_else(
612 || crate::security::config::UploadConfig::default().max_request_size_bytes,
613 |config| config.max_request_size_bytes,
614 );
615 axum::extract::DefaultBodyLimit::max(max_request_size_bytes).apply(&mut req);
616 Self::from_request_with_route_limit(req, state).await
617 }
618}
619
620#[cfg(feature = "multipart")]
625const SNIFF_PREFIX_BYTES: usize = 512;
626
627#[cfg(feature = "multipart")]
629pub struct MultipartField<'a> {
630 inner: axum::extract::multipart::Field<'a>,
631 max_file_size_bytes: usize,
632 prefix: Vec<u8>,
636 sniffed_content_type: Option<&'static str>,
639 is_empty_optional_input: bool,
649}
650
651#[cfg(all(feature = "multipart", feature = "storage"))]
652struct MultipartFieldStreamState<'a> {
653 inner: axum::extract::multipart::Field<'a>,
654 prefix: Option<bytes::Bytes>,
656 total: usize,
657 max: usize,
658 errored: bool,
659}
660
661#[cfg(feature = "multipart")]
662#[allow(clippy::elidable_lifetime_names)]
663impl<'a> MultipartField<'a> {
664 const fn new(inner: axum::extract::multipart::Field<'a>, max_file_size_bytes: usize) -> Self {
667 Self {
668 inner,
669 max_file_size_bytes,
670 prefix: Vec::new(),
671 sniffed_content_type: None,
672 is_empty_optional_input: false,
673 }
674 }
675
676 pub(crate) const fn is_empty_optional_input(&self) -> bool {
679 self.is_empty_optional_input
680 }
681
682 #[must_use]
684 pub fn name(&self) -> Option<&str> {
685 self.inner.name()
686 }
687
688 #[must_use]
696 pub const fn sniffed_content_type(&self) -> Option<&str> {
697 self.sniffed_content_type
698 }
699
700 #[must_use]
728 pub fn file_name(&self) -> Option<&str> {
729 if self.is_empty_optional_input {
730 None
731 } else {
732 self.inner.file_name()
733 }
734 }
735
736 #[must_use]
738 pub fn content_type(&self) -> Option<&str> {
739 self.inner.content_type()
740 }
741
742 #[must_use]
763 pub fn with_max_bytes(mut self, max: usize) -> Self {
764 self.max_file_size_bytes = self.max_file_size_bytes.min(max);
765 self
766 }
767
768 pub async fn bytes_limited(mut self) -> crate::AutumnResult<Vec<u8>> {
775 let mut out = self.prefix;
779 let mut read = out.len();
780 if read > self.max_file_size_bytes {
781 return Err(file_too_large_error(self.max_file_size_bytes));
782 }
783 while let Some(chunk) = self
784 .inner
785 .chunk()
786 .await
787 .map_err(|err| multipart_error_to_error(&err))?
788 {
789 read = read.saturating_add(chunk.len());
792 if read > self.max_file_size_bytes {
793 return Err(file_too_large_error(self.max_file_size_bytes));
794 }
795 out.extend_from_slice(&chunk);
796 }
797 Ok(out)
798 }
799
800 #[cfg(feature = "storage")]
837 pub async fn save_to_blob_store<'b>(
838 self,
839 store: &'b (dyn crate::storage::BlobStore + '_),
840 key: impl Into<String>,
841 ) -> crate::AutumnResult<crate::storage::Blob>
842 where
843 'a: 'b,
844 {
845 let key = key.into();
846 let content_type = self
849 .sniffed_content_type
850 .map(str::to_owned)
851 .or_else(|| self.inner.content_type().map(str::to_owned))
852 .unwrap_or_else(|| "application/octet-stream".to_owned());
853
854 let prefix = if self.prefix.is_empty() {
855 None
856 } else {
857 Some(bytes::Bytes::from(self.prefix))
858 };
859
860 let state = MultipartFieldStreamState {
866 inner: self.inner,
867 prefix,
868 total: 0,
869 max: self.max_file_size_bytes,
870 errored: false,
871 };
872
873 let stream = futures::stream::unfold(state, |mut state| async move {
874 if state.errored {
875 return None;
876 }
877 if let Some(prefix) = state.prefix.take() {
878 state.total = state.total.saturating_add(prefix.len());
879 if state.total > state.max {
880 let err = crate::storage::BlobStoreError::PayloadTooLarge(format!(
881 "uploaded file exceeds limit of {} bytes",
882 state.max,
883 ));
884 state.errored = true;
885 return Some((Err(err), state));
886 }
887 return Some((Ok(prefix), state));
888 }
889 match state.inner.chunk().await {
890 Ok(Some(chunk)) => {
891 state.total = state.total.saturating_add(chunk.len());
892 if state.total > state.max {
893 let err = crate::storage::BlobStoreError::PayloadTooLarge(format!(
894 "uploaded file exceeds limit of {} bytes",
895 state.max,
896 ));
897 state.errored = true;
898 Some((Err(err), state))
899 } else {
900 Some((Ok(chunk), state))
901 }
902 }
903 Ok(None) => None,
904 Err(err) => {
905 state.errored = true;
911 let mapped = blob_error_from_multipart(&err);
912 Some((Err(mapped), state))
913 }
914 }
915 });
916 let stream: crate::storage::ByteStream<'b> = Box::pin(stream);
917
918 store
919 .put_stream(&key, &content_type, stream)
920 .await
921 .map_err(crate::storage::BlobStoreError::into_autumn_error)
922 }
923
924 pub async fn save_to<P: AsRef<std::path::Path>>(
931 mut self,
932 path: P,
933 ) -> crate::AutumnResult<usize> {
934 use tokio::io::AsyncWriteExt as _;
935
936 let path = path.as_ref();
937 let mut file = tokio::fs::File::create(path)
938 .await
939 .map_err(crate::AutumnError::internal_server_error)?;
940
941 let mut written = 0usize;
942 if !self.prefix.is_empty() {
945 written = written.saturating_add(self.prefix.len());
946 if written > self.max_file_size_bytes {
947 drop(file);
948 let _ = tokio::fs::remove_file(path).await;
949 return Err(file_too_large_error(self.max_file_size_bytes));
950 }
951 file.write_all(&self.prefix)
952 .await
953 .map_err(crate::AutumnError::internal_server_error)?;
954 }
955 while let Some(chunk) = self
956 .inner
957 .chunk()
958 .await
959 .map_err(|err| multipart_error_to_error(&err))?
960 {
961 written = written.saturating_add(chunk.len());
964 if written > self.max_file_size_bytes {
965 drop(file);
966 let _ = tokio::fs::remove_file(path).await;
967 return Err(file_too_large_error(self.max_file_size_bytes));
968 }
969 file.write_all(&chunk)
970 .await
971 .map_err(crate::AutumnError::internal_server_error)?;
972 }
973 file.flush()
974 .await
975 .map_err(crate::AutumnError::internal_server_error)?;
976 Ok(written)
977 }
978}
979
980#[cfg(feature = "multipart")]
981fn multipart_rejection_to_error(
982 err: &axum::extract::multipart::MultipartRejection,
983) -> crate::AutumnError {
984 crate::AutumnError::bad_request_msg(err.body_text()).with_status(err.status())
985}
986
987#[cfg(feature = "multipart")]
988#[cfg(all(feature = "multipart", feature = "storage"))]
996fn blob_error_from_multipart(
997 err: &axum::extract::multipart::MultipartError,
998) -> crate::storage::BlobStoreError {
999 let status = err.status();
1000 let body = err.body_text();
1001 if status == http::StatusCode::PAYLOAD_TOO_LARGE {
1002 crate::storage::BlobStoreError::PayloadTooLarge(body)
1003 } else if status.is_client_error() {
1004 crate::storage::BlobStoreError::InvalidInput(body)
1005 } else {
1006 crate::storage::BlobStoreError::Io(body)
1007 }
1008}
1009
1010#[cfg(feature = "multipart")]
1011fn multipart_error_to_error(err: &axum::extract::multipart::MultipartError) -> crate::AutumnError {
1012 crate::AutumnError::bad_request_msg(err.body_text()).with_status(err.status())
1013}
1014
1015#[cfg(feature = "multipart")]
1016fn file_too_large_error(max_file_size_bytes: usize) -> crate::AutumnError {
1017 crate::AutumnError::bad_request_msg(format!(
1018 "uploaded file exceeds limit of {max_file_size_bytes} bytes",
1019 ))
1020 .with_status(http::StatusCode::PAYLOAD_TOO_LARGE)
1021}
1022
1023pub use axum::extract::State;
1024
1025#[cfg(all(test, feature = "multipart"))]
1026mod tests {
1027 use super::*;
1028 use axum::extract::FromRequest;
1029 use axum::http::Request;
1030
1031 #[cfg(feature = "csv")]
1032 #[derive(serde::Deserialize, serde::Serialize, Debug, PartialEq)]
1033 struct TestCsvRecord {
1034 id: i32,
1035 name: String,
1036 }
1037
1038 #[cfg(feature = "csv")]
1039 #[tokio::test]
1040 async fn csv_extractor_and_response() {
1041 use axum::response::IntoResponse;
1042 use tower::ServiceExt;
1043
1044 async fn csv_handler(Csv(payload): Csv<Vec<TestCsvRecord>>) -> impl IntoResponse {
1045 Csv(payload)
1046 }
1047
1048 let app = axum::Router::new().route("/csv", axum::routing::post(csv_handler));
1049 let body = "id,name\n1,foo\n2,bar\n";
1050 let req = Request::builder()
1051 .method("POST")
1052 .uri("/csv")
1053 .header("Content-Type", "text/csv")
1054 .body(axum::body::Body::from(body))
1055 .unwrap();
1056
1057 let resp = app.oneshot(req).await.unwrap();
1058 assert_eq!(resp.status(), 200);
1059
1060 let content_type = resp.headers().get("Content-Type").unwrap();
1061 assert_eq!(content_type, "text/csv; charset=utf-8");
1062
1063 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1064 .await
1065 .unwrap();
1066
1067 let text = String::from_utf8(bytes.to_vec()).unwrap();
1068 assert_eq!(text, "id,name\n1,foo\n2,bar\n");
1069 }
1070
1071 #[cfg(feature = "csv")]
1072 #[tokio::test]
1073 async fn csv_extractor_invalid_csv() {
1074 use axum::response::IntoResponse;
1075 use tower::ServiceExt;
1076
1077 async fn csv_handler(Csv(payload): Csv<Vec<TestCsvRecord>>) -> impl IntoResponse {
1078 Csv(payload)
1079 }
1080
1081 let app = axum::Router::new().route("/csv", axum::routing::post(csv_handler));
1082 let body = "id\n1\n";
1084 let req = Request::builder()
1085 .method("POST")
1086 .uri("/csv")
1087 .header("Content-Type", "text/csv")
1088 .body(axum::body::Body::from(body))
1089 .unwrap();
1090
1091 let resp = app.oneshot(req).await.unwrap();
1092 assert_eq!(resp.status(), 400);
1094 }
1095
1096 #[tokio::test]
1097 async fn test_multipart_field_bytes_limited_success() {
1098 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nhello\r\n--boundary--\r\n";
1099 let req = Request::builder()
1100 .header("content-type", "multipart/form-data; boundary=boundary")
1101 .body(axum::body::Body::from(body))
1102 .unwrap();
1103
1104 let mut multipart = axum::extract::Multipart::from_request(req, &())
1105 .await
1106 .unwrap();
1107 let field = multipart.next_field().await.unwrap().unwrap();
1108
1109 let wrapper = MultipartField::new(field, 100);
1110
1111 let bytes = wrapper.bytes_limited().await.unwrap();
1112 assert_eq!(bytes, b"hello");
1113 }
1114
1115 #[tokio::test]
1116 async fn test_multipart_field_bytes_limited_too_large() {
1117 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nhello world\r\n--boundary--\r\n";
1118 let req = Request::builder()
1119 .header("content-type", "multipart/form-data; boundary=boundary")
1120 .body(axum::body::Body::from(body))
1121 .unwrap();
1122
1123 let mut multipart = axum::extract::Multipart::from_request(req, &())
1124 .await
1125 .unwrap();
1126 let field = multipart.next_field().await.unwrap().unwrap();
1127
1128 let wrapper = MultipartField::new(field, 5);
1129
1130 let err = wrapper.bytes_limited().await.unwrap_err();
1131 assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
1132 }
1133
1134 #[tokio::test]
1135 async fn test_multipart_field_save_to_success() {
1136 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nfile content\r\n--boundary--\r\n";
1137 let req = Request::builder()
1138 .header("content-type", "multipart/form-data; boundary=boundary")
1139 .body(axum::body::Body::from(body))
1140 .unwrap();
1141
1142 let mut multipart = axum::extract::Multipart::from_request(req, &())
1143 .await
1144 .unwrap();
1145 let field = multipart.next_field().await.unwrap().unwrap();
1146
1147 let wrapper = MultipartField::new(field, 100);
1148
1149 let dir = tempfile::tempdir().unwrap();
1150 let file_path = dir.path().join("out.txt");
1151
1152 let written = wrapper.save_to(&file_path).await.unwrap();
1153 assert_eq!(written, 12);
1154
1155 let content = std::fs::read_to_string(&file_path).unwrap();
1156 assert_eq!(content, "file content");
1157 }
1158
1159 #[tokio::test]
1160 async fn test_multipart_field_save_to_too_large() {
1161 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nfile content\r\n--boundary--\r\n";
1162 let req = Request::builder()
1163 .header("content-type", "multipart/form-data; boundary=boundary")
1164 .body(axum::body::Body::from(body))
1165 .unwrap();
1166
1167 let mut multipart = axum::extract::Multipart::from_request(req, &())
1168 .await
1169 .unwrap();
1170 let field = multipart.next_field().await.unwrap().unwrap();
1171
1172 let wrapper = MultipartField::new(field, 4);
1173
1174 let dir = tempfile::tempdir().unwrap();
1175 let file_path = dir.path().join("out_large.txt");
1176
1177 let err = wrapper.save_to(&file_path).await.unwrap_err();
1178 assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
1179
1180 assert!(!file_path.exists());
1181 }
1182
1183 #[cfg(feature = "storage")]
1184 #[tokio::test]
1185 async fn test_multipart_field_save_to_blob_store_success() {
1186 use crate::storage::{BlobStore, LocalBlobStore, local::SigningKey};
1187 use std::time::Duration;
1188
1189 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nblob content\r\n--boundary--\r\n";
1190 let req = Request::builder()
1191 .header("content-type", "multipart/form-data; boundary=boundary")
1192 .body(axum::body::Body::from(body))
1193 .unwrap();
1194
1195 let mut multipart = axum::extract::Multipart::from_request(req, &())
1196 .await
1197 .unwrap();
1198 let field = multipart.next_field().await.unwrap().unwrap();
1199
1200 let wrapper = MultipartField::new(field, 100);
1201
1202 let root = tempfile::tempdir().unwrap();
1203 let store = LocalBlobStore::new(
1204 "local",
1205 root.path(),
1206 "/blobs",
1207 Duration::from_secs(3600),
1208 SigningKey::random(),
1209 vec![],
1210 )
1211 .unwrap();
1212
1213 let blob = wrapper.save_to_blob_store(&store, "myblob").await.unwrap();
1214 assert_eq!(blob.key, "myblob");
1215 assert_eq!(blob.content_type, "text/plain");
1216
1217 let bytes = store.get("myblob").await.unwrap();
1218 assert_eq!(&bytes[..], b"blob content");
1219 }
1220
1221 #[cfg(feature = "storage")]
1222 #[tokio::test]
1223 async fn test_multipart_field_save_to_blob_store_too_large() {
1224 use crate::storage::{BlobStore, LocalBlobStore, local::SigningKey};
1225 use std::time::Duration;
1226
1227 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nblob content\r\n--boundary--\r\n";
1228 let req = Request::builder()
1229 .header("content-type", "multipart/form-data; boundary=boundary")
1230 .body(axum::body::Body::from(body))
1231 .unwrap();
1232
1233 let mut multipart = axum::extract::Multipart::from_request(req, &())
1234 .await
1235 .unwrap();
1236 let field = multipart.next_field().await.unwrap().unwrap();
1237
1238 let wrapper = MultipartField::new(field, 4); let root = tempfile::tempdir().unwrap();
1241 let store = LocalBlobStore::new(
1242 "local",
1243 root.path(),
1244 "/blobs",
1245 Duration::from_secs(3600),
1246 SigningKey::random(),
1247 vec![],
1248 )
1249 .unwrap();
1250
1251 let err = wrapper
1252 .save_to_blob_store(&store, "myblob")
1253 .await
1254 .unwrap_err();
1255 assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
1256
1257 let get_err = store.get("myblob").await.unwrap_err();
1259 assert_eq!(get_err.status(), http::StatusCode::NOT_FOUND);
1260 }
1261
1262 #[tokio::test]
1263 async fn test_multipart_field_metadata() {
1264 let body = "--boundary\r\nContent-Disposition: form-data; name=\"custom_name\"; filename=\"custom_file.png\"\r\nContent-Type: image/png\r\n\r\npng\r\n--boundary--\r\n";
1265 let req = Request::builder()
1266 .header("content-type", "multipart/form-data; boundary=boundary")
1267 .body(axum::body::Body::from(body))
1268 .unwrap();
1269
1270 let mut multipart = axum::extract::Multipart::from_request(req, &())
1271 .await
1272 .unwrap();
1273 let field = multipart.next_field().await.unwrap().unwrap();
1274
1275 let wrapper = MultipartField::new(field, 100);
1276
1277 assert_eq!(wrapper.name(), Some("custom_name"));
1278 assert_eq!(wrapper.file_name(), Some("custom_file.png"));
1279 assert_eq!(wrapper.content_type(), Some("image/png"));
1280
1281 let tighter = wrapper.with_max_bytes(50);
1282 assert_eq!(tighter.max_file_size_bytes, 50);
1283
1284 let not_tighter = tighter.with_max_bytes(200);
1285 assert_eq!(not_tighter.max_file_size_bytes, 50); }
1287
1288 #[test]
1289 fn sniff_content_type_recognizes_png() {
1290 let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
1291 assert_eq!(sniff_content_type(&png), Some("image/png"));
1292 }
1293
1294 #[test]
1295 fn sniff_content_type_unknown_is_none() {
1296 assert_eq!(sniff_content_type(&[0x01, 0x02]), None);
1297 assert_eq!(sniff_content_type(&[]), None);
1298 }
1299
1300 #[test]
1301 fn content_type_essence_strips_parameters() {
1302 assert_eq!(
1303 content_type_essence("image/png; charset=binary"),
1304 "image/png"
1305 );
1306 assert_eq!(content_type_essence(" text/csv "), "text/csv");
1307 assert_eq!(content_type_essence("application/json"), "application/json");
1308 assert_eq!(content_type_essence(""), "");
1309 }
1310
1311 #[test]
1312 fn is_signatureless_text_type_is_case_insensitive() {
1313 assert!(is_signatureless_text_type("text/csv"));
1316 assert!(is_signatureless_text_type("Text/Csv"));
1317 assert!(is_signatureless_text_type("TEXT/PLAIN"));
1318 assert!(is_signatureless_text_type("application/json"));
1319 assert!(is_signatureless_text_type("Application/JSON"));
1320 assert!(is_signatureless_text_type("application/csv"));
1321 assert!(!is_signatureless_text_type("image/png"));
1324 assert!(!is_signatureless_text_type("Image/PNG"));
1325 assert!(!is_signatureless_text_type("application/octet-stream"));
1326 assert!(!is_signatureless_text_type("application/pdf"));
1327 }
1328
1329 #[test]
1330 fn prefix_looks_like_markup_detects_leading_angle_bracket() {
1331 assert!(prefix_looks_like_markup(b"<!DOCTYPE html>"));
1332 assert!(prefix_looks_like_markup(b"<svg onload=alert(1)>"));
1333 assert!(prefix_looks_like_markup(b"<?xml version=\"1.0\"?>"));
1334 assert!(prefix_looks_like_markup(b" \n\t<html>"));
1336 assert!(prefix_looks_like_markup(&[0xEF, 0xBB, 0xBF, b'<', b'a']));
1337 assert!(!prefix_looks_like_markup(b"a,b\n1,2"));
1339 assert!(!prefix_looks_like_markup(br#"{"a":1}"#));
1340 assert!(!prefix_looks_like_markup(&[0x01, 0x02]));
1341 assert!(!prefix_looks_like_markup(b""));
1342 }
1343
1344 #[tokio::test]
1345 async fn next_field_exposes_sniffed_content_type_for_genuine_png() {
1346 let mut body: Vec<u8> = Vec::new();
1349 body.extend_from_slice(
1350 b"--boundary\r\nContent-Disposition: form-data; name=\"file\"; \
1351 filename=\"real.png\"\r\nContent-Type: application/octet-stream\r\n\r\n",
1352 );
1353 body.extend_from_slice(&[
1354 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
1355 0x44, 0x52,
1356 ]);
1357 body.extend_from_slice(b"\r\n--boundary--\r\n");
1358
1359 let req = Request::builder()
1360 .header("content-type", "multipart/form-data; boundary=boundary")
1361 .body(axum::body::Body::from(body))
1362 .unwrap();
1363
1364 let inner = axum::extract::Multipart::from_request(req, &())
1365 .await
1366 .unwrap();
1367 let mut multipart = Multipart {
1368 inner,
1369 config: crate::security::config::UploadConfig {
1370 allowed_mime_types: vec!["image/png".to_owned()],
1371 ..crate::security::config::UploadConfig::default()
1372 },
1373 };
1374
1375 let field = multipart.next_field().await.unwrap().unwrap();
1376 assert_eq!(field.sniffed_content_type(), Some("image/png"));
1377 let bytes = field.bytes_limited().await.unwrap();
1379 assert_eq!(bytes.len(), 16);
1380 }
1381}
1382
1383#[derive(Debug, Clone, PartialEq, Eq)]
1404pub struct CurrentPath(pub String);
1405
1406impl CurrentPath {
1407 #[must_use]
1409 pub fn as_str(&self) -> &str {
1410 &self.0
1411 }
1412}
1413
1414impl<S> FromRequestParts<S> for CurrentPath
1415where
1416 S: Send + Sync,
1417{
1418 type Rejection = std::convert::Infallible;
1419
1420 async fn from_request_parts(
1421 parts: &mut axum::http::request::Parts,
1422 state: &S,
1423 ) -> Result<Self, Self::Rejection> {
1424 let axum::extract::OriginalUri(uri) =
1430 axum::extract::OriginalUri::from_request_parts(parts, state)
1431 .await
1432 .unwrap();
1433 Ok(Self(uri.path().to_owned()))
1434 }
1435}
1436
1437use crate::security::trusted_proxies::ResolvedClientIdentity;
1440
1441pub struct ClientAddr(pub std::net::IpAddr);
1460
1461impl ClientAddr {
1462 #[must_use]
1464 pub const fn ip(&self) -> std::net::IpAddr {
1465 self.0
1466 }
1467}
1468
1469impl<S> FromRequestParts<S> for ClientAddr
1470where
1471 S: Send + Sync,
1472{
1473 type Rejection = (axum::http::StatusCode, &'static str);
1474
1475 async fn from_request_parts(
1476 parts: &mut axum::http::request::Parts,
1477 _state: &S,
1478 ) -> Result<Self, Self::Rejection> {
1479 parts
1480 .extensions
1481 .get::<ResolvedClientIdentity>()
1482 .and_then(|id| id.addr)
1483 .map(ClientAddr)
1484 .ok_or((
1485 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1486 "ClientAddr not resolved. Is the TrustedProxiesLayer installed?",
1487 ))
1488 }
1489}
1490
1491impl<S> axum::extract::OptionalFromRequestParts<S> for ClientAddr
1492where
1493 S: Send + Sync,
1494{
1495 type Rejection = std::convert::Infallible;
1496
1497 async fn from_request_parts(
1498 parts: &mut axum::http::request::Parts,
1499 _state: &S,
1500 ) -> Result<Option<Self>, Self::Rejection> {
1501 Ok(parts
1502 .extensions
1503 .get::<ResolvedClientIdentity>()
1504 .and_then(|id| id.addr)
1505 .map(ClientAddr))
1506 }
1507}
1508
1509pub struct ClientHost(pub String);
1518
1519impl ClientHost {
1520 #[must_use]
1522 pub fn as_str(&self) -> &str {
1523 &self.0
1524 }
1525}
1526
1527impl<S> FromRequestParts<S> for ClientHost
1528where
1529 S: Send + Sync,
1530{
1531 type Rejection = (axum::http::StatusCode, &'static str);
1532
1533 async fn from_request_parts(
1534 parts: &mut axum::http::request::Parts,
1535 _state: &S,
1536 ) -> Result<Self, Self::Rejection> {
1537 parts
1538 .extensions
1539 .get::<ResolvedClientIdentity>()
1540 .and_then(|id| id.host.clone())
1541 .map(ClientHost)
1542 .ok_or((
1543 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1544 "ClientHost not resolved. Is the TrustedProxiesLayer installed?",
1545 ))
1546 }
1547}
1548
1549impl<S> axum::extract::OptionalFromRequestParts<S> for ClientHost
1550where
1551 S: Send + Sync,
1552{
1553 type Rejection = std::convert::Infallible;
1554
1555 async fn from_request_parts(
1556 parts: &mut axum::http::request::Parts,
1557 _state: &S,
1558 ) -> Result<Option<Self>, Self::Rejection> {
1559 Ok(parts
1560 .extensions
1561 .get::<ResolvedClientIdentity>()
1562 .and_then(|id| id.host.clone())
1563 .map(ClientHost))
1564 }
1565}
1566
1567pub struct ClientScheme(pub String);
1576
1577impl ClientScheme {
1578 #[must_use]
1580 pub fn as_str(&self) -> &str {
1581 &self.0
1582 }
1583
1584 #[must_use]
1586 pub fn is_https(&self) -> bool {
1587 self.0.eq_ignore_ascii_case("https")
1588 }
1589}
1590
1591impl<S> FromRequestParts<S> for ClientScheme
1592where
1593 S: Send + Sync,
1594{
1595 type Rejection = (axum::http::StatusCode, &'static str);
1596
1597 async fn from_request_parts(
1598 parts: &mut axum::http::request::Parts,
1599 _state: &S,
1600 ) -> Result<Self, Self::Rejection> {
1601 parts
1602 .extensions
1603 .get::<ResolvedClientIdentity>()
1604 .map(|id| Self(id.scheme.clone().unwrap_or_else(|| "http".to_owned())))
1605 .ok_or((
1606 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1607 "ClientScheme not resolved. Is the TrustedProxiesLayer installed?",
1608 ))
1609 }
1610}
1611
1612impl<S> axum::extract::OptionalFromRequestParts<S> for ClientScheme
1613where
1614 S: Send + Sync,
1615{
1616 type Rejection = std::convert::Infallible;
1617
1618 async fn from_request_parts(
1619 parts: &mut axum::http::request::Parts,
1620 _state: &S,
1621 ) -> Result<Option<Self>, Self::Rejection> {
1622 Ok(parts
1623 .extensions
1624 .get::<ResolvedClientIdentity>()
1625 .map(|id| Self(id.scheme.clone().unwrap_or_else(|| "http".to_owned()))))
1626 }
1627}
1628
1629#[cfg(test)]
1630mod trusted_proxy_extractor_tests {
1631 use super::*;
1632 use axum::Router;
1633 use axum::body::Body;
1634 use axum::routing::get;
1635 use tower::ServiceExt;
1636
1637 fn make_identity(addr: &str, host: &str, scheme: &str) -> ResolvedClientIdentity {
1638 ResolvedClientIdentity {
1639 addr: Some(addr.parse().unwrap()),
1640 host: Some(host.to_owned()),
1641 scheme: Some(scheme.to_owned()),
1642 }
1643 }
1644
1645 #[tokio::test]
1646 async fn client_addr_extractor_reads_from_extension() {
1647 async fn handler(ClientAddr(ip): ClientAddr) -> String {
1648 ip.to_string()
1649 }
1650
1651 let app = Router::new().route("/", get(handler));
1652
1653 let mut req = axum::http::Request::builder()
1654 .uri("/")
1655 .body(Body::empty())
1656 .unwrap();
1657 req.extensions_mut()
1658 .insert(make_identity("192.0.2.1", "app.example", "https"));
1659
1660 let resp = app.oneshot(req).await.unwrap();
1661 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1662 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1663 assert_eq!(&body[..], b"192.0.2.1");
1664 }
1665
1666 #[tokio::test]
1667 async fn client_host_extractor_reads_from_extension() {
1668 async fn handler(ClientHost(host): ClientHost) -> String {
1669 host
1670 }
1671
1672 let app = Router::new().route("/", get(handler));
1673
1674 let mut req = axum::http::Request::builder()
1675 .uri("/")
1676 .body(Body::empty())
1677 .unwrap();
1678 req.extensions_mut()
1679 .insert(make_identity("192.0.2.1", "app.example", "https"));
1680
1681 let resp = app.oneshot(req).await.unwrap();
1682 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1683 assert_eq!(&body[..], b"app.example");
1684 }
1685
1686 #[tokio::test]
1687 async fn client_scheme_extractor_reads_from_extension() {
1688 async fn handler(ClientScheme(scheme): ClientScheme) -> String {
1689 scheme
1690 }
1691
1692 let app = Router::new().route("/", get(handler));
1693
1694 let mut req = axum::http::Request::builder()
1695 .uri("/")
1696 .body(Body::empty())
1697 .unwrap();
1698 req.extensions_mut()
1699 .insert(make_identity("192.0.2.1", "app.example", "https"));
1700
1701 let resp = app.oneshot(req).await.unwrap();
1702 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1703 assert_eq!(&body[..], b"https");
1704 }
1705
1706 #[tokio::test]
1707 async fn client_addr_missing_returns_500() {
1708 async fn handler(_: ClientAddr) -> &'static str {
1709 "ok"
1710 }
1711
1712 let app = Router::new().route("/", get(handler));
1713 let req = axum::http::Request::builder()
1714 .uri("/")
1715 .body(Body::empty())
1716 .unwrap();
1717 let resp = app.oneshot(req).await.unwrap();
1718 assert_eq!(resp.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
1719 }
1720
1721 #[tokio::test]
1722 async fn optional_client_addr_returns_none_when_missing() {
1723 async fn handler(addr: Option<ClientAddr>) -> String {
1724 if addr.is_some() {
1725 "some".to_owned()
1726 } else {
1727 "none".to_owned()
1728 }
1729 }
1730
1731 let app = Router::new().route("/", get(handler));
1732 let req = axum::http::Request::builder()
1733 .uri("/")
1734 .body(Body::empty())
1735 .unwrap();
1736 let resp = app.oneshot(req).await.unwrap();
1737 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1738 assert_eq!(&body[..], b"none");
1739 }
1740
1741 #[tokio::test]
1742 async fn current_path_extracts_uri_path() {
1743 async fn handler(CurrentPath(path): CurrentPath) -> String {
1744 path
1745 }
1746
1747 let app = Router::new().route("/admin/posts", get(handler));
1748 let req = axum::http::Request::builder()
1749 .uri("/admin/posts")
1750 .body(Body::empty())
1751 .unwrap();
1752 let resp = app.oneshot(req).await.unwrap();
1753 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1754 assert_eq!(&body[..], b"/admin/posts");
1755 }
1756
1757 #[tokio::test]
1758 async fn current_path_strips_query_string() {
1759 async fn handler(CurrentPath(path): CurrentPath) -> String {
1760 path
1761 }
1762
1763 let app = Router::new().route("/admin/posts", get(handler));
1764 let req = axum::http::Request::builder()
1765 .uri("/admin/posts?page=2")
1766 .body(Body::empty())
1767 .unwrap();
1768 let resp = app.oneshot(req).await.unwrap();
1769 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1770 assert_eq!(&body[..], b"/admin/posts");
1771 }
1772
1773 #[tokio::test]
1774 async fn current_path_preserves_prefix_under_nested_router() {
1775 async fn handler(CurrentPath(path): CurrentPath) -> String {
1782 path
1783 }
1784
1785 let inner = Router::new().route("/posts", get(handler));
1786 let app = Router::new().nest("/admin", inner);
1787 let req = axum::http::Request::builder()
1788 .uri("/admin/posts")
1789 .body(Body::empty())
1790 .unwrap();
1791 let resp = app.oneshot(req).await.unwrap();
1792 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1793 assert_eq!(&body[..], b"/admin/posts");
1794 }
1795}
1796
1797#[derive(Debug, Clone)]
1815pub struct ShutdownToken(tokio_util::sync::CancellationToken);
1816
1817impl ShutdownToken {
1818 #[must_use]
1820 pub fn is_cancelled(&self) -> bool {
1821 self.0.is_cancelled()
1822 }
1823
1824 pub async fn cancelled(&self) {
1826 self.0.cancelled().await;
1827 }
1828
1829 #[must_use]
1831 pub fn into_inner(self) -> tokio_util::sync::CancellationToken {
1832 self.0
1833 }
1834}
1835
1836impl FromRequestParts<crate::AppState> for ShutdownToken {
1837 type Rejection = std::convert::Infallible;
1838
1839 async fn from_request_parts(
1840 _parts: &mut axum::http::request::Parts,
1841 state: &crate::AppState,
1842 ) -> Result<Self, Self::Rejection> {
1843 Ok(Self(state.shutdown_token()))
1844 }
1845}