Skip to main content

autumn_web/
extract.rs

1//! Re-exports of Axum extractors for use in Autumn handlers.
2//!
3//! These are provided so users don't need `axum` as a direct dependency
4//! for the most common extractor types.
5//!
6//! | Extractor | Purpose |
7//! |-----------|---------|
8//! | [`Form`] | Deserialize `application/x-www-form-urlencoded` request bodies |
9//! | [`Json`] | Deserialize/serialize JSON request and response bodies |
10//! | [`Path`] | Extract path parameters (e.g., `/users/{id}`) |
11//! | [`Query`] | Deserialize URL query strings (e.g., `?page=2&limit=10`) |
12//!
13//! [`Json`] serves double duty -- it is both an extractor (parses JSON
14//! request bodies) and a response type (serializes to JSON with
15//! `Content-Type: application/json`).
16//!
17//! For the full set of Axum extractors, use
18//! `autumn_web::reexports::axum::extract`.
19
20// autumn-panic-gate: request-path module — production code path must be panic-free.
21// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
22// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
23#![cfg_attr(
24    not(test),
25    deny(
26        clippy::unwrap_used,
27        clippy::expect_used,
28        clippy::panic,
29        clippy::unreachable,
30        clippy::todo,
31        clippy::unimplemented,
32        clippy::indexing_slicing,
33    )
34)]
35
36use axum::extract::{FromRequest, FromRequestParts};
37use axum::response::{IntoResponse, Response};
38
39macro_rules! impl_extractor_deref {
40    ($extractor:ident) => {
41        impl<T> std::ops::Deref for $extractor<T> {
42            type Target = T;
43
44            fn deref(&self) -> &Self::Target {
45                &self.0
46            }
47        }
48
49        impl<T> std::ops::DerefMut for $extractor<T> {
50            fn deref_mut(&mut self) -> &mut Self::Target {
51                &mut self.0
52            }
53        }
54    };
55}
56
57/// Deserialize `application/x-www-form-urlencoded` request bodies.
58///
59/// Wraps [`axum::extract::Form`] so parser failures use Autumn's
60/// Problem Details error contract.
61#[derive(Debug, Clone, Copy, Default)]
62pub struct Form<T>(pub T);
63
64impl_extractor_deref!(Form);
65
66impl<S, T> FromRequest<S> for Form<T>
67where
68    S: Send + Sync,
69    axum::extract::Form<T>: FromRequest<S, Rejection = axum::extract::rejection::FormRejection>,
70{
71    type Rejection = crate::AutumnError;
72
73    async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
74        axum::extract::Form::from_request(req, state)
75            .await
76            .map(|axum::extract::Form(value)| Self(value))
77            .map_err(|err| rejection_to_error(err.status(), err.body_text()))
78    }
79}
80
81/// Deserialize and serialize JSON request/response bodies.
82///
83/// Wraps [`axum::extract::Json`] so JSON parse failures use Autumn's
84/// Problem Details error contract while successful responses still serialize
85/// exactly like Axum's `Json<T>`.
86#[derive(Debug, Clone, Copy, Default)]
87pub struct Json<T>(pub T);
88
89impl_extractor_deref!(Json);
90
91impl<S, T> FromRequest<S> for Json<T>
92where
93    S: Send + Sync,
94    axum::extract::Json<T>: FromRequest<S, Rejection = axum::extract::rejection::JsonRejection>,
95{
96    type Rejection = crate::AutumnError;
97
98    async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
99        axum::extract::Json::from_request(req, state)
100            .await
101            .map(|axum::extract::Json(value)| Self(value))
102            .map_err(|err| rejection_to_error(err.status(), err.body_text()))
103    }
104}
105
106impl<T> IntoResponse for Json<T>
107where
108    axum::Json<T>: IntoResponse,
109{
110    fn into_response(self) -> Response {
111        axum::Json(self.0).into_response()
112    }
113}
114
115/// Extract typed path parameters from the URL.
116///
117/// Wraps [`axum::extract::Path`] so path parse failures use Autumn's
118/// Problem Details error contract.
119#[derive(Debug, Clone, Copy, Default)]
120pub struct Path<T>(pub T);
121
122impl_extractor_deref!(Path);
123
124impl<S, T> FromRequestParts<S> for Path<T>
125where
126    S: Send + Sync,
127    axum::extract::Path<T>:
128        FromRequestParts<S, Rejection = axum::extract::rejection::PathRejection>,
129{
130    type Rejection = crate::AutumnError;
131
132    async fn from_request_parts(
133        parts: &mut axum::http::request::Parts,
134        state: &S,
135    ) -> Result<Self, Self::Rejection> {
136        axum::extract::Path::from_request_parts(parts, state)
137            .await
138            .map(|axum::extract::Path(value)| Self(value))
139            .map_err(|err| rejection_to_error(err.status(), err.body_text()))
140    }
141}
142
143/// Deserialize URL query string parameters.
144///
145/// Wraps [`axum::extract::Query`] so query parse failures use Autumn's
146/// Problem Details error contract.
147///
148/// # Query strings are flat
149///
150/// Deserialization goes through
151/// [`serde_urlencoded`](https://docs.rs/serde_urlencoded), which is **strictly
152/// flat**. It decodes scalar fields (`?q=foo&page=2`) and a sequence field from
153/// repeated keys (`?tags=a&tags=b`), but it **cannot** deserialize a nested
154/// struct by any encoding — neither bracketed (`key[sub]=`) nor
155/// JSON-in-a-string. If a request needs structured/nested input, take it as a
156/// JSON body ([`Json<T>`](crate::extract::Json)) instead of a query struct.
157///
158/// This also shapes MCP tool exposure (issue #1972): a `Query<T>` becomes the
159/// tool's `query` object property, and dispatch renders it as flat `key=value`
160/// pairs. A `Query<T>` whose schema has a nested object / array-of-object field
161/// therefore cannot round-trip, and Autumn emits a build-time `tracing::warn`
162/// steering that input to a JSON body. See the [MCP guide]'s "Query is flat"
163/// note.
164///
165/// [MCP guide]: https://docs.rs/autumn-web (guide/mcp.md)
166#[derive(Debug, Clone, Copy, Default)]
167pub struct Query<T>(pub T);
168
169impl_extractor_deref!(Query);
170
171impl<S, T> FromRequestParts<S> for Query<T>
172where
173    S: Send + Sync,
174    axum::extract::Query<T>:
175        FromRequestParts<S, Rejection = axum::extract::rejection::QueryRejection>,
176{
177    type Rejection = crate::AutumnError;
178
179    async fn from_request_parts(
180        parts: &mut axum::http::request::Parts,
181        state: &S,
182    ) -> Result<Self, Self::Rejection> {
183        axum::extract::Query::from_request_parts(parts, state)
184            .await
185            .map(|axum::extract::Query(value)| Self(value))
186            .map_err(|err| rejection_to_error(err.status(), err.body_text()))
187    }
188}
189
190fn rejection_to_error(status: http::StatusCode, body_text: String) -> crate::AutumnError {
191    crate::AutumnError::bad_request_msg(body_text).with_status(status)
192}
193
194/// Multipart form-data extractor with Autumn upload policy integration.
195///
196/// This wraps Axum's multipart extractor and applies framework-level
197/// validation from `security.upload`:
198///
199/// - MIME allow-list checks (`allowed_mime_types`)
200/// - Per-file size caps when consuming field bytes or streaming to disk
201///
202/// Request size limits are enforced per request in this extractor via
203/// `security.upload.max_request_size_bytes`.
204#[cfg(feature = "multipart")]
205pub struct Multipart {
206    inner: axum::extract::Multipart,
207    config: crate::security::config::UploadConfig,
208}
209
210#[cfg(feature = "multipart")]
211impl Multipart {
212    /// Read the next multipart field, validating MIME type when configured.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`crate::AutumnError`] when multipart parsing fails or the
217    /// field MIME type is not allowed by config.
218    pub async fn next_field(&mut self) -> crate::AutumnResult<Option<MultipartField<'_>>> {
219        let Some(mut field) = self
220            .inner
221            .next_field()
222            .await
223            .map_err(|err| multipart_error_to_error(&err))?
224        else {
225            return Ok(None);
226        };
227
228        // Only file parts carry uploadable content worth validating. Regular
229        // form fields omit a filename entirely and are never sniffed. A file
230        // part always carries a filename (`Some`), so we sniff whenever one is
231        // present AND an allow-list is configured OR strict mismatch-rejection
232        // is enabled — either check needs the actual (magic-byte) content type
233        // rather than the spoofable client header.
234        let needs_sniff = field.file_name().is_some()
235            && (!self.config.allowed_mime_types.is_empty()
236                || self.config.reject_on_content_type_mismatch);
237
238        // An optional/empty file input submits a part with `filename=""` and a
239        // 0-byte body. Only that genuinely-empty case is exempt from
240        // enforcement (see below); a `filename=""` part with a NON-empty body
241        // must still be sniffed and enforced, since callers can consume its
242        // bytes by field name. Capture the empty-filename flag now, before the
243        // body is consumed by the prefix-buffering loop, to avoid borrowing
244        // `field` after it has been read. On the sniff path this flag also
245        // drives the `file_name()` normalization: a genuinely-empty optional
246        // input surfaces as `None`, a non-empty-body empty-filename part as a
247        // file.
248        let filename_is_empty = field.file_name().is_some_and(str::is_empty);
249
250        if !needs_sniff {
251            // Default path: no MIME policy, so no sniffing and no enforcement.
252            // Hand the field through with ZERO buffering — exactly as before
253            // #1873 — so a handler can reject or stream it without the whole
254            // part being pulled into memory first. `file_name()` normalization
255            // is scoped to the sniff path (below), where body emptiness is
256            // already observed from the bounded sniff prefix; here there is no
257            // classification happening, so the raw inner filename (`Some("")`
258            // for an empty-filename part) is surfaced unchanged. Default config
259            // was explicitly unaffected by #1873.
260            return Ok(Some(MultipartField::new(
261                field,
262                self.config.max_file_size_bytes,
263            )));
264        }
265
266        // Buffer a bounded leading prefix (a few chunks at most) for sniffing,
267        // preserving streaming: the rest of the field is still pulled lazily by
268        // the consuming methods, which replay this prefix first.
269        let mut prefix: Vec<u8> = Vec::with_capacity(SNIFF_PREFIX_BYTES);
270        while prefix.len() < SNIFF_PREFIX_BYTES {
271            match field
272                .chunk()
273                .await
274                .map_err(|err| multipart_error_to_error(&err))?
275            {
276                Some(chunk) => {
277                    prefix.extend_from_slice(&chunk);
278                    // Reject as soon as the buffered prefix exceeds the per-file
279                    // cap, so an oversized leading chunk bails without reading on.
280                    if prefix.len() > self.config.max_file_size_bytes {
281                        return Err(file_too_large_error(self.config.max_file_size_bytes));
282                    }
283                }
284                None => break,
285            }
286        }
287
288        // Borrow the declared header rather than allocating: `declared_essence`
289        // only needs a slice, and the borrow of `field` ends before it is moved
290        // into the returned wrapper below.
291        // Compare on the type ESSENCE (media type without parameters), so a
292        // header like `image/png; charset=binary` matches sniffed `image/png`.
293        let declared_essence = field.content_type().map(content_type_essence);
294        let sniffed = sniff_content_type(&prefix);
295
296        // A genuinely-empty optional file input (empty filename AND 0-byte
297        // body) carries no upload: skip enforcement so it passes through as a
298        // non-file/absent field instead of aborting the submit. Every other
299        // part with a filename — including `filename=""` with a non-empty body
300        // — is sniffed and enforced exactly as below, closing the bypass where
301        // a crafted empty filename could smuggle disallowed content.
302        let is_empty_file_input = filename_is_empty && prefix.is_empty();
303        if !is_empty_file_input {
304            // Enforce the allow-list (sniffed → markup-guard → declared
305            // fallback) and, when enabled, strict declared-vs-sniffed matching.
306            // See the helpers below for the exact rules.
307            if !self.config.allowed_mime_types.is_empty() {
308                enforce_upload_allow_list(
309                    &self.config.allowed_mime_types,
310                    &prefix,
311                    declared_essence,
312                    sniffed,
313                )?;
314            }
315            if self.config.reject_on_content_type_mismatch {
316                enforce_content_type_match(declared_essence, sniffed)?;
317            }
318        }
319
320        Ok(Some(MultipartField {
321            inner: field,
322            max_file_size_bytes: self.config.max_file_size_bytes,
323            prefix,
324            sniffed_content_type: sniffed,
325            is_empty_optional_input: is_empty_file_input,
326        }))
327    }
328}
329
330/// Sniff the content type of a file from its leading bytes via magic-byte
331/// detection. Returns `None` when the format is unrecognized. `infer` yields a
332/// `&'static str` media type, so no allocation is needed.
333#[cfg(feature = "multipart")]
334fn sniff_content_type(prefix: &[u8]) -> Option<&'static str> {
335    infer::get(prefix).map(|kind| kind.mime_type())
336}
337
338/// The essence of a content-type header — the media type without any
339/// parameters (e.g. `image/png` from `image/png; charset=binary`).
340#[cfg(feature = "multipart")]
341fn content_type_essence(raw: &str) -> &str {
342    raw.split(';').next().unwrap_or("").trim()
343}
344
345/// Whether the leading bytes look like textual markup (`<…`), after skipping a
346/// UTF-8 BOM and any leading ASCII whitespace. Catches HTML (`<!DOCTYPE`,
347/// `<html`, `<script`), SVG (`<svg`), and XML (`<?xml`) that `infer` cannot
348/// recognize by magic bytes, so they can never be admitted via the declared
349/// content-type fallback.
350#[cfg(feature = "multipart")]
351fn prefix_looks_like_markup(prefix: &[u8]) -> bool {
352    let bytes = prefix.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(prefix);
353    bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'<')
354}
355
356/// Bound a content-type value before echoing it into an error message, so a
357/// hostile client can't inflate responses with an arbitrarily long header.
358#[cfg(feature = "multipart")]
359fn truncate_for_error(value: &str) -> String {
360    const MAX_CHARS: usize = 128;
361    value.chars().take(MAX_CHARS).collect()
362}
363
364/// Types that legitimately have no magic-byte signature, so a content sniff of
365/// `None` is expected and the declared type may be trusted for the allow-list.
366/// Binary/sniffable types (image/*, application/pdf, …) are deliberately
367/// excluded: a genuine one always sniffs positively, so `None` for such a type
368/// means the declared header is spoofed.
369#[cfg(feature = "multipart")]
370fn is_signatureless_text_type(essence: &str) -> bool {
371    // Media types are case-insensitive, so normalize before comparing.
372    let lower = essence.to_ascii_lowercase();
373    lower.starts_with("text/") || matches!(lower.as_str(), "application/json" | "application/csv")
374}
375
376/// Enforce a MIME allow-list against a file part, using content sniffing as the
377/// primary signal. `infer` only recognizes binary formats by magic bytes; text
378/// formats (text/plain, application/json, text/csv, …) have no signature and
379/// always sniff to `None`. Precedence:
380///
381/// 1. **Sniffed recognized** → must be in the list, else reject. Header ignored.
382/// 2. **Unrecognized + markup** (leading `<…`) → reject unconditionally, so
383///    HTML/SVG/XML can't ride in under a spoofed declared type.
384/// 3. **Unrecognized + not markup** → the declared header is trusted ONLY when
385///    it names a signature-less TEXT type (see [`is_signatureless_text_type`])
386///    that is in the list. A declared binary/sniffable type on unrecognizable
387///    bytes is definitionally a spoof (a real one would have sniffed) → reject.
388#[cfg(feature = "multipart")]
389fn enforce_upload_allow_list(
390    allowed: &[String],
391    prefix: &[u8],
392    declared_essence: Option<&str>,
393    sniffed: Option<&str>,
394) -> crate::AutumnResult<()> {
395    let in_list = |value: &str| {
396        allowed
397            .iter()
398            .any(|entry| entry.eq_ignore_ascii_case(value))
399    };
400    if let Some(sniffed) = sniffed {
401        if !in_list(sniffed) {
402            return Err(crate::AutumnError::bad_request_msg(format!(
403                "upload content type not allowed: sniffed={sniffed}"
404            )));
405        }
406    } else if prefix_looks_like_markup(prefix) {
407        return Err(crate::AutumnError::bad_request_msg(
408            "upload rejected: unrecognized file content looks like markup",
409        ));
410    } else if !matches!(
411        declared_essence,
412        Some(essence) if is_signatureless_text_type(essence) && in_list(essence)
413    ) {
414        return Err(crate::AutumnError::bad_request_msg(format!(
415            "upload content type could not be verified from its content: declared={}",
416            declared_essence.map_or_else(String::new, truncate_for_error)
417        )));
418    }
419    Ok(())
420}
421
422/// Strict declared-vs-sniffed matching (`reject_on_content_type_mismatch`).
423/// Comparison is on the declared essence. Omitting the header is itself a
424/// failure — otherwise an attacker could bypass the check by sending no
425/// `Content-Type`.
426#[cfg(feature = "multipart")]
427fn enforce_content_type_match(
428    declared_essence: Option<&str>,
429    sniffed: Option<&str>,
430) -> crate::AutumnResult<()> {
431    let Some(declared_essence) = declared_essence else {
432        return Err(crate::AutumnError::bad_request_msg(
433            "content-type mismatch check enabled but the upload declared no content type",
434        ));
435    };
436    match sniffed {
437        Some(sniffed) if declared_essence.eq_ignore_ascii_case(sniffed) => Ok(()),
438        Some(sniffed) => Err(crate::AutumnError::bad_request_msg(format!(
439            "declared content type {} does not match sniffed content type {sniffed}",
440            truncate_for_error(declared_essence)
441        ))),
442        None => Err(crate::AutumnError::bad_request_msg(format!(
443            "cannot verify declared content type {}: file content is unrecognized",
444            truncate_for_error(declared_essence)
445        ))),
446    }
447}
448
449#[cfg(feature = "multipart")]
450impl<S> axum::extract::FromRequest<S> for Multipart
451where
452    S: Send + Sync,
453    axum::extract::Multipart:
454        axum::extract::FromRequest<S, Rejection = axum::extract::multipart::MultipartRejection>,
455{
456    type Rejection = crate::AutumnError;
457
458    async fn from_request(
459        mut req: axum::extract::Request,
460        state: &S,
461    ) -> Result<Self, Self::Rejection> {
462        let config = req
463            .extensions()
464            .get::<crate::security::config::UploadConfig>()
465            .cloned()
466            .unwrap_or_default();
467        axum::extract::DefaultBodyLimit::max(config.max_request_size_bytes).apply(&mut req);
468        let inner = axum::extract::Multipart::from_request(req, state)
469            .await
470            .map_err(|err| multipart_rejection_to_error(&err))?;
471        Ok(Self { inner, config })
472    }
473}
474
475/// Number of leading bytes buffered for magic-byte content sniffing.
476///
477/// `infer` only needs a small header prefix to recognize a format, so we read
478/// at most this many bytes (a few chunks) and never buffer the whole upload.
479#[cfg(feature = "multipart")]
480const SNIFF_PREFIX_BYTES: usize = 512;
481
482/// A multipart field wrapper that provides safe streaming helpers.
483#[cfg(feature = "multipart")]
484pub struct MultipartField<'a> {
485    inner: axum::extract::multipart::Field<'a>,
486    max_file_size_bytes: usize,
487    /// Leading bytes already consumed from `inner` during content sniffing.
488    /// Empty when no sniffing occurred. Consuming methods must emit these
489    /// first so the already-read prefix is not lost.
490    prefix: Vec<u8>,
491    /// Sniffed (magic-byte) content type, if the leading bytes were recognized.
492    /// `infer` returns a `&'static str`, so this stores the borrow directly.
493    sniffed_content_type: Option<&'static str>,
494    /// Whether this part is a genuinely-empty optional file input: an empty
495    /// `filename=""` AND a 0-byte body. Set only on the sniff path (an
496    /// allow-list or strict-mismatch policy is configured), where body
497    /// emptiness is already observed from the bounded sniff prefix, and used by
498    /// [`file_name`](Self::file_name) to normalize such a part to `None` while
499    /// leaving an empty-filename part with a non-empty body classified as a
500    /// file (`Some("")`). Always `false` on the default (no-policy) path, which
501    /// streams through without buffering and performs no `file_name()`
502    /// normalization.
503    is_empty_optional_input: bool,
504}
505
506#[cfg(all(feature = "multipart", feature = "storage"))]
507struct MultipartFieldStreamState<'a> {
508    inner: axum::extract::multipart::Field<'a>,
509    /// Sniffed prefix bytes to emit before draining `inner`. Taken once.
510    prefix: Option<bytes::Bytes>,
511    total: usize,
512    max: usize,
513    errored: bool,
514}
515
516#[cfg(feature = "multipart")]
517#[allow(clippy::elidable_lifetime_names)]
518impl<'a> MultipartField<'a> {
519    /// Wrap a raw multipart field with no sniffed prefix (the common path for
520    /// non-file parts or when no content validation is configured).
521    const fn new(inner: axum::extract::multipart::Field<'a>, max_file_size_bytes: usize) -> Self {
522        Self {
523            inner,
524            max_file_size_bytes,
525            prefix: Vec::new(),
526            sniffed_content_type: None,
527            is_empty_optional_input: false,
528        }
529    }
530
531    /// Field name from the multipart form.
532    #[must_use]
533    pub fn name(&self) -> Option<&str> {
534        self.inner.name()
535    }
536
537    /// The sniffed (magic-byte) content type of this field, if it was
538    /// validated by content and the format was recognized.
539    ///
540    /// Returns `None` when no sniffing occurred (non-file part, or no
541    /// allow-list / mismatch policy configured) or the content was
542    /// unrecognized. Prefer this over [`content_type`](Self::content_type),
543    /// which returns the spoofable client-declared header.
544    #[must_use]
545    pub const fn sniffed_content_type(&self) -> Option<&str> {
546        self.sniffed_content_type
547    }
548
549    /// Uploaded file name (if this field represents a file).
550    ///
551    /// Normalization applies on the SNIFF PATH ONLY — i.e. when an allow-list
552    /// or strict-mismatch policy is configured — and to exactly ONE case there:
553    /// an empty `filename=""` AND a 0-byte body is returned as `None`, because
554    /// an optional file input left blank submits such a part to represent an
555    /// absent file rather than a real upload (see issue #1873). Handlers that
556    /// distinguish uploads via `field.file_name().is_some()` therefore treat it
557    /// as "no file provided" instead of persisting a zero-byte file.
558    ///
559    /// On the sniff path every other part is returned unchanged, so this getter
560    /// agrees with the file/non-file classification `next_field` uses for
561    /// enforcement:
562    ///
563    /// - empty filename + NON-empty body → `Some("")` (it is a file: it is
564    ///   sniffed and enforced under an allow-list, so it is surfaced as one);
565    /// - non-empty filename → `Some("name")`.
566    ///
567    /// On the DEFAULT path (no MIME policy) no normalization occurs: the field
568    /// streams through unbuffered and the raw inner value is returned verbatim
569    /// (`Some("")` for an empty-filename part). Default config was explicitly
570    /// unaffected by #1873, and no enforcement/classification happens there, so
571    /// there is no inconsistency to resolve.
572    ///
573    /// The sniff-path decision is made in `next_field`, where body emptiness is
574    /// observable from the bounded prefix, and recorded on this field — this
575    /// getter cannot inspect the (lazily read) body itself.
576    #[must_use]
577    pub fn file_name(&self) -> Option<&str> {
578        if self.is_empty_optional_input {
579            None
580        } else {
581            self.inner.file_name()
582        }
583    }
584
585    /// Declared MIME type for this field.
586    #[must_use]
587    pub fn content_type(&self) -> Option<&str> {
588        self.inner.content_type()
589    }
590
591    /// Tighten the per-field upload cap below the global
592    /// `security.upload.max_file_size_bytes`.
593    ///
594    /// Routes can use this to enforce stricter caps than the global
595    /// policy. The effective cap is `min(current, max)` — calling this
596    /// with a value larger than the global is a no-op, so a route
597    /// can't accidentally relax the framework-level limit.
598    ///
599    /// Returns `413 Payload Too Large` if subsequent reads exceed the
600    /// tightened cap, just like the unchained form.
601    ///
602    /// # Examples
603    ///
604    /// ```rust,ignore
605    /// // Cap avatar uploads at 2 MiB even if the global cap is higher.
606    /// let blob = field
607    ///     .with_max_bytes(2 * 1024 * 1024)
608    ///     .save_to_blob_store(&*store, &key)
609    ///     .await?;
610    /// ```
611    #[must_use]
612    pub fn with_max_bytes(mut self, max: usize) -> Self {
613        self.max_file_size_bytes = self.max_file_size_bytes.min(max);
614        self
615    }
616
617    /// Read this field fully into memory while enforcing file-size limits.
618    ///
619    /// # Errors
620    ///
621    /// Returns `413 Payload Too Large` if the field exceeds
622    /// `security.upload.max_file_size_bytes`.
623    pub async fn bytes_limited(mut self) -> crate::AutumnResult<Vec<u8>> {
624        // Reuse the sniff prefix as the output buffer: it already holds exactly
625        // the leading bytes in order, so appending the inner stream after it
626        // reproduces the original byte sequence with no extra allocation.
627        let mut out = self.prefix;
628        let mut read = out.len();
629        if read > self.max_file_size_bytes {
630            return Err(file_too_large_error(self.max_file_size_bytes));
631        }
632        while let Some(chunk) = self
633            .inner
634            .chunk()
635            .await
636            .map_err(|err| multipart_error_to_error(&err))?
637        {
638            read += chunk.len();
639            if read > self.max_file_size_bytes {
640                return Err(file_too_large_error(self.max_file_size_bytes));
641            }
642            out.extend_from_slice(&chunk);
643        }
644        Ok(out)
645    }
646
647    /// Stream this field into a [`BlobStore`](crate::storage::BlobStore)
648    /// while enforcing file-size limits.
649    ///
650    /// This is the production-ready replacement for [`save_to`](Self::save_to):
651    /// the bytes flow through the configured blob backend (Local for dev,
652    /// S3 for prod) so they survive container restarts and are visible to
653    /// every replica.
654    ///
655    /// # Examples
656    ///
657    /// ```rust,ignore
658    /// use autumn_web::prelude::*;
659    /// use autumn_web::extract::Multipart;
660    /// use autumn_web::storage::BlobStoreState;
661    ///
662    /// #[post("/avatar")]
663    /// async fn upload(state: State<AppState>, mut form: Multipart) -> AutumnResult<String> {
664    ///     let store = state.extension::<BlobStoreState>().expect("storage configured");
665    ///     while let Some(field) = form.next_field().await? {
666    ///         if field.name() == Some("avatar") {
667    ///             let blob = field
668    ///                 .save_to_blob_store(store.store().as_ref(), "avatars/me.png")
669    ///                 .await?;
670    ///             return Ok(blob.key);
671    ///         }
672    ///     }
673    ///     Err(autumn_web::AutumnError::bad_request_msg("missing avatar field"))
674    /// }
675    /// ```
676    ///
677    /// # Errors
678    ///
679    /// Returns an error when the field exceeds
680    /// `security.upload.max_file_size_bytes`, when the multipart body is
681    /// malformed, or when the underlying [`BlobStore`](crate::storage::BlobStore)
682    /// rejects the write.
683    #[cfg(feature = "storage")]
684    pub async fn save_to_blob_store<'b>(
685        self,
686        store: &'b (dyn crate::storage::BlobStore + '_),
687        key: impl Into<String>,
688    ) -> crate::AutumnResult<crate::storage::Blob>
689    where
690        'a: 'b,
691    {
692        let key = key.into();
693        // Persist the VERIFIED (sniffed) type when available, falling back to
694        // the client-declared header only when no sniffing occurred.
695        let content_type = self
696            .sniffed_content_type
697            .map(str::to_owned)
698            .or_else(|| self.inner.content_type().map(str::to_owned))
699            .unwrap_or_else(|| "application/octet-stream".to_owned());
700
701        let prefix = if self.prefix.is_empty() {
702            None
703        } else {
704            Some(bytes::Bytes::from(self.prefix))
705        };
706
707        // Adapt the multipart chunk iterator into the trait's
708        // `ByteStream`, enforcing the per-file size cap as we go so we
709        // never buffer the whole upload in memory and large files flow
710        // straight through to the store's streaming path. Any sniffed prefix
711        // is replayed as the first chunk so it is not lost.
712        let state = MultipartFieldStreamState {
713            inner: self.inner,
714            prefix,
715            total: 0,
716            max: self.max_file_size_bytes,
717            errored: false,
718        };
719
720        let stream = futures::stream::unfold(state, |mut state| async move {
721            if state.errored {
722                return None;
723            }
724            if let Some(prefix) = state.prefix.take() {
725                state.total = state.total.saturating_add(prefix.len());
726                if state.total > state.max {
727                    let err = crate::storage::BlobStoreError::PayloadTooLarge(format!(
728                        "uploaded file exceeds limit of {} bytes",
729                        state.max,
730                    ));
731                    state.errored = true;
732                    return Some((Err(err), state));
733                }
734                return Some((Ok(prefix), state));
735            }
736            match state.inner.chunk().await {
737                Ok(Some(chunk)) => {
738                    state.total = state.total.saturating_add(chunk.len());
739                    if state.total > state.max {
740                        let err = crate::storage::BlobStoreError::PayloadTooLarge(format!(
741                            "uploaded file exceeds limit of {} bytes",
742                            state.max,
743                        ));
744                        state.errored = true;
745                        Some((Err(err), state))
746                    } else {
747                        Some((Ok(chunk), state))
748                    }
749                }
750                Ok(None) => None,
751                Err(err) => {
752                    // multer/axum already classifies multipart parser
753                    // failures: 400 for malformed bodies, 413 for body
754                    // limit violations, etc. Preserve that — wrapping
755                    // every parser error as `Io` would silently turn
756                    // client errors into 500s on the way out.
757                    state.errored = true;
758                    let mapped = blob_error_from_multipart(&err);
759                    Some((Err(mapped), state))
760                }
761            }
762        });
763        let stream: crate::storage::ByteStream<'b> = Box::pin(stream);
764
765        store
766            .put_stream(&key, &content_type, stream)
767            .await
768            .map_err(crate::storage::BlobStoreError::into_autumn_error)
769    }
770
771    /// Stream this field to disk while enforcing file-size limits.
772    ///
773    /// # Errors
774    ///
775    /// Returns an error if writing fails or the file exceeds configured
776    /// limits. Partial files are removed on limit violations.
777    pub async fn save_to<P: AsRef<std::path::Path>>(
778        mut self,
779        path: P,
780    ) -> crate::AutumnResult<usize> {
781        use tokio::io::AsyncWriteExt as _;
782
783        let path = path.as_ref();
784        let mut file = tokio::fs::File::create(path)
785            .await
786            .map_err(crate::AutumnError::internal_server_error)?;
787
788        let mut written = 0usize;
789        // Write any sniffed prefix bytes first so they are not lost, counting
790        // them toward the per-file cap.
791        if !self.prefix.is_empty() {
792            written += self.prefix.len();
793            if written > self.max_file_size_bytes {
794                drop(file);
795                let _ = tokio::fs::remove_file(path).await;
796                return Err(file_too_large_error(self.max_file_size_bytes));
797            }
798            file.write_all(&self.prefix)
799                .await
800                .map_err(crate::AutumnError::internal_server_error)?;
801        }
802        while let Some(chunk) = self
803            .inner
804            .chunk()
805            .await
806            .map_err(|err| multipart_error_to_error(&err))?
807        {
808            written += chunk.len();
809            if written > self.max_file_size_bytes {
810                drop(file);
811                let _ = tokio::fs::remove_file(path).await;
812                return Err(file_too_large_error(self.max_file_size_bytes));
813            }
814            file.write_all(&chunk)
815                .await
816                .map_err(crate::AutumnError::internal_server_error)?;
817        }
818        file.flush()
819            .await
820            .map_err(crate::AutumnError::internal_server_error)?;
821        Ok(written)
822    }
823}
824
825#[cfg(feature = "multipart")]
826fn multipart_rejection_to_error(
827    err: &axum::extract::multipart::MultipartRejection,
828) -> crate::AutumnError {
829    crate::AutumnError::bad_request_msg(err.body_text()).with_status(err.status())
830}
831
832#[cfg(feature = "multipart")]
833/// Map a multipart parser error to a `BlobStoreError` variant whose
834/// `status()` matches what the parser would have reported as an HTTP
835/// response — so a malformed-body parser failure becomes 400, a body-
836/// limit violation becomes 413, and only true server-side problems
837/// stay as 500. Without this, every parser error would wrap as
838/// `BlobStoreError::Io` and `into_autumn_error` would surface them all
839/// as 500s.
840#[cfg(all(feature = "multipart", feature = "storage"))]
841fn blob_error_from_multipart(
842    err: &axum::extract::multipart::MultipartError,
843) -> crate::storage::BlobStoreError {
844    let status = err.status();
845    let body = err.body_text();
846    if status == http::StatusCode::PAYLOAD_TOO_LARGE {
847        crate::storage::BlobStoreError::PayloadTooLarge(body)
848    } else if status.is_client_error() {
849        crate::storage::BlobStoreError::InvalidInput(body)
850    } else {
851        crate::storage::BlobStoreError::Io(body)
852    }
853}
854
855#[cfg(feature = "multipart")]
856fn multipart_error_to_error(err: &axum::extract::multipart::MultipartError) -> crate::AutumnError {
857    crate::AutumnError::bad_request_msg(err.body_text()).with_status(err.status())
858}
859
860#[cfg(feature = "multipart")]
861fn file_too_large_error(max_file_size_bytes: usize) -> crate::AutumnError {
862    crate::AutumnError::bad_request_msg(format!(
863        "uploaded file exceeds limit of {max_file_size_bytes} bytes",
864    ))
865    .with_status(http::StatusCode::PAYLOAD_TOO_LARGE)
866}
867
868pub use axum::extract::State;
869
870#[cfg(all(test, feature = "multipart"))]
871mod tests {
872    use super::*;
873    use axum::extract::FromRequest;
874    use axum::http::Request;
875
876    #[tokio::test]
877    async fn test_multipart_field_bytes_limited_success() {
878        let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nhello\r\n--boundary--\r\n";
879        let req = Request::builder()
880            .header("content-type", "multipart/form-data; boundary=boundary")
881            .body(axum::body::Body::from(body))
882            .unwrap();
883
884        let mut multipart = axum::extract::Multipart::from_request(req, &())
885            .await
886            .unwrap();
887        let field = multipart.next_field().await.unwrap().unwrap();
888
889        let wrapper = MultipartField::new(field, 100);
890
891        let bytes = wrapper.bytes_limited().await.unwrap();
892        assert_eq!(bytes, b"hello");
893    }
894
895    #[tokio::test]
896    async fn test_multipart_field_bytes_limited_too_large() {
897        let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nhello world\r\n--boundary--\r\n";
898        let req = Request::builder()
899            .header("content-type", "multipart/form-data; boundary=boundary")
900            .body(axum::body::Body::from(body))
901            .unwrap();
902
903        let mut multipart = axum::extract::Multipart::from_request(req, &())
904            .await
905            .unwrap();
906        let field = multipart.next_field().await.unwrap().unwrap();
907
908        let wrapper = MultipartField::new(field, 5);
909
910        let err = wrapper.bytes_limited().await.unwrap_err();
911        assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
912    }
913
914    #[tokio::test]
915    async fn test_multipart_field_save_to_success() {
916        let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nfile content\r\n--boundary--\r\n";
917        let req = Request::builder()
918            .header("content-type", "multipart/form-data; boundary=boundary")
919            .body(axum::body::Body::from(body))
920            .unwrap();
921
922        let mut multipart = axum::extract::Multipart::from_request(req, &())
923            .await
924            .unwrap();
925        let field = multipart.next_field().await.unwrap().unwrap();
926
927        let wrapper = MultipartField::new(field, 100);
928
929        let dir = tempfile::tempdir().unwrap();
930        let file_path = dir.path().join("out.txt");
931
932        let written = wrapper.save_to(&file_path).await.unwrap();
933        assert_eq!(written, 12);
934
935        let content = std::fs::read_to_string(&file_path).unwrap();
936        assert_eq!(content, "file content");
937    }
938
939    #[tokio::test]
940    async fn test_multipart_field_save_to_too_large() {
941        let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nfile content\r\n--boundary--\r\n";
942        let req = Request::builder()
943            .header("content-type", "multipart/form-data; boundary=boundary")
944            .body(axum::body::Body::from(body))
945            .unwrap();
946
947        let mut multipart = axum::extract::Multipart::from_request(req, &())
948            .await
949            .unwrap();
950        let field = multipart.next_field().await.unwrap().unwrap();
951
952        let wrapper = MultipartField::new(field, 4);
953
954        let dir = tempfile::tempdir().unwrap();
955        let file_path = dir.path().join("out_large.txt");
956
957        let err = wrapper.save_to(&file_path).await.unwrap_err();
958        assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
959
960        assert!(!file_path.exists());
961    }
962
963    #[cfg(feature = "storage")]
964    #[tokio::test]
965    async fn test_multipart_field_save_to_blob_store_success() {
966        use crate::storage::{BlobStore, LocalBlobStore, local::SigningKey};
967        use std::time::Duration;
968
969        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";
970        let req = Request::builder()
971            .header("content-type", "multipart/form-data; boundary=boundary")
972            .body(axum::body::Body::from(body))
973            .unwrap();
974
975        let mut multipart = axum::extract::Multipart::from_request(req, &())
976            .await
977            .unwrap();
978        let field = multipart.next_field().await.unwrap().unwrap();
979
980        let wrapper = MultipartField::new(field, 100);
981
982        let root = tempfile::tempdir().unwrap();
983        let store = LocalBlobStore::new(
984            "local",
985            root.path(),
986            "/blobs",
987            Duration::from_secs(3600),
988            SigningKey::random(),
989            vec![],
990        )
991        .unwrap();
992
993        let blob = wrapper.save_to_blob_store(&store, "myblob").await.unwrap();
994        assert_eq!(blob.key, "myblob");
995        assert_eq!(blob.content_type, "text/plain");
996
997        let bytes = store.get("myblob").await.unwrap();
998        assert_eq!(&bytes[..], b"blob content");
999    }
1000
1001    #[cfg(feature = "storage")]
1002    #[tokio::test]
1003    async fn test_multipart_field_save_to_blob_store_too_large() {
1004        use crate::storage::{BlobStore, LocalBlobStore, local::SigningKey};
1005        use std::time::Duration;
1006
1007        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";
1008        let req = Request::builder()
1009            .header("content-type", "multipart/form-data; boundary=boundary")
1010            .body(axum::body::Body::from(body))
1011            .unwrap();
1012
1013        let mut multipart = axum::extract::Multipart::from_request(req, &())
1014            .await
1015            .unwrap();
1016        let field = multipart.next_field().await.unwrap().unwrap();
1017
1018        let wrapper = MultipartField::new(field, 4); // "blob content" is 12 bytes
1019
1020        let root = tempfile::tempdir().unwrap();
1021        let store = LocalBlobStore::new(
1022            "local",
1023            root.path(),
1024            "/blobs",
1025            Duration::from_secs(3600),
1026            SigningKey::random(),
1027            vec![],
1028        )
1029        .unwrap();
1030
1031        let err = wrapper
1032            .save_to_blob_store(&store, "myblob")
1033            .await
1034            .unwrap_err();
1035        assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
1036
1037        // Verify that the blob was not created/persisted
1038        let get_err = store.get("myblob").await.unwrap_err();
1039        assert_eq!(get_err.status(), http::StatusCode::NOT_FOUND);
1040    }
1041
1042    #[tokio::test]
1043    async fn test_multipart_field_metadata() {
1044        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";
1045        let req = Request::builder()
1046            .header("content-type", "multipart/form-data; boundary=boundary")
1047            .body(axum::body::Body::from(body))
1048            .unwrap();
1049
1050        let mut multipart = axum::extract::Multipart::from_request(req, &())
1051            .await
1052            .unwrap();
1053        let field = multipart.next_field().await.unwrap().unwrap();
1054
1055        let wrapper = MultipartField::new(field, 100);
1056
1057        assert_eq!(wrapper.name(), Some("custom_name"));
1058        assert_eq!(wrapper.file_name(), Some("custom_file.png"));
1059        assert_eq!(wrapper.content_type(), Some("image/png"));
1060
1061        let tighter = wrapper.with_max_bytes(50);
1062        assert_eq!(tighter.max_file_size_bytes, 50);
1063
1064        let not_tighter = tighter.with_max_bytes(200);
1065        assert_eq!(not_tighter.max_file_size_bytes, 50); // should not relax
1066    }
1067
1068    #[test]
1069    fn sniff_content_type_recognizes_png() {
1070        let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
1071        assert_eq!(sniff_content_type(&png), Some("image/png"));
1072    }
1073
1074    #[test]
1075    fn sniff_content_type_unknown_is_none() {
1076        assert_eq!(sniff_content_type(&[0x01, 0x02]), None);
1077        assert_eq!(sniff_content_type(&[]), None);
1078    }
1079
1080    #[test]
1081    fn content_type_essence_strips_parameters() {
1082        assert_eq!(
1083            content_type_essence("image/png; charset=binary"),
1084            "image/png"
1085        );
1086        assert_eq!(content_type_essence("  text/csv  "), "text/csv");
1087        assert_eq!(content_type_essence("application/json"), "application/json");
1088        assert_eq!(content_type_essence(""), "");
1089    }
1090
1091    #[test]
1092    fn is_signatureless_text_type_is_case_insensitive() {
1093        // Media types are case-insensitive, so mixed-case declarations must be
1094        // recognized as signature-less text.
1095        assert!(is_signatureless_text_type("text/csv"));
1096        assert!(is_signatureless_text_type("Text/Csv"));
1097        assert!(is_signatureless_text_type("TEXT/PLAIN"));
1098        assert!(is_signatureless_text_type("application/json"));
1099        assert!(is_signatureless_text_type("Application/JSON"));
1100        assert!(is_signatureless_text_type("application/csv"));
1101        // Binary/sniffable types are never treated as signature-less text —
1102        // the P1 fix must be preserved regardless of case.
1103        assert!(!is_signatureless_text_type("image/png"));
1104        assert!(!is_signatureless_text_type("Image/PNG"));
1105        assert!(!is_signatureless_text_type("application/octet-stream"));
1106        assert!(!is_signatureless_text_type("application/pdf"));
1107    }
1108
1109    #[test]
1110    fn prefix_looks_like_markup_detects_leading_angle_bracket() {
1111        assert!(prefix_looks_like_markup(b"<!DOCTYPE html>"));
1112        assert!(prefix_looks_like_markup(b"<svg onload=alert(1)>"));
1113        assert!(prefix_looks_like_markup(b"<?xml version=\"1.0\"?>"));
1114        // Leading whitespace and a UTF-8 BOM are skipped.
1115        assert!(prefix_looks_like_markup(b"   \n\t<html>"));
1116        assert!(prefix_looks_like_markup(&[0xEF, 0xBB, 0xBF, b'<', b'a']));
1117        // Genuine non-markup text and binary are not flagged.
1118        assert!(!prefix_looks_like_markup(b"a,b\n1,2"));
1119        assert!(!prefix_looks_like_markup(br#"{"a":1}"#));
1120        assert!(!prefix_looks_like_markup(&[0x01, 0x02]));
1121        assert!(!prefix_looks_like_markup(b""));
1122    }
1123
1124    #[tokio::test]
1125    async fn next_field_exposes_sniffed_content_type_for_genuine_png() {
1126        // Genuine PNG bytes declared as octet-stream: the extractor must
1127        // recognize the real type from magic bytes and expose it.
1128        let mut body: Vec<u8> = Vec::new();
1129        body.extend_from_slice(
1130            b"--boundary\r\nContent-Disposition: form-data; name=\"file\"; \
1131              filename=\"real.png\"\r\nContent-Type: application/octet-stream\r\n\r\n",
1132        );
1133        body.extend_from_slice(&[
1134            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
1135            0x44, 0x52,
1136        ]);
1137        body.extend_from_slice(b"\r\n--boundary--\r\n");
1138
1139        let req = Request::builder()
1140            .header("content-type", "multipart/form-data; boundary=boundary")
1141            .body(axum::body::Body::from(body))
1142            .unwrap();
1143
1144        let inner = axum::extract::Multipart::from_request(req, &())
1145            .await
1146            .unwrap();
1147        let mut multipart = Multipart {
1148            inner,
1149            config: crate::security::config::UploadConfig {
1150                allowed_mime_types: vec!["image/png".to_owned()],
1151                ..crate::security::config::UploadConfig::default()
1152            },
1153        };
1154
1155        let field = multipart.next_field().await.unwrap().unwrap();
1156        assert_eq!(field.sniffed_content_type(), Some("image/png"));
1157        // The prefix bytes consumed during sniffing are not lost.
1158        let bytes = field.bytes_limited().await.unwrap();
1159        assert_eq!(bytes.len(), 16);
1160    }
1161}
1162
1163// ── Current request path ─────────────────────────────────────────────────────
1164
1165/// The current request's URI path (e.g. `"/admin/posts/3/edit"`), without the
1166/// query string.
1167///
1168/// Infallible — always succeeds, even on requests with no matched route.
1169/// Intended for path-aware view helpers such as `nav_link`
1170/// (`autumn_web::widgets::nav_link`, behind the `maud` feature), which need
1171/// to know the current path to decide whether a navigation link is active.
1172///
1173/// # Example
1174///
1175/// ```rust,ignore
1176/// use autumn_web::prelude::*;
1177///
1178/// #[get("/posts")]
1179/// async fn index(CurrentPath(path): CurrentPath) -> Markup {
1180///     nav_link(&path, "/posts", "Posts")
1181/// }
1182/// ```
1183#[derive(Debug, Clone, PartialEq, Eq)]
1184pub struct CurrentPath(pub String);
1185
1186impl CurrentPath {
1187    /// The current request path as a string slice.
1188    #[must_use]
1189    pub fn as_str(&self) -> &str {
1190        &self.0
1191    }
1192}
1193
1194impl<S> FromRequestParts<S> for CurrentPath
1195where
1196    S: Send + Sync,
1197{
1198    type Rejection = std::convert::Infallible;
1199
1200    async fn from_request_parts(
1201        parts: &mut axum::http::request::Parts,
1202        state: &S,
1203    ) -> Result<Self, Self::Rejection> {
1204        // Use OriginalUri rather than parts.uri directly: axum's Router::nest
1205        // strips the mount prefix from parts.uri inside the nested service,
1206        // but nav_link needs the full browser-visible path to compare against
1207        // normal absolute hrefs. OriginalUri falls back to parts.uri itself
1208        // when the request wasn't dispatched through a nested router.
1209        let axum::extract::OriginalUri(uri) =
1210            axum::extract::OriginalUri::from_request_parts(parts, state)
1211                .await
1212                .unwrap();
1213        Ok(Self(uri.path().to_owned()))
1214    }
1215}
1216
1217// ── Trusted-proxy client-identity extractors ─────────────────────────────────
1218
1219use crate::security::trusted_proxies::ResolvedClientIdentity;
1220
1221/// The resolved client IP address after trusted-proxy evaluation.
1222///
1223/// Populated by the framework's proxy-resolver middleware from the operator's
1224/// `[security.trusted_proxies]` configuration.
1225///
1226/// # Plugin authors
1227///
1228/// > **Never read `X-Forwarded-*` headers directly.  Use this extractor.**
1229///
1230/// This is the only blessed way to obtain the real client IP in handlers and
1231/// middleware.  Direct reads of `X-Forwarded-For` or `X-Real-IP` will be
1232/// rejected by the `grep` CI guard introduced in #812.
1233///
1234/// # Failure
1235///
1236/// Returns `500 Internal Server Error` when the proxy-resolver middleware is
1237/// not installed.  Use `Option<ClientAddr>` for routes where the middleware may
1238/// be absent.
1239pub struct ClientAddr(pub std::net::IpAddr);
1240
1241impl ClientAddr {
1242    /// The resolved client IP.
1243    #[must_use]
1244    pub const fn ip(&self) -> std::net::IpAddr {
1245        self.0
1246    }
1247}
1248
1249impl<S> FromRequestParts<S> for ClientAddr
1250where
1251    S: Send + Sync,
1252{
1253    type Rejection = (axum::http::StatusCode, &'static str);
1254
1255    async fn from_request_parts(
1256        parts: &mut axum::http::request::Parts,
1257        _state: &S,
1258    ) -> Result<Self, Self::Rejection> {
1259        parts
1260            .extensions
1261            .get::<ResolvedClientIdentity>()
1262            .and_then(|id| id.addr)
1263            .map(ClientAddr)
1264            .ok_or((
1265                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1266                "ClientAddr not resolved. Is the TrustedProxiesLayer installed?",
1267            ))
1268    }
1269}
1270
1271impl<S> axum::extract::OptionalFromRequestParts<S> for ClientAddr
1272where
1273    S: Send + Sync,
1274{
1275    type Rejection = std::convert::Infallible;
1276
1277    async fn from_request_parts(
1278        parts: &mut axum::http::request::Parts,
1279        _state: &S,
1280    ) -> Result<Option<Self>, Self::Rejection> {
1281        Ok(parts
1282            .extensions
1283            .get::<ResolvedClientIdentity>()
1284            .and_then(|id| id.addr)
1285            .map(ClientAddr))
1286    }
1287}
1288
1289/// The resolved external host as seen by the client after trusted-proxy evaluation.
1290///
1291/// Returns the value of `X-Forwarded-Host` when the proxy is trusted, otherwise
1292/// falls back to the `Host` header.
1293///
1294/// # Plugin authors
1295///
1296/// > **Never read `X-Forwarded-Host` directly.  Use this extractor.**
1297pub struct ClientHost(pub String);
1298
1299impl ClientHost {
1300    /// The resolved host string.
1301    #[must_use]
1302    pub fn as_str(&self) -> &str {
1303        &self.0
1304    }
1305}
1306
1307impl<S> FromRequestParts<S> for ClientHost
1308where
1309    S: Send + Sync,
1310{
1311    type Rejection = (axum::http::StatusCode, &'static str);
1312
1313    async fn from_request_parts(
1314        parts: &mut axum::http::request::Parts,
1315        _state: &S,
1316    ) -> Result<Self, Self::Rejection> {
1317        parts
1318            .extensions
1319            .get::<ResolvedClientIdentity>()
1320            .and_then(|id| id.host.clone())
1321            .map(ClientHost)
1322            .ok_or((
1323                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1324                "ClientHost not resolved. Is the TrustedProxiesLayer installed?",
1325            ))
1326    }
1327}
1328
1329impl<S> axum::extract::OptionalFromRequestParts<S> for ClientHost
1330where
1331    S: Send + Sync,
1332{
1333    type Rejection = std::convert::Infallible;
1334
1335    async fn from_request_parts(
1336        parts: &mut axum::http::request::Parts,
1337        _state: &S,
1338    ) -> Result<Option<Self>, Self::Rejection> {
1339        Ok(parts
1340            .extensions
1341            .get::<ResolvedClientIdentity>()
1342            .and_then(|id| id.host.clone())
1343            .map(ClientHost))
1344    }
1345}
1346
1347/// The resolved external scheme (`"http"` or `"https"`) after trusted-proxy evaluation.
1348///
1349/// Returns the leftmost value of `X-Forwarded-Proto` when the proxy is trusted,
1350/// otherwise falls back to the request URI scheme or `"http"`.
1351///
1352/// # Plugin authors
1353///
1354/// > **Never read `X-Forwarded-Proto` directly.  Use this extractor.**
1355pub struct ClientScheme(pub String);
1356
1357impl ClientScheme {
1358    /// The resolved scheme string (`"http"` or `"https"`).
1359    #[must_use]
1360    pub fn as_str(&self) -> &str {
1361        &self.0
1362    }
1363
1364    /// Returns `true` when the resolved scheme is `"https"`.
1365    #[must_use]
1366    pub fn is_https(&self) -> bool {
1367        self.0.eq_ignore_ascii_case("https")
1368    }
1369}
1370
1371impl<S> FromRequestParts<S> for ClientScheme
1372where
1373    S: Send + Sync,
1374{
1375    type Rejection = (axum::http::StatusCode, &'static str);
1376
1377    async fn from_request_parts(
1378        parts: &mut axum::http::request::Parts,
1379        _state: &S,
1380    ) -> Result<Self, Self::Rejection> {
1381        parts
1382            .extensions
1383            .get::<ResolvedClientIdentity>()
1384            .map(|id| Self(id.scheme.clone().unwrap_or_else(|| "http".to_owned())))
1385            .ok_or((
1386                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1387                "ClientScheme not resolved. Is the TrustedProxiesLayer installed?",
1388            ))
1389    }
1390}
1391
1392impl<S> axum::extract::OptionalFromRequestParts<S> for ClientScheme
1393where
1394    S: Send + Sync,
1395{
1396    type Rejection = std::convert::Infallible;
1397
1398    async fn from_request_parts(
1399        parts: &mut axum::http::request::Parts,
1400        _state: &S,
1401    ) -> Result<Option<Self>, Self::Rejection> {
1402        Ok(parts
1403            .extensions
1404            .get::<ResolvedClientIdentity>()
1405            .map(|id| Self(id.scheme.clone().unwrap_or_else(|| "http".to_owned()))))
1406    }
1407}
1408
1409#[cfg(test)]
1410mod trusted_proxy_extractor_tests {
1411    use super::*;
1412    use axum::Router;
1413    use axum::body::Body;
1414    use axum::routing::get;
1415    use tower::ServiceExt;
1416
1417    fn make_identity(addr: &str, host: &str, scheme: &str) -> ResolvedClientIdentity {
1418        ResolvedClientIdentity {
1419            addr: Some(addr.parse().unwrap()),
1420            host: Some(host.to_owned()),
1421            scheme: Some(scheme.to_owned()),
1422        }
1423    }
1424
1425    #[tokio::test]
1426    async fn client_addr_extractor_reads_from_extension() {
1427        async fn handler(ClientAddr(ip): ClientAddr) -> String {
1428            ip.to_string()
1429        }
1430
1431        let app = Router::new().route("/", get(handler));
1432
1433        let mut req = axum::http::Request::builder()
1434            .uri("/")
1435            .body(Body::empty())
1436            .unwrap();
1437        req.extensions_mut()
1438            .insert(make_identity("192.0.2.1", "app.example", "https"));
1439
1440        let resp = app.oneshot(req).await.unwrap();
1441        assert_eq!(resp.status(), axum::http::StatusCode::OK);
1442        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1443        assert_eq!(&body[..], b"192.0.2.1");
1444    }
1445
1446    #[tokio::test]
1447    async fn client_host_extractor_reads_from_extension() {
1448        async fn handler(ClientHost(host): ClientHost) -> String {
1449            host
1450        }
1451
1452        let app = Router::new().route("/", get(handler));
1453
1454        let mut req = axum::http::Request::builder()
1455            .uri("/")
1456            .body(Body::empty())
1457            .unwrap();
1458        req.extensions_mut()
1459            .insert(make_identity("192.0.2.1", "app.example", "https"));
1460
1461        let resp = app.oneshot(req).await.unwrap();
1462        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1463        assert_eq!(&body[..], b"app.example");
1464    }
1465
1466    #[tokio::test]
1467    async fn client_scheme_extractor_reads_from_extension() {
1468        async fn handler(ClientScheme(scheme): ClientScheme) -> String {
1469            scheme
1470        }
1471
1472        let app = Router::new().route("/", get(handler));
1473
1474        let mut req = axum::http::Request::builder()
1475            .uri("/")
1476            .body(Body::empty())
1477            .unwrap();
1478        req.extensions_mut()
1479            .insert(make_identity("192.0.2.1", "app.example", "https"));
1480
1481        let resp = app.oneshot(req).await.unwrap();
1482        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1483        assert_eq!(&body[..], b"https");
1484    }
1485
1486    #[tokio::test]
1487    async fn client_addr_missing_returns_500() {
1488        async fn handler(_: ClientAddr) -> &'static str {
1489            "ok"
1490        }
1491
1492        let app = Router::new().route("/", get(handler));
1493        let req = axum::http::Request::builder()
1494            .uri("/")
1495            .body(Body::empty())
1496            .unwrap();
1497        let resp = app.oneshot(req).await.unwrap();
1498        assert_eq!(resp.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
1499    }
1500
1501    #[tokio::test]
1502    async fn optional_client_addr_returns_none_when_missing() {
1503        async fn handler(addr: Option<ClientAddr>) -> String {
1504            if addr.is_some() {
1505                "some".to_owned()
1506            } else {
1507                "none".to_owned()
1508            }
1509        }
1510
1511        let app = Router::new().route("/", get(handler));
1512        let req = axum::http::Request::builder()
1513            .uri("/")
1514            .body(Body::empty())
1515            .unwrap();
1516        let resp = app.oneshot(req).await.unwrap();
1517        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1518        assert_eq!(&body[..], b"none");
1519    }
1520
1521    #[tokio::test]
1522    async fn current_path_extracts_uri_path() {
1523        async fn handler(CurrentPath(path): CurrentPath) -> String {
1524            path
1525        }
1526
1527        let app = Router::new().route("/admin/posts", get(handler));
1528        let req = axum::http::Request::builder()
1529            .uri("/admin/posts")
1530            .body(Body::empty())
1531            .unwrap();
1532        let resp = app.oneshot(req).await.unwrap();
1533        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1534        assert_eq!(&body[..], b"/admin/posts");
1535    }
1536
1537    #[tokio::test]
1538    async fn current_path_strips_query_string() {
1539        async fn handler(CurrentPath(path): CurrentPath) -> String {
1540            path
1541        }
1542
1543        let app = Router::new().route("/admin/posts", get(handler));
1544        let req = axum::http::Request::builder()
1545            .uri("/admin/posts?page=2")
1546            .body(Body::empty())
1547            .unwrap();
1548        let resp = app.oneshot(req).await.unwrap();
1549        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1550        assert_eq!(&body[..], b"/admin/posts");
1551    }
1552
1553    #[tokio::test]
1554    async fn current_path_preserves_prefix_under_nested_router() {
1555        // axum's Router::nest strips the mount prefix from `parts.uri` inside
1556        // the nested service, so a request to "/admin/posts" is seen as
1557        // "/posts" there. nav_link's documented use compares CurrentPath
1558        // against normal absolute hrefs like "/admin/posts", so CurrentPath
1559        // must return the browser-visible path (via OriginalUri), not the
1560        // nest-relative one.
1561        async fn handler(CurrentPath(path): CurrentPath) -> String {
1562            path
1563        }
1564
1565        let inner = Router::new().route("/posts", get(handler));
1566        let app = Router::new().nest("/admin", inner);
1567        let req = axum::http::Request::builder()
1568            .uri("/admin/posts")
1569            .body(Body::empty())
1570            .unwrap();
1571        let resp = app.oneshot(req).await.unwrap();
1572        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1573        assert_eq!(&body[..], b"/admin/posts");
1574    }
1575}