Skip to main content

autumn_web/
extract.rs

1//! Autumn's request extractors.
2//!
3//! See the [extractors guide](https://github.com/autumn-foundation/autumn/blob/trunk/docs/guide/extractors.md)
4//! for the full catalog, the ordering rules, and how to write your own.
5//!
6//! Most are thin wrappers over the Axum extractor of the same name, provided so
7//! users don't need `axum` as a direct dependency and so parse failures use
8//! Autumn's Problem Details error contract. [`Query`] is the exception: it
9//! decodes through [`crate::query_string`], which accepts sequences and nested
10//! structures the flat `serde_urlencoded` form cannot express.
11//!
12//! | Extractor | Purpose |
13//! |-----------|---------|
14//! | [`Form`] | Deserialize `application/x-www-form-urlencoded` request bodies |
15//! | [`Json`] | Deserialize/serialize JSON request and response bodies |
16//! | [`Path`] | Extract path parameters (e.g., `/users/{id}`) |
17//! | [`Query`] | Deserialize URL query strings (e.g., `?page=2&limit=10`) |
18//!
19//! [`Json`] serves double duty -- it is both an extractor (parses JSON
20//! request bodies) and a response type (serializes to JSON with
21//! `Content-Type: application/json`).
22//!
23//! For the full set of Axum extractors, use
24//! `autumn_web::reexports::axum::extract`.
25
26// autumn-panic-gate: request-path module — production code path must be panic-free.
27// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
28// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
29#![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/// Deserialize `application/x-www-form-urlencoded` request bodies.
66///
67/// Wraps [`axum::extract::Form`] so parser failures use Autumn's
68/// Problem Details error contract.
69#[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/// Deserialize and serialize JSON request/response bodies.
90///
91/// Wraps [`axum::extract::Json`] so JSON parse failures use Autumn's
92/// Problem Details error contract while successful responses still serialize
93/// exactly like Axum's `Json<T>`.
94#[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
114/// The one gated sink of the first compile-time data-classification slice
115/// (issue #1654).
116///
117/// [`JsonSink`](crate::classify::JsonSink) is blanket-implemented for every
118/// `Serialize` type, so an ordinary handler never notices the bound. What it
119/// excludes is exactly the set autumn withholds `Serialize` from: a `#[model]`
120/// carrying a `#[classified]` column. Releasing such a column at a declared
121/// declassification boundary yields a plain value again, and the released view
122/// serializes here like anything else.
123///
124/// See `docs/guide/data-classification.md`.
125impl<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/// CSV request body extractor and response type.
135///
136/// When used as a handler parameter, deserializes the request body as a CSV list of records.
137/// When returned from a handler, serializes the value as CSV with
138/// `Content-Type: text/csv; charset=utf-8`.
139///
140/// Requires the `csv` feature.
141///
142/// # Examples
143///
144/// ```rust,ignore
145/// use autumn_web::prelude::*;
146/// use serde::{Deserialize, Serialize};
147///
148/// #[derive(Deserialize, Serialize)]
149/// struct Item { id: i64, name: String }
150///
151/// #[post("/items/import")]
152/// async fn import(Csv(items): Csv<Vec<Item>>) -> Csv<Vec<Item>> {
153///     Csv(items)
154/// }
155/// ```
156#[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/// Extract typed path parameters from the URL.
229///
230/// Wraps [`axum::extract::Path`] so path parse failures use Autumn's
231/// Problem Details error contract.
232#[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/// Deserialize URL query string parameters.
257///
258/// Query parse failures use Autumn's Problem Details error contract.
259///
260/// # Sequences and nested structures
261///
262/// Decoding goes through [`query_string`](crate::query_string), a **superset**
263/// of the flat `serde_urlencoded` form: a query string of unique scalar keys
264/// decodes exactly as it always did, and on top of that
265///
266/// ```text
267/// tags=a&tags=b               // repeated key   → Vec<String>
268/// tags[]=a&tags[]=b           // append form    → Vec<String>
269/// tags[0]=a&tags[2]=c         // indexed form   → Vec<String> (gaps compacted)
270/// filter[status]=open         // nested object  → struct field
271/// items[0][sku]=A-1           // array of objects
272/// ```
273///
274/// are all accepted — a bracketed dialect whose `items[0][sku]` shape matches
275/// the repeated-row encoding [`nested_form`](crate::nested_form) renders,
276/// generalized to arbitrary objects, sequences and depths. It applies to the
277/// query string only: [`Form<T>`] still decodes request bodies through
278/// `serde_urlencoded`. See [`query_string`](crate::query_string) for the exact
279/// semantics — scalar coercion, duplicate-key rejection, shape conflicts, the
280/// depth cap, and what changed for `HashMap`-typed targets.
281///
282/// This also shapes MCP tool exposure (issue #1972): a `Query<T>` becomes the
283/// tool's `query` object property, and `tools/call` dispatch renders that
284/// object into this same wire format, so a tool advertising a sequence or a
285/// nested object round-trips back into the handler's typed struct.
286#[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/// Multipart form-data extractor with Autumn upload policy integration.
319///
320/// This wraps Axum's multipart extractor and applies framework-level
321/// validation from `security.upload`:
322///
323/// - MIME allow-list checks (`allowed_mime_types`)
324/// - Per-file size caps when consuming field bytes or streaming to disk
325///
326/// Request size limits are enforced per request in this extractor via
327/// `security.upload.max_request_size_bytes`.
328#[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    /// Like the [`FromRequest`] impl, but keep the body limit the route
337    /// already has. `ChangesetForm` uses this (issue #3394), so a route with a
338    /// tighter `DefaultBodyLimit` keeps it. The global layer applies
339    /// `max_request_size_bytes` to every route.
340    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    /// Read the next multipart field, validating MIME type when configured.
359    ///
360    /// # Errors
361    ///
362    /// Returns [`crate::AutumnError`] when multipart parsing fails or the
363    /// field MIME type is not allowed by config.
364    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        // Only file parts carry uploadable content worth validating. Regular
375        // form fields omit a filename entirely and are never sniffed. A file
376        // part always carries a filename (`Some`), so we sniff whenever one is
377        // present AND an allow-list is configured OR strict mismatch-rejection
378        // is enabled — either check needs the actual (magic-byte) content type
379        // rather than the spoofable client header.
380        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        // An optional/empty file input submits a part with `filename=""` and a
385        // 0-byte body. Only that genuinely-empty case is exempt from
386        // enforcement (see below); a `filename=""` part with a NON-empty body
387        // must still be sniffed and enforced, since callers can consume its
388        // bytes by field name. Capture the empty-filename flag now, before the
389        // body is consumed by the prefix-buffering loop, to avoid borrowing
390        // `field` after it has been read. On the sniff path this flag also
391        // drives the `file_name()` normalization: a genuinely-empty optional
392        // input surfaces as `None`, a non-empty-body empty-filename part as a
393        // file.
394        let filename_is_empty = field.file_name().is_some_and(str::is_empty);
395
396        if !needs_sniff {
397            // Default path: no MIME policy, so no sniffing and no enforcement.
398            // Hand the field through with ZERO buffering — exactly as before
399            // #1873 — so a handler can reject or stream it without the whole
400            // part being pulled into memory first. `file_name()` normalization
401            // is scoped to the sniff path (below), where body emptiness is
402            // already observed from the bounded sniff prefix; here there is no
403            // classification happening, so the raw inner filename (`Some("")`
404            // for an empty-filename part) is surfaced unchanged. Default config
405            // was explicitly unaffected by #1873.
406            return Ok(Some(MultipartField::new(
407                field,
408                self.config.max_file_size_bytes,
409            )));
410        }
411
412        // Buffer a bounded leading prefix (a few chunks at most) for sniffing,
413        // preserving streaming: the rest of the field is still pulled lazily by
414        // the consuming methods, which replay this prefix first.
415        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                    // Reject as soon as the buffered prefix exceeds the per-file
425                    // cap, so an oversized leading chunk bails without reading on.
426                    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        // Borrow the declared header rather than allocating: `declared_essence`
435        // only needs a slice, and the borrow of `field` ends before it is moved
436        // into the returned wrapper below.
437        // Compare on the type ESSENCE (media type without parameters), so a
438        // header like `image/png; charset=binary` matches sniffed `image/png`.
439        let declared_essence = field.content_type().map(content_type_essence);
440        let sniffed = sniff_content_type(&prefix);
441
442        // A genuinely-empty optional file input (empty filename AND 0-byte
443        // body) carries no upload: skip enforcement so it passes through as a
444        // non-file/absent field instead of aborting the submit. Every other
445        // part with a filename — including `filename=""` with a non-empty body
446        // — is sniffed and enforced exactly as below, closing the bypass where
447        // a crafted empty filename could smuggle disallowed content.
448        let is_empty_file_input = filename_is_empty && prefix.is_empty();
449        if !is_empty_file_input {
450            // Enforce the allow-list (sniffed → markup-guard → declared
451            // fallback) and, when enabled, strict declared-vs-sniffed matching.
452            // See the helpers below for the exact rules.
453            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/// Sniff the content type of a file from its leading bytes via magic-byte
477/// detection. Returns `None` when the format is unrecognized. `infer` yields a
478/// `&'static str` media type, so no allocation is needed.
479#[cfg(feature = "multipart")]
480fn sniff_content_type(prefix: &[u8]) -> Option<&'static str> {
481    infer::get(prefix).map(|kind| kind.mime_type())
482}
483
484/// The essence of a content-type header — the media type without any
485/// parameters (e.g. `image/png` from `image/png; charset=binary`).
486#[cfg(feature = "multipart")]
487fn content_type_essence(raw: &str) -> &str {
488    raw.split(';').next().unwrap_or("").trim()
489}
490
491/// Whether the leading bytes look like textual markup (`<…`), after skipping a
492/// UTF-8 BOM and any leading ASCII whitespace. Catches HTML (`<!DOCTYPE`,
493/// `<html`, `<script`), SVG (`<svg`), and XML (`<?xml`) that `infer` cannot
494/// recognize by magic bytes, so they can never be admitted via the declared
495/// content-type fallback.
496#[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/// Bound a content-type value before echoing it into an error message, so a
503/// hostile client can't inflate responses with an arbitrarily long header.
504#[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/// Types that legitimately have no magic-byte signature, so a content sniff of
511/// `None` is expected and the declared type may be trusted for the allow-list.
512/// Binary/sniffable types (image/*, application/pdf, …) are deliberately
513/// excluded: a genuine one always sniffs positively, so `None` for such a type
514/// means the declared header is spoofed.
515#[cfg(feature = "multipart")]
516fn is_signatureless_text_type(essence: &str) -> bool {
517    // Media types are case-insensitive, so normalize before comparing.
518    let lower = essence.to_ascii_lowercase();
519    lower.starts_with("text/") || matches!(lower.as_str(), "application/json" | "application/csv")
520}
521
522/// Enforce a MIME allow-list against a file part, using content sniffing as the
523/// primary signal. `infer` only recognizes binary formats by magic bytes; text
524/// formats (text/plain, application/json, text/csv, …) have no signature and
525/// always sniff to `None`. Precedence:
526///
527/// 1. **Sniffed recognized** → must be in the list, else reject. Header ignored.
528/// 2. **Unrecognized + markup** (leading `<…`) → reject unconditionally, so
529///    HTML/SVG/XML can't ride in under a spoofed declared type.
530/// 3. **Unrecognized + not markup** → the declared header is trusted ONLY when
531///    it names a signature-less TEXT type (see [`is_signatureless_text_type`])
532///    that is in the list. A declared binary/sniffable type on unrecognizable
533///    bytes is definitionally a spoof (a real one would have sniffed) → reject.
534#[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/// Strict declared-vs-sniffed matching (`reject_on_content_type_mismatch`).
569/// Comparison is on the declared essence. Omitting the header is itself a
570/// failure — otherwise an attacker could bypass the check by sending no
571/// `Content-Type`.
572#[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/// Number of leading bytes buffered for magic-byte content sniffing.
621///
622/// `infer` only needs a small header prefix to recognize a format, so we read
623/// at most this many bytes (a few chunks) and never buffer the whole upload.
624#[cfg(feature = "multipart")]
625const SNIFF_PREFIX_BYTES: usize = 512;
626
627/// A multipart field wrapper that provides safe streaming helpers.
628#[cfg(feature = "multipart")]
629pub struct MultipartField<'a> {
630    inner: axum::extract::multipart::Field<'a>,
631    max_file_size_bytes: usize,
632    /// Leading bytes already consumed from `inner` during content sniffing.
633    /// Empty when no sniffing occurred. Consuming methods must emit these
634    /// first so the already-read prefix is not lost.
635    prefix: Vec<u8>,
636    /// Sniffed (magic-byte) content type, if the leading bytes were recognized.
637    /// `infer` returns a `&'static str`, so this stores the borrow directly.
638    sniffed_content_type: Option<&'static str>,
639    /// Whether this part is a genuinely-empty optional file input: an empty
640    /// `filename=""` AND a 0-byte body. Set only on the sniff path (an
641    /// allow-list or strict-mismatch policy is configured), where body
642    /// emptiness is already observed from the bounded sniff prefix, and used by
643    /// [`file_name`](Self::file_name) to normalize such a part to `None` while
644    /// leaving an empty-filename part with a non-empty body classified as a
645    /// file (`Some("")`). Always `false` on the default (no-policy) path, which
646    /// streams through without buffering and performs no `file_name()`
647    /// normalization.
648    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    /// Sniffed prefix bytes to emit before draining `inner`. Taken once.
655    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    /// Wrap a raw multipart field with no sniffed prefix (the common path for
665    /// non-file parts or when no content validation is configured).
666    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    /// `true` for an empty optional file input (no file chosen) on the
677    /// MIME-policy path. [`file_name`](Self::file_name) is `None` for it.
678    pub(crate) const fn is_empty_optional_input(&self) -> bool {
679        self.is_empty_optional_input
680    }
681
682    /// Field name from the multipart form.
683    #[must_use]
684    pub fn name(&self) -> Option<&str> {
685        self.inner.name()
686    }
687
688    /// The sniffed (magic-byte) content type of this field, if it was
689    /// validated by content and the format was recognized.
690    ///
691    /// Returns `None` when no sniffing occurred (non-file part, or no
692    /// allow-list / mismatch policy configured) or the content was
693    /// unrecognized. Prefer this over [`content_type`](Self::content_type),
694    /// which returns the spoofable client-declared header.
695    #[must_use]
696    pub const fn sniffed_content_type(&self) -> Option<&str> {
697        self.sniffed_content_type
698    }
699
700    /// Uploaded file name (if this field represents a file).
701    ///
702    /// Normalization applies on the SNIFF PATH ONLY — i.e. when an allow-list
703    /// or strict-mismatch policy is configured — and to exactly ONE case there:
704    /// an empty `filename=""` AND a 0-byte body is returned as `None`, because
705    /// an optional file input left blank submits such a part to represent an
706    /// absent file rather than a real upload (see issue #1873). Handlers that
707    /// distinguish uploads via `field.file_name().is_some()` therefore treat it
708    /// as "no file provided" instead of persisting a zero-byte file.
709    ///
710    /// On the sniff path every other part is returned unchanged, so this getter
711    /// agrees with the file/non-file classification `next_field` uses for
712    /// enforcement:
713    ///
714    /// - empty filename + NON-empty body → `Some("")` (it is a file: it is
715    ///   sniffed and enforced under an allow-list, so it is surfaced as one);
716    /// - non-empty filename → `Some("name")`.
717    ///
718    /// On the DEFAULT path (no MIME policy) no normalization occurs: the field
719    /// streams through unbuffered and the raw inner value is returned verbatim
720    /// (`Some("")` for an empty-filename part). Default config was explicitly
721    /// unaffected by #1873, and no enforcement/classification happens there, so
722    /// there is no inconsistency to resolve.
723    ///
724    /// The sniff-path decision is made in `next_field`, where body emptiness is
725    /// observable from the bounded prefix, and recorded on this field — this
726    /// getter cannot inspect the (lazily read) body itself.
727    #[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    /// Declared MIME type for this field.
737    #[must_use]
738    pub fn content_type(&self) -> Option<&str> {
739        self.inner.content_type()
740    }
741
742    /// Tighten the per-field upload cap below the global
743    /// `security.upload.max_file_size_bytes`.
744    ///
745    /// Routes can use this to enforce stricter caps than the global
746    /// policy. The effective cap is `min(current, max)` — calling this
747    /// with a value larger than the global is a no-op, so a route
748    /// can't accidentally relax the framework-level limit.
749    ///
750    /// Returns `413 Payload Too Large` if subsequent reads exceed the
751    /// tightened cap, just like the unchained form.
752    ///
753    /// # Examples
754    ///
755    /// ```rust,ignore
756    /// // Cap avatar uploads at 2 MiB even if the global cap is higher.
757    /// let blob = field
758    ///     .with_max_bytes(2 * 1024 * 1024)
759    ///     .save_to_blob_store(&*store, &key)
760    ///     .await?;
761    /// ```
762    #[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    /// Read this field fully into memory while enforcing file-size limits.
769    ///
770    /// # Errors
771    ///
772    /// Returns `413 Payload Too Large` if the field exceeds
773    /// `security.upload.max_file_size_bytes`.
774    pub async fn bytes_limited(mut self) -> crate::AutumnResult<Vec<u8>> {
775        // Reuse the sniff prefix as the output buffer: it already holds exactly
776        // the leading bytes in order, so appending the inner stream after it
777        // reproduces the original byte sequence with no extra allocation.
778        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            // Saturating: the very next check rejects anything over the cap,
790            // so a saturated total still fails closed instead of overflowing.
791            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    /// Stream this field into a [`BlobStore`](crate::storage::BlobStore)
801    /// while enforcing file-size limits.
802    ///
803    /// This is the production-ready replacement for [`save_to`](Self::save_to):
804    /// the bytes flow through the configured blob backend (Local for dev,
805    /// S3 for prod) so they survive container restarts and are visible to
806    /// every replica.
807    ///
808    /// # Examples
809    ///
810    /// ```rust,ignore
811    /// use autumn_web::prelude::*;
812    /// use autumn_web::extract::Multipart;
813    /// use autumn_web::storage::BlobStoreState;
814    ///
815    /// #[post("/avatar")]
816    /// async fn upload(state: State<AppState>, mut form: Multipart) -> AutumnResult<String> {
817    ///     let store = state.extension::<BlobStoreState>().expect("storage configured");
818    ///     while let Some(field) = form.next_field().await? {
819    ///         if field.name() == Some("avatar") {
820    ///             let blob = field
821    ///                 .save_to_blob_store(store.store().as_ref(), "avatars/me.png")
822    ///                 .await?;
823    ///             return Ok(blob.key);
824    ///         }
825    ///     }
826    ///     Err(autumn_web::AutumnError::bad_request_msg("missing avatar field"))
827    /// }
828    /// ```
829    ///
830    /// # Errors
831    ///
832    /// Returns an error when the field exceeds
833    /// `security.upload.max_file_size_bytes`, when the multipart body is
834    /// malformed, or when the underlying [`BlobStore`](crate::storage::BlobStore)
835    /// rejects the write.
836    #[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        // Persist the VERIFIED (sniffed) type when available, falling back to
847        // the client-declared header only when no sniffing occurred.
848        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        // Adapt the multipart chunk iterator into the trait's
861        // `ByteStream`, enforcing the per-file size cap as we go so we
862        // never buffer the whole upload in memory and large files flow
863        // straight through to the store's streaming path. Any sniffed prefix
864        // is replayed as the first chunk so it is not lost.
865        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                    // multer/axum already classifies multipart parser
906                    // failures: 400 for malformed bodies, 413 for body
907                    // limit violations, etc. Preserve that — wrapping
908                    // every parser error as `Io` would silently turn
909                    // client errors into 500s on the way out.
910                    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    /// Stream this field to disk while enforcing file-size limits.
925    ///
926    /// # Errors
927    ///
928    /// Returns an error if writing fails or the file exceeds configured
929    /// limits. Partial files are removed on limit violations.
930    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        // Write any sniffed prefix bytes first so they are not lost, counting
943        // them toward the per-file cap.
944        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            // Saturating for the same reason as `bytes_limited`: a saturated
962            // running total is still over the cap, so the file is rejected.
963            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/// Map a multipart parser error to a `BlobStoreError` variant whose
989/// `status()` matches what the parser would have reported as an HTTP
990/// response — so a malformed-body parser failure becomes 400, a body-
991/// limit violation becomes 413, and only true server-side problems
992/// stay as 500. Without this, every parser error would wrap as
993/// `BlobStoreError::Io` and `into_autumn_error` would surface them all
994/// as 500s.
995#[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        // Missing name column
1083        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        // CSV parse errors map to AutumnError::bad_request_msg (400)
1093        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); // "blob content" is 12 bytes
1239
1240        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        // Verify that the blob was not created/persisted
1258        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); // should not relax
1286    }
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        // Media types are case-insensitive, so mixed-case declarations must be
1314        // recognized as signature-less text.
1315        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        // Binary/sniffable types are never treated as signature-less text —
1322        // the P1 fix must be preserved regardless of case.
1323        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        // Leading whitespace and a UTF-8 BOM are skipped.
1335        assert!(prefix_looks_like_markup(b"   \n\t<html>"));
1336        assert!(prefix_looks_like_markup(&[0xEF, 0xBB, 0xBF, b'<', b'a']));
1337        // Genuine non-markup text and binary are not flagged.
1338        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        // Genuine PNG bytes declared as octet-stream: the extractor must
1347        // recognize the real type from magic bytes and expose it.
1348        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        // The prefix bytes consumed during sniffing are not lost.
1378        let bytes = field.bytes_limited().await.unwrap();
1379        assert_eq!(bytes.len(), 16);
1380    }
1381}
1382
1383// ── Current request path ─────────────────────────────────────────────────────
1384
1385/// The current request's URI path (e.g. `"/admin/posts/3/edit"`), without the
1386/// query string.
1387///
1388/// Infallible — always succeeds, even on requests with no matched route.
1389/// Intended for path-aware view helpers such as `nav_link`
1390/// (`autumn_web::widgets::nav_link`, behind the `maud` feature), which need
1391/// to know the current path to decide whether a navigation link is active.
1392///
1393/// # Example
1394///
1395/// ```rust,ignore
1396/// use autumn_web::prelude::*;
1397///
1398/// #[get("/posts")]
1399/// async fn index(CurrentPath(path): CurrentPath) -> Markup {
1400///     nav_link(&path, "/posts", "Posts")
1401/// }
1402/// ```
1403#[derive(Debug, Clone, PartialEq, Eq)]
1404pub struct CurrentPath(pub String);
1405
1406impl CurrentPath {
1407    /// The current request path as a string slice.
1408    #[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        // Use OriginalUri rather than parts.uri directly: axum's Router::nest
1425        // strips the mount prefix from parts.uri inside the nested service,
1426        // but nav_link needs the full browser-visible path to compare against
1427        // normal absolute hrefs. OriginalUri falls back to parts.uri itself
1428        // when the request wasn't dispatched through a nested router.
1429        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
1437// ── Trusted-proxy client-identity extractors ─────────────────────────────────
1438
1439use crate::security::trusted_proxies::ResolvedClientIdentity;
1440
1441/// The resolved client IP address after trusted-proxy evaluation.
1442///
1443/// Populated by the framework's proxy-resolver middleware from the operator's
1444/// `[security.trusted_proxies]` configuration.
1445///
1446/// # Plugin authors
1447///
1448/// > **Never read `X-Forwarded-*` headers directly.  Use this extractor.**
1449///
1450/// This is the only blessed way to obtain the real client IP in handlers and
1451/// middleware.  Direct reads of `X-Forwarded-For` or `X-Real-IP` will be
1452/// rejected by the `grep` CI guard introduced in #812.
1453///
1454/// # Failure
1455///
1456/// Returns `500 Internal Server Error` when the proxy-resolver middleware is
1457/// not installed.  Use `Option<ClientAddr>` for routes where the middleware may
1458/// be absent.
1459pub struct ClientAddr(pub std::net::IpAddr);
1460
1461impl ClientAddr {
1462    /// The resolved client IP.
1463    #[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
1509/// The resolved external host as seen by the client after trusted-proxy evaluation.
1510///
1511/// Returns the value of `X-Forwarded-Host` when the proxy is trusted, otherwise
1512/// falls back to the `Host` header.
1513///
1514/// # Plugin authors
1515///
1516/// > **Never read `X-Forwarded-Host` directly.  Use this extractor.**
1517pub struct ClientHost(pub String);
1518
1519impl ClientHost {
1520    /// The resolved host string.
1521    #[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
1567/// The resolved external scheme (`"http"` or `"https"`) after trusted-proxy evaluation.
1568///
1569/// Returns the leftmost value of `X-Forwarded-Proto` when the proxy is trusted,
1570/// otherwise falls back to the request URI scheme or `"http"`.
1571///
1572/// # Plugin authors
1573///
1574/// > **Never read `X-Forwarded-Proto` directly.  Use this extractor.**
1575pub struct ClientScheme(pub String);
1576
1577impl ClientScheme {
1578    /// The resolved scheme string (`"http"` or `"https"`).
1579    #[must_use]
1580    pub fn as_str(&self) -> &str {
1581        &self.0
1582    }
1583
1584    /// Returns `true` when the resolved scheme is `"https"`.
1585    #[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        // axum's Router::nest strips the mount prefix from `parts.uri` inside
1776        // the nested service, so a request to "/admin/posts" is seen as
1777        // "/posts" there. nav_link's documented use compares CurrentPath
1778        // against normal absolute hrefs like "/admin/posts", so CurrentPath
1779        // must return the browser-visible path (via OriginalUri), not the
1780        // nest-relative one.
1781        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/// The server shutdown signal, for a handler (issue #3058).
1798///
1799/// The token is cancelled when the server stops accepting connections. Use it
1800/// to stop long work, for example a long poll, before the drain window ends.
1801///
1802/// ```rust,no_run
1803/// use autumn_web::extract::ShutdownToken;
1804/// use autumn_web::get;
1805///
1806/// #[get("/poll")]
1807/// async fn poll(shutdown: ShutdownToken) -> &'static str {
1808///     tokio::select! {
1809///         () = shutdown.cancelled() => "stopping",
1810///         () = tokio::time::sleep(std::time::Duration::from_secs(20)) => "no news",
1811///     }
1812/// }
1813/// ```
1814#[derive(Debug, Clone)]
1815pub struct ShutdownToken(tokio_util::sync::CancellationToken);
1816
1817impl ShutdownToken {
1818    /// `true` after the shutdown signal.
1819    #[must_use]
1820    pub fn is_cancelled(&self) -> bool {
1821        self.0.is_cancelled()
1822    }
1823
1824    /// Wait for the shutdown signal.
1825    pub async fn cancelled(&self) {
1826        self.0.cancelled().await;
1827    }
1828
1829    /// The token, to give to other tasks.
1830    #[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}