Skip to main content

autumn_web/security/
submit_token.rs

1//! One-time submit tokens — at-most-once form submissions with no client JS.
2//!
3//! A user who double-clicks **Submit** (or hits Back→resubmit, or whose
4//! browser silently retries a flaky POST) can otherwise create duplicate
5//! records. The two adjacent primitives don't close this hole on their own:
6//!
7//! - [`CsrfLayer`](crate::security::CsrfLayer) mints a *stable per-session*
8//!   token — a valid `_csrf` value submitted twice passes both times.
9//! - [`IdempotencyLayer`](crate::idempotency::IdempotencyLayer) is
10//!   *header-driven* on `Idempotency-Key`, which API clients send but browsers
11//!   never attach to a plain form POST.
12//!
13//! [`SubmitTokenLayer`] fuses the per-request token plumbing with the shared
14//! idempotency store to give every scaffolded form a **server-side, default-on**
15//! at-most-once guarantee:
16//!
17//! 1. On every render a fresh random token is made available via the
18//!    [`SubmitToken`] extractor, embeddable as a hidden `_submit_token` field.
19//! 2. On a mutating request the guard extracts `_submit_token` from the form
20//!    body and consumes it against the store. First use runs the handler and
21//!    records the response; a replayed token short-circuits and replays the
22//!    first response instead of re-running the handler.
23//! 3. No client header is required — this is the explicit difference from the
24//!    `Idempotency-Key` layer.
25//!
26//! # Examples
27//!
28//! ```rust,ignore
29//! use autumn_web::prelude::*;
30//! use autumn_web::security::SubmitToken;
31//!
32//! #[get("/form")]
33//! async fn form(submit_token: SubmitToken) -> Markup {
34//!     html! {
35//!         form method="POST" action="/submit" {
36//!             input type="hidden" name="_submit_token" value=(submit_token.token());
37//!             input type="text" name="title";
38//!             button { "Submit" }
39//!         }
40//!     }
41//! }
42//! ```
43
44use std::future::Future;
45use std::pin::Pin;
46use std::sync::Arc;
47use std::task::{Context, Poll};
48use std::time::Duration;
49
50use axum::body::{Body, Bytes};
51use axum::extract::{FromRequestParts, OptionalFromRequestParts};
52use axum::http::{HeaderMap, Method, Request, Response, StatusCode};
53use futures::StreamExt as _;
54use sha2::Digest as _;
55use tower::{Layer, Service};
56use uuid::Uuid;
57
58use super::config::SubmitTokenConfig;
59use crate::idempotency::{IdempotencyRecord, IdempotencyStore};
60
61/// Response header set on a replayed submit-token response.
62const SUBMIT_TOKEN_REPLAYED: &str = "x-submit-token-replayed";
63
64/// Maximum response body size cached for replay. Scaffold create/update
65/// handlers redirect (303) with tiny bodies; larger responses stream through
66/// without caching.
67const MAX_CACHEABLE_RESPONSE_BODY: usize = 10 * 1024 * 1024; // 10 MiB
68
69/// Hard cap on the request body bytes buffered while scanning for the token.
70const MAX_SCAN_BYTES_CAP: usize = 2 * 1024 * 1024; // 2 MiB
71
72/// The configured submit-token form field name.
73///
74/// Placed in request extensions by [`SubmitTokenLayer`] so templates emit the
75/// hidden input under the correct name even when
76/// `security.submit_token.field_name` is customised.
77#[derive(Clone, Debug)]
78pub struct SubmitFormField(pub String);
79
80/// A one-time submit token minted per render.
81///
82/// Use this as a handler parameter to embed the token in an HTML form's hidden
83/// `_submit_token` field. A fresh token is generated for every request (both
84/// the GET that renders the form and the 422 re-render on a rejected POST) and
85/// stored in request extensions by [`SubmitTokenLayer`].
86#[derive(Clone, Debug)]
87pub struct SubmitToken(String);
88
89impl SubmitToken {
90    /// Returns the submit-token value for embedding in forms.
91    #[must_use]
92    pub fn token(&self) -> &str {
93        &self.0
94    }
95
96    #[cfg(test)]
97    pub(crate) const fn new(token: String) -> Self {
98        Self(token)
99    }
100}
101
102impl std::fmt::Display for SubmitToken {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.write_str(&self.0)
105    }
106}
107
108impl<S> FromRequestParts<S> for SubmitToken
109where
110    S: Send + Sync,
111{
112    type Rejection = (StatusCode, &'static str);
113
114    async fn from_request_parts(
115        parts: &mut axum::http::request::Parts,
116        _state: &S,
117    ) -> Result<Self, Self::Rejection> {
118        parts.extensions.get::<Self>().cloned().ok_or((
119            StatusCode::INTERNAL_SERVER_ERROR,
120            "Submit token not found in request extensions. Is SubmitTokenLayer enabled?",
121        ))
122    }
123}
124
125impl<S> OptionalFromRequestParts<S> for SubmitToken
126where
127    S: Send + Sync,
128{
129    type Rejection = std::convert::Infallible;
130
131    async fn from_request_parts(
132        parts: &mut axum::http::request::Parts,
133        _state: &S,
134    ) -> Result<Option<Self>, Self::Rejection> {
135        Ok(parts.extensions.get::<Self>().cloned())
136    }
137}
138
139impl<S> FromRequestParts<S> for SubmitFormField
140where
141    S: Send + Sync,
142{
143    type Rejection = (StatusCode, &'static str);
144
145    async fn from_request_parts(
146        parts: &mut axum::http::request::Parts,
147        _state: &S,
148    ) -> Result<Self, Self::Rejection> {
149        parts.extensions.get::<Self>().cloned().ok_or((
150            StatusCode::INTERNAL_SERVER_ERROR,
151            "Submit form field not found in request extensions. Is SubmitTokenLayer enabled?",
152        ))
153    }
154}
155
156impl<S> OptionalFromRequestParts<S> for SubmitFormField
157where
158    S: Send + Sync,
159{
160    type Rejection = std::convert::Infallible;
161
162    async fn from_request_parts(
163        parts: &mut axum::http::request::Parts,
164        _state: &S,
165    ) -> Result<Option<Self>, Self::Rejection> {
166        Ok(parts.extensions.get::<Self>().cloned())
167    }
168}
169
170const fn is_mutating_method(method: &Method) -> bool {
171    matches!(
172        *method,
173        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
174    )
175}
176
177fn hex_lower(bytes: impl AsRef<[u8]>) -> String {
178    bytes.as_ref().iter().fold(
179        String::with_capacity(bytes.as_ref().len() * 2),
180        |mut out, byte| {
181            use std::fmt::Write as _;
182            let _ = write!(out, "{byte:02x}");
183            out
184        },
185    )
186}
187
188/// Derive the store key for a submitted token. The token is a per-render random
189/// UUID, so it is globally unique and needs no method/path/principal scoping —
190/// the token itself identifies the single logical submission.
191fn storage_key(token: &str) -> String {
192    let mut hasher = sha2::Sha256::new();
193    hasher.update(b"autumn.submit_token:v1:");
194    hasher.update(token.as_bytes());
195    format!("submit:{}", hex_lower(hasher.finalize()))
196}
197
198// ── Multipart / body scanning helpers ─────────────────────────────────────────
199
200/// Return the byte position of the first occurrence of `needle` in `haystack`.
201fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
202    if needle.is_empty() {
203        return Some(0);
204    }
205    haystack.windows(needle.len()).position(|w| w == needle)
206}
207
208/// Scan a buffered `multipart/form-data` body for a named text field.
209fn scan_multipart_field<'a>(bytes: &'a [u8], boundary: &str, field_name: &str) -> Option<&'a str> {
210    let delimiter = format!("--{boundary}");
211    let delim = delimiter.as_bytes();
212    let end_marker = format!("\r\n{delimiter}");
213    let end_bytes = end_marker.as_bytes();
214    let mut pos = 0;
215
216    loop {
217        let rel = find_bytes(&bytes[pos..], delim)?;
218        pos += rel + delim.len();
219
220        match bytes.get(pos..pos + 2) {
221            Some(b"\r\n") => pos += 2,
222            _ => break,
223        }
224
225        let header_end = find_bytes(&bytes[pos..], b"\r\n\r\n")?;
226        let headers = std::str::from_utf8(&bytes[pos..pos + header_end]).ok()?;
227        let value_start = pos + header_end + 4;
228
229        let is_match = headers.lines().any(|line| {
230            if !line
231                .to_ascii_lowercase()
232                .starts_with("content-disposition:")
233            {
234                return false;
235            }
236            line.split(';').skip(1).any(|attr| {
237                attr.trim()
238                    .strip_prefix("name=")
239                    .map(|v| v.trim_matches('"'))
240                    == Some(field_name)
241            })
242        });
243
244        if is_match {
245            let end = find_bytes(&bytes[value_start..], end_bytes)
246                .map_or(bytes.len(), |i| value_start + i);
247            return std::str::from_utf8(&bytes[value_start..end]).ok();
248        }
249
250        let next = find_bytes(&bytes[value_start..], end_bytes)?;
251        pos = value_start + next + 2;
252    }
253
254    None
255}
256
257fn scan_for_token(
258    bytes: &[u8],
259    is_urlencoded: bool,
260    boundary: Option<&str>,
261    field: &str,
262) -> Option<String> {
263    if is_urlencoded {
264        url::form_urlencoded::parse(bytes)
265            .find(|(key, _)| key == field)
266            .map(|(_, value)| value.into_owned())
267    } else if let Some(boundary) = boundary {
268        scan_multipart_field(bytes, boundary, field).map(str::to_owned)
269    } else {
270        None
271    }
272}
273
274/// Body collected up to a scan/cache limit.
275enum CollectedBody {
276    /// The whole body fits within the limit and is fully buffered.
277    Full(Bytes),
278    /// The body exceeded the limit. `prefix` is the first `limit` bytes of the
279    /// body — including the leading bytes of the chunk that crossed the cap, so
280    /// a token near the front is scannable even when the very first chunk is
281    /// already over-limit; `body` chains the full, unmodified body (every byte
282    /// of every chunk) with the remaining stream for pass-through.
283    Oversized { prefix: Bytes, body: Body },
284    /// The body stream yielded an error before EOF (and before crossing the
285    /// limit). The bytes read so far are discarded: buffering-then-rebuilding
286    /// them would hand a downstream handler a silently TRUNCATED form (a valid
287    /// leading `_submit_token` followed by missing tail fields), so the caller
288    /// must fail the request instead of pretending the short read succeeded.
289    Errored(axum::Error),
290}
291
292/// Buffer `body` up to `limit` bytes without corrupting oversized bodies.
293async fn collect_body(body: Body, limit: usize) -> CollectedBody {
294    let mut buf = Vec::<u8>::new();
295    let mut stream = body.into_data_stream();
296    loop {
297        match stream.next().await {
298            // Clean end of stream: return everything buffered so far.
299            None => break,
300            // A mid-stream read error must be propagated, never swallowed into a
301            // truncated `Full`: the handler would otherwise receive a form whose
302            // tail fields were lost while the leading token still scanned and
303            // consumed, so the caller fails the request on this variant.
304            Some(Err(err)) => return CollectedBody::Errored(err),
305            Some(Ok(chunk)) => {
306                let remaining = limit.saturating_sub(buf.len());
307                if chunk.len() > remaining {
308                    // Fill the scan prefix up to `limit` with the leading bytes
309                    // of the over-limit chunk, so a token at the front of the
310                    // form is still found even when the FIRST chunk already
311                    // exceeds the cap (e.g. an upstream middleware rebuilt the
312                    // buffered body as one `Body::from(bytes)`, leaving `buf`
313                    // empty here).
314                    let mut prefix_buf = buf.clone();
315                    prefix_buf.extend_from_slice(&chunk[..remaining]);
316                    let prefix = Bytes::from(prefix_buf);
317                    // Replay the FULL body unchanged: the buffered prefix bytes
318                    // followed by the *complete* over-limit chunk (not just its
319                    // scanned head) and the rest of the stream. The scan prefix
320                    // is only for locating the token; the handler must receive
321                    // every byte.
322                    let mut leading = Vec::with_capacity(2);
323                    if !buf.is_empty() {
324                        leading.push(Ok::<Bytes, axum::Error>(Bytes::from(buf)));
325                    }
326                    leading.push(Ok::<Bytes, axum::Error>(chunk));
327                    let body = Body::from_stream(futures::stream::iter(leading).chain(stream));
328                    return CollectedBody::Oversized { prefix, body };
329                }
330                buf.extend_from_slice(&chunk);
331            }
332        }
333    }
334    CollectedBody::Full(Bytes::from(buf))
335}
336
337/// Extract the submitted `_submit_token` from the request body, returning the
338/// token (if present) and a request whose body is preserved for the handler.
339///
340/// Returns `Err` when the body stream errors mid-read while scanning: the caller
341/// must reject the request rather than forward a truncated form (see
342/// [`CollectedBody::Errored`]).
343async fn extract_submitted_token(
344    req: Request<Body>,
345    field: &str,
346    max_scan_bytes: usize,
347) -> Result<(Option<String>, Request<Body>), axum::Error> {
348    let (parts, body) = req.into_parts();
349
350    let content_type = parts
351        .headers
352        .get(axum::http::header::CONTENT_TYPE)
353        .and_then(|v| v.to_str().ok())
354        .unwrap_or_default();
355    // Media types are case-insensitive (RFC 9110 8.3.1) and the header may carry
356    // leading whitespace; normalize the type token for the urlencoded check.
357    let is_urlencoded = content_type
358        .trim_start()
359        .to_ascii_lowercase()
360        .starts_with("application/x-www-form-urlencoded");
361    // Parse the boundary with `multer::parse_boundary` — the exact parser
362    // `axum::extract::Multipart` uses downstream (via `mime`) — so the guard and
363    // the extractor can never disagree about the boundary. A hand-rolled
364    // `split(';')` diverges on quoted values: `mime` permits a `;` inside a
365    // quoted parameter value, so `boundary="x;y"` parses to the boundary `x;y`
366    // in the real extractor while a split truncates it to `x`, leaving the form
367    // to be handled while its `_submit_token` is never scanned/consumed (the
368    // request stays REPLAYABLE). `parse_boundary` is case-insensitive on the
369    // media type / `boundary` param name and preserves the boundary VALUE's
370    // case; it returns `Err` for a non-multipart type, so `.ok()` yields `None`.
371    let boundary = multer::parse_boundary(content_type).ok();
372    // content_type borrow ends here.
373
374    if !is_urlencoded && boundary.is_none() {
375        return Ok((None, Request::from_parts(parts, body)));
376    }
377
378    match collect_body(body, max_scan_bytes).await {
379        CollectedBody::Full(bytes) => {
380            let token = scan_for_token(&bytes, is_urlencoded, boundary.as_deref(), field);
381            Ok((token, Request::from_parts(parts, Body::from(bytes))))
382        }
383        CollectedBody::Oversized { prefix, body } => {
384            let token = scan_for_token(&prefix, is_urlencoded, boundary.as_deref(), field);
385            Ok((token, Request::from_parts(parts, body)))
386        }
387        CollectedBody::Errored(err) => Err(err),
388    }
389}
390
391/// Headers to strip when caching a response for replay: hop-by-hop headers plus
392/// `set-cookie` (never resurrect a stale cookie on a replay) and our own marker.
393fn replay_headers(headers: &HeaderMap) -> Vec<(String, Vec<u8>)> {
394    const SKIP: &[&str] = &[
395        "connection",
396        "transfer-encoding",
397        "keep-alive",
398        "upgrade",
399        "proxy-authenticate",
400        "proxy-authorization",
401        "te",
402        "trailer",
403        "set-cookie",
404        SUBMIT_TOKEN_REPLAYED,
405    ];
406    headers
407        .iter()
408        .filter(|(name, _)| !SKIP.contains(&name.as_str()))
409        .map(|(name, value)| (name.to_string(), value.as_bytes().to_vec()))
410        .collect()
411}
412
413fn replay_response(record: &IdempotencyRecord) -> Response<Body> {
414    let mut builder = Response::builder().status(record.status);
415    for (name, value) in &record.headers {
416        builder = builder.header(name.as_str(), value.as_slice());
417    }
418    builder
419        .header(SUBMIT_TOKEN_REPLAYED, "true")
420        .body(Body::from(record.body.clone()))
421        .unwrap_or_else(|_| {
422            let mut resp = Response::new(Body::empty());
423            *resp.status_mut() =
424                StatusCode::from_u16(record.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
425            resp
426        })
427}
428
429fn in_flight_conflict_response() -> Response<Body> {
430    Response::builder()
431        .status(StatusCode::CONFLICT)
432        .header("retry-after", "1")
433        .body(Body::from(
434            "this form submission is already being processed; retry after 1 second",
435        ))
436        .unwrap_or_else(|_| Response::new(Body::empty()))
437}
438
439/// `400` returned when the request body stream errors mid-read while scanning
440/// for the token. Failing here is safer than forwarding a truncated form to the
441/// handler (missing tail fields) after a leading token has already been consumed.
442fn body_read_error_response() -> Response<Body> {
443    Response::builder()
444        .status(StatusCode::BAD_REQUEST)
445        .body(Body::from("could not read the request body"))
446        .unwrap_or_else(|_| Response::new(Body::empty()))
447}
448
449/// `500` returned when the handler's response body stream errors while it is
450/// being buffered for replay caching. The token is not recorded, and the
451/// in-flight lock is intentionally kept held (it expires via `in_flight_ttl`)
452/// so a retry is rejected in-flight rather than re-running the already-committed
453/// mutation; surface an error rather than a truncated body.
454fn response_read_error_response() -> Response<Body> {
455    Response::builder()
456        .status(StatusCode::INTERNAL_SERVER_ERROR)
457        .body(Body::from("could not read the response body"))
458        .unwrap_or_else(|_| Response::new(Body::empty()))
459}
460
461// ── Layer / Service ───────────────────────────────────────────────────────────
462
463struct SubmitTokenSettings {
464    store: Arc<dyn IdempotencyStore>,
465    field_name: String,
466    ttl: Duration,
467    in_flight_ttl: Duration,
468    exempt_paths: Vec<String>,
469    max_scan_bytes: usize,
470}
471
472// `Arc::make_mut` in the builder methods requires `Clone` on the inner value.
473impl Clone for SubmitTokenSettings {
474    fn clone(&self) -> Self {
475        Self {
476            store: Arc::clone(&self.store),
477            field_name: self.field_name.clone(),
478            ttl: self.ttl,
479            in_flight_ttl: self.in_flight_ttl,
480            exempt_paths: self.exempt_paths.clone(),
481            max_scan_bytes: self.max_scan_bytes,
482        }
483    }
484}
485
486/// Tower [`Layer`] that enforces at-most-once form submissions via one-time
487/// submit tokens.
488///
489/// Applied automatically when `security.submit_token.enabled = true` in config
490/// (the default). Mints a fresh [`SubmitToken`] into request extensions on every
491/// request, and guards mutating requests that carry a `_submit_token` form
492/// field.
493#[derive(Clone)]
494pub struct SubmitTokenLayer {
495    settings: Arc<SubmitTokenSettings>,
496}
497
498impl std::fmt::Debug for SubmitTokenLayer {
499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        f.debug_struct("SubmitTokenLayer")
501            .field("field_name", &self.settings.field_name)
502            .field("ttl", &self.settings.ttl)
503            .finish_non_exhaustive()
504    }
505}
506
507impl SubmitTokenLayer {
508    /// Create a new submit-token layer backed by `store` and configured from
509    /// `config`.
510    #[must_use]
511    pub fn new(store: Arc<dyn IdempotencyStore>, config: &SubmitTokenConfig) -> Self {
512        let ttl = Duration::from_secs(config.ttl_secs);
513        // The in-flight lock TTL is a SEPARATE knob from the replay `ttl`: it
514        // must outlast any active mutating request so a slow submission's retry
515        // stays excluded until the first request records its consumed token.
516        // Deriving it from `ttl_secs` would let lowering the replay window reopen
517        // the double-execute re-entry gap (issue #1360).
518        let in_flight_ttl = Duration::from_secs(config.in_flight_ttl_secs);
519        Self {
520            settings: Arc::new(SubmitTokenSettings {
521                store,
522                field_name: config.field_name.clone(),
523                ttl,
524                in_flight_ttl,
525                exempt_paths: config.exempt_paths.clone(),
526                max_scan_bytes: MAX_SCAN_BYTES_CAP,
527            }),
528        }
529    }
530
531    /// Limit the form-body bytes read when scanning for the token field. The
532    /// effective limit is `min(n, 2 MiB)`.
533    #[must_use]
534    pub fn with_max_scan_bytes(mut self, n: usize) -> Self {
535        Arc::make_mut(&mut self.settings).max_scan_bytes = n.min(MAX_SCAN_BYTES_CAP);
536        self
537    }
538
539    /// Add a path prefix that is exempt from submit-token guarding.
540    #[must_use]
541    pub fn with_exempt_path(mut self, path: impl Into<String>) -> Self {
542        Arc::make_mut(&mut self.settings)
543            .exempt_paths
544            .push(path.into());
545        self
546    }
547}
548
549impl<S> Layer<S> for SubmitTokenLayer {
550    type Service = SubmitTokenService<S>;
551
552    fn layer(&self, inner: S) -> Self::Service {
553        SubmitTokenService {
554            inner,
555            settings: Arc::clone(&self.settings),
556        }
557    }
558}
559
560/// Tower [`Service`] produced by [`SubmitTokenLayer`].
561#[derive(Clone)]
562pub struct SubmitTokenService<S> {
563    inner: S,
564    settings: Arc<SubmitTokenSettings>,
565}
566
567impl<S> Service<Request<Body>> for SubmitTokenService<S>
568where
569    S: Service<Request<Body>, Response = Response<Body>, Error = std::convert::Infallible>
570        + Clone
571        + Send
572        + 'static,
573    S::Future: Send + 'static,
574{
575    type Response = Response<Body>;
576    type Error = std::convert::Infallible;
577    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
578
579    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
580        self.inner.poll_ready(cx)
581    }
582
583    fn call(&mut self, mut req: Request<Body>) -> Self::Future {
584        // Mint a fresh token for every request and expose it via extensions so
585        // GET renders and 422 re-renders can embed it in the next form.
586        let minted = Uuid::new_v4().to_string();
587        req.extensions_mut().insert(SubmitToken(minted));
588        req.extensions_mut()
589            .insert(SubmitFormField(self.settings.field_name.clone()));
590
591        let clean = crate::security::path::clean_path(req.uri().path());
592        let path = clean.as_str();
593        let is_exempt = self.settings.exempt_paths.iter().any(|prefix| {
594            if path == prefix {
595                true
596            } else if let Some(stripped) = path.strip_prefix(prefix) {
597                prefix.ends_with('/') || stripped.starts_with('/')
598            } else {
599                false
600            }
601        });
602        let is_guarded = !is_exempt && is_mutating_method(req.method());
603
604        let settings = Arc::clone(&self.settings);
605        let clone = self.inner.clone();
606        let mut inner = std::mem::replace(&mut self.inner, clone);
607
608        Box::pin(async move {
609            if !is_guarded {
610                return inner.call(req).await;
611            }
612
613            let (submitted, req) =
614                match extract_submitted_token(req, &settings.field_name, settings.max_scan_bytes)
615                    .await
616                {
617                    Ok(pair) => pair,
618                    Err(error) => {
619                        // A body-stream read error while scanning must reject the
620                        // request: forwarding the bytes read so far would hand the
621                        // handler a truncated form whose leading token we already
622                        // scanned, silently dropping tail fields.
623                        tracing::warn!(
624                            error = %error,
625                            "Submit-token body scan failed on a read error; rejecting the request"
626                        );
627                        return Ok(body_read_error_response());
628                    }
629                };
630
631            let Some(token) = submitted.filter(|t| !t.is_empty()) else {
632                // No submit token present — pass through unchanged.
633                return inner.call(req).await;
634            };
635
636            let key = storage_key(&token);
637
638            // Already consumed — replay the stored first response. Use the
639            // fallible `try_get` so a backend read/deserialization failure fails
640            // CLOSED (matching `IdempotencyService`'s lookup path) instead of
641            // collapsing to a cache miss: a swallowed error would fall through,
642            // acquire a fresh lock, and re-run the mutation for an
643            // already-consumed token. No lock is held yet, so surface `503`.
644            match settings.store.try_get(&key) {
645                Ok(Some(entry)) => return Ok(replay_response(&entry.record)),
646                Ok(None) => {}
647                Err(error) => {
648                    tracing::error!(
649                        error = %error,
650                        "Submit-token consumed-token lookup failed; failing closed"
651                    );
652                    return Ok(crate::idempotency::persistence_failed_response());
653                }
654            }
655
656            // Acquire the in-flight lock. A concurrent duplicate that loses the
657            // race gets a 409 so it can never re-run the handler.
658            if !settings.store.try_lock(&key, settings.in_flight_ttl) {
659                return Ok(in_flight_conflict_response());
660            }
661
662            // Double-check after locking: a racing request may have completed
663            // and stored its response between our miss and the lock acquisition.
664            // A read failure here must also fail closed. Because we now hold the
665            // in-flight lock, keep it held (do NOT unlock, letting it expire via
666            // `in_flight_ttl`) so a retry with the same token is rejected
667            // in-flight rather than re-running the handler — exactly as
668            // `IdempotencyService` does on a post-lock lookup error.
669            match settings.store.try_get(&key) {
670                Ok(Some(entry)) => {
671                    settings.store.unlock(&key);
672                    return Ok(replay_response(&entry.record));
673                }
674                Ok(None) => {}
675                Err(error) => {
676                    tracing::error!(
677                        error = %error,
678                        "Submit-token consumed-token lookup failed after lock acquisition; failing closed"
679                    );
680                    return Ok(crate::idempotency::persistence_failed_response());
681                }
682            }
683
684            let response = inner.call(req).await?;
685            Ok(cache_consumed_token_response(response, &settings, &key).await)
686        })
687    }
688}
689
690/// Run the handler's response through the consumed-token replay cache: buffer it
691/// (up to [`MAX_CACHEABLE_RESPONSE_BODY`]), record 2xx/3xx responses under `key`
692/// so a replay returns them verbatim, and release the in-flight lock. Kept out
693/// of [`SubmitTokenService::call`] so that hot method stays under the line cap.
694async fn cache_consumed_token_response(
695    response: Response<Body>,
696    settings: &SubmitTokenSettings,
697    key: &str,
698) -> Response<Body> {
699    let (parts, body) = response.into_parts();
700    match collect_body(body, MAX_CACHEABLE_RESPONSE_BODY).await {
701        CollectedBody::Full(bytes) => {
702            let status = parts.status.as_u16();
703            // Cache successful (2xx) and redirect (3xx) responses so the
704            // replayed submit returns the first response verbatim.
705            if (200..400).contains(&status) {
706                let record = IdempotencyRecord {
707                    status,
708                    headers: replay_headers(&parts.headers),
709                    body: bytes.to_vec(),
710                    metadata: Vec::new(),
711                };
712                // Persist the consumed-token record. If the store write fails
713                // after the handler already committed its mutation and returned
714                // a 2xx/3xx, fail closed exactly as `IdempotencyLayer` does: keep
715                // the in-flight lock held (by not unlocking, it expires via
716                // `in_flight_ttl`) so a retry carrying the same token gets a
717                // `409` in-flight conflict rather than silently re-running the
718                // create/update, and surface `503` instead of an un-recorded
719                // success.
720                if let Err(error) = settings
721                    .store
722                    .try_set(key, record, Vec::new(), settings.ttl)
723                {
724                    tracing::error!(
725                        error = %error,
726                        "Submit-token persistence failed after handler success; failing closed"
727                    );
728                    return crate::idempotency::persistence_failed_response();
729                }
730            }
731            settings.store.unlock(key);
732            Response::from_parts(parts, Body::from(bytes))
733        }
734        CollectedBody::Oversized { body, .. } => {
735            // Too large to cache — stream through. The lock is released; a later
736            // retry re-runs (acceptable: form responses are tiny redirects, so
737            // this path is not hit in practice).
738            settings.store.unlock(key);
739            Response::from_parts(parts, body)
740        }
741        CollectedBody::Errored(error) => {
742            let status = parts.status.as_u16();
743            // The response body errored while buffering for the replay cache.
744            // Mirror the `Full` branch's commit policy, keyed on status:
745            //
746            // * 2xx/3xx (committed, cacheable): the handler already committed
747            //   its mutation, but we can neither record the token nor replay a
748            //   truncated body. Fail closed exactly like the `try_set`
749            //   persistence-failure path above — keep the in-flight lock held
750            //   (by not unlocking, it expires via `in_flight_ttl`) so a retry
751            //   carrying the same token gets a `409` in-flight conflict rather
752            //   than re-running the committed mutation.
753            // * non-2xx/3xx (not committed, not cacheable): like the `Full`
754            //   branch's clean non-success path, this stores no record and the
755            //   request stays retryable, so release the lock. An immediate
756            //   resubmit of a failed/validation request re-runs the handler
757            //   instead of getting a spurious 24h `409` in-flight conflict.
758            tracing::error!(
759                error = %error,
760                "Submit-token response buffering failed on a read error; failing closed"
761            );
762            if !(200..400).contains(&status) {
763                settings.store.unlock(key);
764            }
765            response_read_error_response()
766        }
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::idempotency::{IdempotencyEntry, IdempotencyStoreError, MemoryIdempotencyStore};
774    use axum::Router;
775    use axum::routing::{get, post};
776    use std::sync::atomic::{AtomicUsize, Ordering};
777    use tower::ServiceExt;
778
779    fn default_config() -> SubmitTokenConfig {
780        SubmitTokenConfig {
781            enabled: true,
782            ..Default::default()
783        }
784    }
785
786    fn layer_with_store(store: Arc<dyn IdempotencyStore>) -> SubmitTokenLayer {
787        SubmitTokenLayer::new(store, &default_config())
788    }
789
790    fn urlencoded_post(token: &str) -> Request<Body> {
791        Request::builder()
792            .method("POST")
793            .uri("/submit")
794            .header("Content-Type", "application/x-www-form-urlencoded")
795            .body(Body::from(format!("_submit_token={token}&title=hello")))
796            .unwrap()
797    }
798
799    /// Build a `multipart/form-data` POST carrying `_submit_token`. The raw
800    /// `content_type` header value and the `boundary` used for the body
801    /// delimiters are supplied separately so tests can vary the media-type
802    /// casing independently of the (case-sensitive) boundary value.
803    fn multipart_post(content_type: &str, boundary: &str, token: &str) -> Request<Body> {
804        let body = format!(
805            "--{boundary}\r\n\
806             Content-Disposition: form-data; name=\"_submit_token\"\r\n\
807             \r\n\
808             {token}\r\n\
809             --{boundary}--\r\n"
810        );
811        Request::builder()
812            .method("POST")
813            .uri("/submit")
814            .header("Content-Type", content_type)
815            .body(Body::from(body))
816            .unwrap()
817    }
818
819    #[test]
820    fn submit_token_extractor_exposes_value() {
821        let token = SubmitToken::new("abc-123".to_owned());
822        assert_eq!(token.token(), "abc-123");
823        assert_eq!(token.to_string(), "abc-123");
824    }
825
826    #[tokio::test]
827    async fn mints_token_available_to_extractor() {
828        async fn handler(submit_token: SubmitToken) -> String {
829            submit_token.token().to_owned()
830        }
831        let store: Arc<dyn IdempotencyStore> =
832            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
833        let app = Router::new()
834            .route("/", get(handler))
835            .layer(layer_with_store(store));
836
837        let response = app
838            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
839            .await
840            .unwrap();
841        assert_eq!(response.status(), StatusCode::OK);
842        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
843            .await
844            .unwrap();
845        let token = String::from_utf8(body.to_vec()).unwrap();
846        assert!(
847            Uuid::parse_str(&token).is_ok(),
848            "minted token should be a uuid: {token}"
849        );
850    }
851
852    #[tokio::test]
853    async fn first_use_runs_handler_replay_short_circuits() {
854        let store: Arc<dyn IdempotencyStore> =
855            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
856        let count = Arc::new(AtomicUsize::new(0));
857        let count_inner = count.clone();
858        let app = Router::new()
859            .route(
860                "/submit",
861                post(move || {
862                    let count = count_inner.clone();
863                    async move {
864                        count.fetch_add(1, Ordering::SeqCst);
865                        "created"
866                    }
867                }),
868            )
869            .layer(layer_with_store(store));
870
871        let token = "tok-replay";
872        // First submit runs the handler.
873        let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
874        assert_eq!(first.status(), StatusCode::OK);
875        assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
876        let first_body = axum::body::to_bytes(first.into_body(), usize::MAX)
877            .await
878            .unwrap();
879        assert_eq!(&first_body[..], b"created");
880
881        // Second submit with the same token replays without re-running.
882        let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
883        assert_eq!(second.status(), StatusCode::OK);
884        assert_eq!(
885            second
886                .headers()
887                .get(SUBMIT_TOKEN_REPLAYED)
888                .map(|v| v.to_str().unwrap()),
889            Some("true")
890        );
891        let second_body = axum::body::to_bytes(second.into_body(), usize::MAX)
892            .await
893            .unwrap();
894        assert_eq!(&second_body[..], b"created");
895
896        assert_eq!(
897            count.load(Ordering::SeqCst),
898            1,
899            "handler must run exactly once"
900        );
901    }
902
903    #[tokio::test]
904    async fn lowercase_multipart_consumes_token_and_replay_short_circuits() {
905        // Positive control: a conventionally-cased multipart body already works.
906        // This proves the multipart body format the tests build is correct, so
907        // any failure of the mixed-case test below is unambiguously about casing.
908        let store: Arc<dyn IdempotencyStore> =
909            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
910        let count = Arc::new(AtomicUsize::new(0));
911        let count_inner = count.clone();
912        let app = Router::new()
913            .route(
914                "/submit",
915                post(move || {
916                    let count = count_inner.clone();
917                    async move {
918                        count.fetch_add(1, Ordering::SeqCst);
919                        "created"
920                    }
921                }),
922            )
923            .layer(layer_with_store(store));
924
925        let token = "tok-mp-lower";
926        let ct = "multipart/form-data; boundary=simpleboundary123";
927        let boundary = "simpleboundary123";
928
929        let first = app
930            .clone()
931            .oneshot(multipart_post(ct, boundary, token))
932            .await
933            .unwrap();
934        assert_eq!(first.status(), StatusCode::OK);
935        assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
936
937        let second = app
938            .clone()
939            .oneshot(multipart_post(ct, boundary, token))
940            .await
941            .unwrap();
942        assert_eq!(second.status(), StatusCode::OK);
943        assert_eq!(
944            second
945                .headers()
946                .get(SUBMIT_TOKEN_REPLAYED)
947                .map(|v| v.to_str().unwrap()),
948            Some("true")
949        );
950
951        assert_eq!(
952            count.load(Ordering::SeqCst),
953            1,
954            "handler must run exactly once"
955        );
956    }
957
958    #[tokio::test]
959    async fn mixed_case_multipart_consumes_token_and_replay_short_circuits() {
960        // Media types are case-insensitive (RFC 9110); the Multipart extractor
961        // accepts `Multipart/Form-Data` with a `Boundary=` parameter. The body
962        // scanner must recognize it and consume the token so a replay is caught.
963        // The weird-case boundary value is identical in the header and the body
964        // delimiters, which also proves the boundary VALUE stays case-sensitive.
965        let store: Arc<dyn IdempotencyStore> =
966            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
967        let count = Arc::new(AtomicUsize::new(0));
968        let count_inner = count.clone();
969        let app = Router::new()
970            .route(
971                "/submit",
972                post(move || {
973                    let count = count_inner.clone();
974                    async move {
975                        count.fetch_add(1, Ordering::SeqCst);
976                        "created"
977                    }
978                }),
979            )
980            .layer(layer_with_store(store));
981
982        let token = "tok-mp-mixed";
983        let ct = "Multipart/Form-Data; Boundary=BoUnDaRy-XyZ-123";
984        let boundary = "BoUnDaRy-XyZ-123";
985
986        let first = app
987            .clone()
988            .oneshot(multipart_post(ct, boundary, token))
989            .await
990            .unwrap();
991        assert_eq!(first.status(), StatusCode::OK);
992        assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
993
994        let second = app
995            .clone()
996            .oneshot(multipart_post(ct, boundary, token))
997            .await
998            .unwrap();
999        assert_eq!(second.status(), StatusCode::OK);
1000        assert_eq!(
1001            second
1002                .headers()
1003                .get(SUBMIT_TOKEN_REPLAYED)
1004                .map(|v| v.to_str().unwrap()),
1005            Some("true")
1006        );
1007
1008        assert_eq!(
1009            count.load(Ordering::SeqCst),
1010            1,
1011            "handler must run exactly once"
1012        );
1013    }
1014
1015    #[tokio::test]
1016    async fn quoted_semicolon_boundary_consumes_token_and_replay_short_circuits() {
1017        // A `;` inside a QUOTED boundary parameter is a valid RFC 2046 /
1018        // `mime` restricted quoted char, so `boundary="x;y"` parses to the
1019        // boundary `x;y` in the `multer`/`mime` parser axum's Multipart
1020        // extractor uses downstream. A hand-rolled `split(';')` truncates it
1021        // to `x`, so the guard fails to find/consume `_submit_token` while the
1022        // handler still parses and acts on the form — leaving the request
1023        // REPLAYABLE. The boundary parser must match the extractor's so the
1024        // token is consumed and a replay is short-circuited. Fully lowercase,
1025        // RFC-shaped multipart — no casing trick.
1026        let store: Arc<dyn IdempotencyStore> =
1027            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1028        let count = Arc::new(AtomicUsize::new(0));
1029        let count_inner = count.clone();
1030        let app = Router::new()
1031            .route(
1032                "/submit",
1033                post(move || {
1034                    let count = count_inner.clone();
1035                    async move {
1036                        count.fetch_add(1, Ordering::SeqCst);
1037                        "created"
1038                    }
1039                }),
1040            )
1041            .layer(layer_with_store(store));
1042
1043        let token = "tok-mp-quoted-semi";
1044        let ct = "multipart/form-data; boundary=\"x;y\"";
1045        let boundary = "x;y";
1046
1047        let first = app
1048            .clone()
1049            .oneshot(multipart_post(ct, boundary, token))
1050            .await
1051            .unwrap();
1052        assert_eq!(first.status(), StatusCode::OK);
1053        assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
1054
1055        let second = app
1056            .clone()
1057            .oneshot(multipart_post(ct, boundary, token))
1058            .await
1059            .unwrap();
1060        assert_eq!(second.status(), StatusCode::OK);
1061        assert_eq!(
1062            second
1063                .headers()
1064                .get(SUBMIT_TOKEN_REPLAYED)
1065                .map(|v| v.to_str().unwrap()),
1066            Some("true")
1067        );
1068
1069        assert_eq!(
1070            count.load(Ordering::SeqCst),
1071            1,
1072            "handler must run exactly once"
1073        );
1074    }
1075
1076    #[tokio::test]
1077    async fn uppercase_urlencoded_consumes_token_and_replay_short_circuits() {
1078        // The urlencoded branch has the same casing hole and is fixed by the
1079        // same media-type normalization.
1080        let store: Arc<dyn IdempotencyStore> =
1081            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1082        let count = Arc::new(AtomicUsize::new(0));
1083        let count_inner = count.clone();
1084        let app = Router::new()
1085            .route(
1086                "/submit",
1087                post(move || {
1088                    let count = count_inner.clone();
1089                    async move {
1090                        count.fetch_add(1, Ordering::SeqCst);
1091                        "created"
1092                    }
1093                }),
1094            )
1095            .layer(layer_with_store(store));
1096
1097        let token = "tok-ue-upper";
1098        let make_req = || {
1099            Request::builder()
1100                .method("POST")
1101                .uri("/submit")
1102                .header("Content-Type", "APPLICATION/X-WWW-FORM-URLENCODED")
1103                .body(Body::from(format!("_submit_token={token}&title=hello")))
1104                .unwrap()
1105        };
1106
1107        let first = app.clone().oneshot(make_req()).await.unwrap();
1108        assert_eq!(first.status(), StatusCode::OK);
1109        assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
1110
1111        let second = app.clone().oneshot(make_req()).await.unwrap();
1112        assert_eq!(second.status(), StatusCode::OK);
1113        assert_eq!(
1114            second
1115                .headers()
1116                .get(SUBMIT_TOKEN_REPLAYED)
1117                .map(|v| v.to_str().unwrap()),
1118            Some("true")
1119        );
1120
1121        assert_eq!(
1122            count.load(Ordering::SeqCst),
1123            1,
1124            "handler must run exactly once"
1125        );
1126    }
1127
1128    #[tokio::test]
1129    async fn missing_token_passes_through() {
1130        let store: Arc<dyn IdempotencyStore> =
1131            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1132        let count = Arc::new(AtomicUsize::new(0));
1133        let count_inner = count.clone();
1134        let app = Router::new()
1135            .route(
1136                "/submit",
1137                post(move || {
1138                    let count = count_inner.clone();
1139                    async move {
1140                        count.fetch_add(1, Ordering::SeqCst);
1141                        "ok"
1142                    }
1143                }),
1144            )
1145            .layer(layer_with_store(store));
1146
1147        // No `_submit_token` field — both requests run the handler.
1148        for _ in 0..2 {
1149            let req = Request::builder()
1150                .method("POST")
1151                .uri("/submit")
1152                .header("Content-Type", "application/x-www-form-urlencoded")
1153                .body(Body::from("title=hello"))
1154                .unwrap();
1155            let resp = app.clone().oneshot(req).await.unwrap();
1156            assert_eq!(resp.status(), StatusCode::OK);
1157        }
1158        assert_eq!(count.load(Ordering::SeqCst), 2);
1159    }
1160
1161    #[test]
1162    fn expired_token_re_runs_after_ttl() {
1163        // A very short TTL means the stored record expires and a later submit
1164        // re-runs rather than replaying.
1165        let store = MemoryIdempotencyStore::new(Duration::from_millis(10));
1166        let key = storage_key("ttl-token");
1167        store.set(
1168            &key,
1169            IdempotencyRecord {
1170                status: 200,
1171                headers: Vec::new(),
1172                body: b"first".to_vec(),
1173                metadata: Vec::new(),
1174            },
1175            Vec::new(),
1176            Duration::from_millis(10),
1177        );
1178        assert!(store.get(&key).is_some());
1179        std::thread::sleep(Duration::from_millis(30));
1180        assert!(
1181            store.get(&key).is_none(),
1182            "record must expire after its TTL"
1183        );
1184    }
1185
1186    #[tokio::test]
1187    async fn distinct_from_csrf_replayed_submit_token_short_circuits() {
1188        // Even a request that would pass CSRF is short-circuited when its
1189        // submit token has already been consumed: the guard consults the store,
1190        // not the CSRF cookie/token.
1191        let store: Arc<dyn IdempotencyStore> =
1192            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1193        let count = Arc::new(AtomicUsize::new(0));
1194        let count_inner = count.clone();
1195        let app = Router::new()
1196            .route(
1197                "/submit",
1198                post(move || {
1199                    let count = count_inner.clone();
1200                    async move {
1201                        count.fetch_add(1, Ordering::SeqCst);
1202                        "created"
1203                    }
1204                }),
1205            )
1206            .layer(layer_with_store(store));
1207
1208        let token = "tok-csrf-distinct";
1209        // Each submit carries a valid _csrf field alongside the submit token.
1210        let build = || {
1211            Request::builder()
1212                .method("POST")
1213                .uri("/submit")
1214                .header("Content-Type", "application/x-www-form-urlencoded")
1215                .body(Body::from(format!(
1216                    "_csrf=valid-csrf&_submit_token={token}"
1217                )))
1218                .unwrap()
1219        };
1220        let first = app.clone().oneshot(build()).await.unwrap();
1221        assert_eq!(first.status(), StatusCode::OK);
1222        // A replay with the same submit token (and a still-valid _csrf) is
1223        // short-circuited by the guard.
1224        let second = app.clone().oneshot(build()).await.unwrap();
1225        assert_eq!(
1226            second
1227                .headers()
1228                .get(SUBMIT_TOKEN_REPLAYED)
1229                .map(|v| v.to_str().unwrap()),
1230            Some("true")
1231        );
1232        assert_eq!(count.load(Ordering::SeqCst), 1);
1233    }
1234
1235    /// Success metric (AC #7): 10 identical concurrent POSTs at a scaffolded
1236    /// create endpoint persist exactly ONE row; the replays return the first
1237    /// response without re-running the side effect.
1238    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1239    async fn ten_concurrent_posts_persist_exactly_one_row() {
1240        let store: Arc<dyn IdempotencyStore> =
1241            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1242        // Simulates the durable side effect (a DB insert).
1243        let rows = Arc::new(AtomicUsize::new(0));
1244        let rows_inner = rows.clone();
1245        let app = Router::new()
1246            .route(
1247                "/posts",
1248                post(move || {
1249                    let rows = rows_inner.clone();
1250                    async move {
1251                        // Simulate real work so requests genuinely overlap.
1252                        tokio::time::sleep(Duration::from_millis(20)).await;
1253                        rows.fetch_add(1, Ordering::SeqCst);
1254                        (StatusCode::SEE_OTHER, [("location", "/posts")], "")
1255                    }
1256                }),
1257            )
1258            .layer(layer_with_store(store));
1259
1260        let token = "concurrent-token";
1261        let mut handles = Vec::new();
1262        for _ in 0..10 {
1263            let app = app.clone();
1264            handles.push(tokio::spawn(async move {
1265                let req = Request::builder()
1266                    .method("POST")
1267                    .uri("/posts")
1268                    .header("Content-Type", "application/x-www-form-urlencoded")
1269                    .body(Body::from(format!("_submit_token={token}&title=hello")))
1270                    .unwrap();
1271                app.oneshot(req).await.unwrap().status()
1272            }));
1273        }
1274
1275        let mut succeeded = 0;
1276        let mut conflicts = 0;
1277        for h in handles {
1278            let status = h.await.unwrap();
1279            if status == StatusCode::SEE_OTHER {
1280                succeeded += 1;
1281            } else if status == StatusCode::CONFLICT {
1282                conflicts += 1;
1283            } else {
1284                panic!("unexpected status: {status}");
1285            }
1286        }
1287
1288        assert_eq!(
1289            rows.load(Ordering::SeqCst),
1290            1,
1291            "exactly one row must be persisted from 10 concurrent identical POSTs"
1292        );
1293        assert_eq!(
1294            succeeded + conflicts,
1295            10,
1296            "every request must either return the first response or be rejected in-flight"
1297        );
1298        assert!(
1299            succeeded >= 1,
1300            "at least the first submission must succeed and be replayable"
1301        );
1302    }
1303
1304    /// Regression: when the FIRST body chunk already exceeds `max_scan_bytes`
1305    /// (e.g. an upstream middleware rebuilt the buffered form as a single
1306    /// `Body::from(bytes)`), the leading `_submit_token` must still be scanned
1307    /// from that over-limit chunk — the guard fires — while the handler still
1308    /// receives the complete, untruncated body.
1309    #[tokio::test]
1310    async fn over_limit_first_chunk_detects_leading_token_and_preserves_body() {
1311        let store: Arc<dyn IdempotencyStore> =
1312            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1313        let count = Arc::new(AtomicUsize::new(0));
1314        let count_inner = count.clone();
1315        let seen_len = Arc::new(AtomicUsize::new(0));
1316        let seen_len_inner = seen_len.clone();
1317        // The handler consumes the whole body so we can assert nothing was
1318        // truncated on its way through the scan.
1319        let app = Router::new()
1320            .route(
1321                "/submit",
1322                post(move |body: Bytes| {
1323                    let count = count_inner.clone();
1324                    let seen_len = seen_len_inner.clone();
1325                    async move {
1326                        count.fetch_add(1, Ordering::SeqCst);
1327                        seen_len.store(body.len(), Ordering::SeqCst);
1328                        "created"
1329                    }
1330                }),
1331            )
1332            // A tiny scan cap the very first chunk already blows past.
1333            .layer(layer_with_store(store).with_max_scan_bytes(64));
1334
1335        let token = "over-limit-token";
1336        // `_submit_token` sits at the front (well within the 64-byte cap); a
1337        // large filler pushes the single chunk far over the cap.
1338        let filler = "x".repeat(4096);
1339        let full_body = format!("_submit_token={token}&title={filler}");
1340        let full_len = full_body.len();
1341        assert!(full_len > 64, "body must exceed the scan cap for this test");
1342        let make = || {
1343            Request::builder()
1344                .method("POST")
1345                .uri("/submit")
1346                .header("Content-Type", "application/x-www-form-urlencoded")
1347                .body(Body::from(full_body.clone()))
1348                .unwrap()
1349        };
1350
1351        // First submit: the guard scans the leading bytes of the over-limit
1352        // chunk, runs the handler once, and the handler sees the full body.
1353        let first = app.clone().oneshot(make()).await.unwrap();
1354        assert_eq!(first.status(), StatusCode::OK);
1355        assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
1356        assert_eq!(
1357            seen_len.load(Ordering::SeqCst),
1358            full_len,
1359            "handler must receive the complete body, not just the scanned prefix"
1360        );
1361
1362        // Second submit with the same token replays — proving the leading token
1363        // was detected despite the over-limit first chunk.
1364        let second = app.clone().oneshot(make()).await.unwrap();
1365        assert_eq!(
1366            second
1367                .headers()
1368                .get(SUBMIT_TOKEN_REPLAYED)
1369                .map(|v| v.to_str().unwrap()),
1370            Some("true"),
1371            "an over-limit first chunk with a leading token must still be guarded"
1372        );
1373        assert_eq!(
1374            count.load(Ordering::SeqCst),
1375            1,
1376            "handler must run exactly once"
1377        );
1378    }
1379
1380    /// Store stub whose consumed-token persistence always fails, while locking
1381    /// and reads behave like the in-memory store. Mirrors the idempotency
1382    /// layer's failing-backend tests so we can prove fail-closed semantics.
1383    struct FailingSetStore {
1384        inner: MemoryIdempotencyStore,
1385    }
1386
1387    impl FailingSetStore {
1388        fn new() -> Self {
1389            Self {
1390                inner: MemoryIdempotencyStore::new(Duration::from_secs(600)),
1391            }
1392        }
1393    }
1394
1395    impl IdempotencyStore for FailingSetStore {
1396        fn get(&self, key: &str) -> Option<IdempotencyEntry> {
1397            self.inner.get(key)
1398        }
1399
1400        fn set(&self, _key: &str, _record: IdempotencyRecord, _body_hash: Vec<u8>, _ttl: Duration) {
1401            // No-op: the fallible `try_set` path is what the guard uses; this
1402            // simulated backend never persists so retries cannot see a record.
1403        }
1404
1405        fn try_set(
1406            &self,
1407            _key: &str,
1408            _record: IdempotencyRecord,
1409            _body_hash: Vec<u8>,
1410            _ttl: Duration,
1411        ) -> Result<(), IdempotencyStoreError> {
1412            Err(IdempotencyStoreError::backend(
1413                "simulated consumed-token persistence failure",
1414            ))
1415        }
1416
1417        fn try_lock(&self, key: &str, lock_ttl: Duration) -> bool {
1418            self.inner.try_lock(key, lock_ttl)
1419        }
1420
1421        fn unlock(&self, key: &str) {
1422            self.inner.unlock(key);
1423        }
1424    }
1425
1426    /// Finding C (fail closed): when the store cannot persist the consumed-token
1427    /// record after the handler already committed its mutation, the guard must
1428    /// NOT return a bare success (which a retry would re-run). It fails closed
1429    /// exactly like `IdempotencyLayer`: the first request surfaces `503`, and
1430    /// the in-flight lock stays held so a retry with the same token is rejected
1431    /// with an in-flight `409` instead of re-running the handler.
1432    #[tokio::test]
1433    async fn persistence_failure_fails_closed_and_holds_lock() {
1434        let store: Arc<dyn IdempotencyStore> = Arc::new(FailingSetStore::new());
1435        let count = Arc::new(AtomicUsize::new(0));
1436        let count_inner = count.clone();
1437        let app = Router::new()
1438            .route(
1439                "/submit",
1440                post(move || {
1441                    let count = count_inner.clone();
1442                    async move {
1443                        count.fetch_add(1, Ordering::SeqCst);
1444                        "created"
1445                    }
1446                }),
1447            )
1448            .layer(layer_with_store(store));
1449
1450        let token = "tok-persist-fail";
1451
1452        // First submit: handler runs once, but the store write fails, so the
1453        // guard fails closed with 503 instead of returning the 200 "created".
1454        let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
1455        assert_eq!(
1456            first.status(),
1457            StatusCode::SERVICE_UNAVAILABLE,
1458            "a persistence failure after handler success must fail closed, not return the success"
1459        );
1460
1461        // Retry with the same token: the in-flight lock is still held (it was
1462        // deliberately not released), so the retry is rejected in-flight and the
1463        // handler does NOT run a second time.
1464        let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
1465        assert_eq!(
1466            second.status(),
1467            StatusCode::CONFLICT,
1468            "a retry after a persistence failure must be rejected in-flight, not re-run"
1469        );
1470
1471        assert_eq!(
1472            count.load(Ordering::SeqCst),
1473            1,
1474            "the handler must run at most once even when persistence fails"
1475        );
1476    }
1477
1478    /// Finding (fail closed on response-stream error): when the handler has
1479    /// already committed its mutation and returned a 2xx, but its response body
1480    /// stream then errors mid-buffer, the guard can neither record the token nor
1481    /// replay the truncated body. It must fail closed exactly like the
1482    /// persistence-failure path: the first request surfaces `500`, no
1483    /// consumed-token record is stored, and the in-flight lock stays held so a
1484    /// retry with the same token is rejected in-flight with a `409` instead of
1485    /// re-running the committed mutation.
1486    #[tokio::test]
1487    async fn response_stream_error_fails_closed_and_holds_lock() {
1488        let store = Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1489        let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
1490        let count = Arc::new(AtomicUsize::new(0));
1491        let count_inner = count.clone();
1492        let app = Router::new()
1493            .route(
1494                "/submit",
1495                post(move || {
1496                    let count = count_inner.clone();
1497                    async move {
1498                        // The handler commits its mutation and returns a 200
1499                        // whose body stream delivers a leading chunk and then
1500                        // errors before EOF — so the replay cache buffering hits
1501                        // `CollectedBody::Errored` after the commit.
1502                        count.fetch_add(1, Ordering::SeqCst);
1503                        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
1504                            Ok(Bytes::from("created")),
1505                            Err(std::io::Error::other("simulated response read failure")),
1506                        ];
1507                        Response::new(Body::from_stream(futures::stream::iter(chunks)))
1508                    }
1509                }),
1510            )
1511            .layer(layer_with_store(store_dyn));
1512
1513        let token = "tok-resp-stream-fail";
1514
1515        // First submit: handler runs once and commits, but its response body
1516        // stream errors while buffering, so the guard fails closed with 500
1517        // instead of returning a truncated success.
1518        let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
1519        assert_eq!(
1520            first.status(),
1521            StatusCode::INTERNAL_SERVER_ERROR,
1522            "a response-stream error after handler commit must fail closed, not return a truncated body"
1523        );
1524
1525        // No consumed-token record was persisted (the body never fully buffered).
1526        let key = storage_key(token);
1527        assert!(
1528            store.get(&key).is_none(),
1529            "a response-stream error must not persist a consumed-token record"
1530        );
1531
1532        // Retry with the same token: the in-flight lock is still held (it was
1533        // deliberately not released), so the retry is rejected in-flight and the
1534        // handler does NOT run a second time.
1535        let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
1536        assert_eq!(
1537            second.status(),
1538            StatusCode::CONFLICT,
1539            "a retry after a response-stream error must be rejected in-flight, not re-run"
1540        );
1541
1542        assert_eq!(
1543            count.load(Ordering::SeqCst),
1544            1,
1545            "the handler must run at most once even when the response stream errors"
1546        );
1547    }
1548
1549    /// Companion to `response_stream_error_fails_closed_and_holds_lock`: when the
1550    /// erroring response carries a NON-success status (e.g. `422`), the handler
1551    /// did not commit a cacheable mutation, so — matching the `Full` branch's
1552    /// clean non-success path — no record is stored and the in-flight lock is
1553    /// released. An immediate resubmit with the same token must therefore be
1554    /// retryable: it re-acquires the lock and re-runs the handler rather than
1555    /// getting a spurious `409` in-flight conflict for the full `in_flight_ttl`.
1556    #[tokio::test]
1557    async fn response_stream_error_on_non_success_releases_lock() {
1558        let store = Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1559        let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
1560        let count = Arc::new(AtomicUsize::new(0));
1561        let count_inner = count.clone();
1562        let app = Router::new()
1563            .route(
1564                "/submit",
1565                post(move || {
1566                    let count = count_inner.clone();
1567                    async move {
1568                        // The handler returns a 422 (validation failure — no
1569                        // committed, cacheable mutation) whose body stream
1570                        // delivers a leading chunk and then errors before EOF,
1571                        // so the replay cache buffering hits
1572                        // `CollectedBody::Errored` on a non-success status.
1573                        count.fetch_add(1, Ordering::SeqCst);
1574                        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
1575                            Ok(Bytes::from("invalid")),
1576                            Err(std::io::Error::other("simulated response read failure")),
1577                        ];
1578                        Response::builder()
1579                            .status(StatusCode::UNPROCESSABLE_ENTITY)
1580                            .body(Body::from_stream(futures::stream::iter(chunks)))
1581                            .unwrap()
1582                    }
1583                }),
1584            )
1585            .layer(layer_with_store(store_dyn));
1586
1587        let token = "tok-resp-stream-fail-422";
1588
1589        // First submit: handler runs once and returns a 422 whose body stream
1590        // errors while buffering, so the guard surfaces 500 but — because the
1591        // status is non-success — releases the in-flight lock.
1592        let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
1593        assert_eq!(
1594            first.status(),
1595            StatusCode::INTERNAL_SERVER_ERROR,
1596            "a response-stream error must fail closed with 500 rather than a truncated body"
1597        );
1598
1599        // No consumed-token record was persisted (non-success, and the body
1600        // never fully buffered).
1601        let key = storage_key(token);
1602        assert!(
1603            store.get(&key).is_none(),
1604            "a non-success response-stream error must not persist a consumed-token record"
1605        );
1606
1607        // Retry with the same token: the lock was released, so the retry is NOT
1608        // rejected in-flight — it re-acquires the lock and re-runs the handler.
1609        let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
1610        assert_ne!(
1611            second.status(),
1612            StatusCode::CONFLICT,
1613            "a retry after a non-success response-stream error must be retryable, not a 409 in-flight conflict"
1614        );
1615
1616        assert_eq!(
1617            count.load(Ordering::SeqCst),
1618            2,
1619            "the handler must re-run on retry once the non-success lock is released"
1620        );
1621    }
1622
1623    /// Store stub whose consumed-token lookups can be flipped to fail. Locking
1624    /// and writes behave like the in-memory store so a token can first be
1625    /// consumed, then have its lookup fail on replay. Mirrors the write-side
1626    /// `FailingSetStore` to prove the READ path also fails closed.
1627    struct FailingGetStore {
1628        inner: MemoryIdempotencyStore,
1629        fail_reads: std::sync::atomic::AtomicBool,
1630    }
1631
1632    impl FailingGetStore {
1633        fn new() -> Self {
1634            Self {
1635                inner: MemoryIdempotencyStore::new(Duration::from_secs(600)),
1636                fail_reads: std::sync::atomic::AtomicBool::new(false),
1637            }
1638        }
1639    }
1640
1641    impl IdempotencyStore for FailingGetStore {
1642        fn get(&self, key: &str) -> Option<IdempotencyEntry> {
1643            // The infallible path collapses a read failure to a cache miss —
1644            // exactly the fail-open behaviour the guard must NOT rely on. It
1645            // uses `try_get` instead, so this stays here only for the trait.
1646            if self.fail_reads.load(Ordering::SeqCst) {
1647                None
1648            } else {
1649                self.inner.get(key)
1650            }
1651        }
1652
1653        fn try_get(&self, key: &str) -> Result<Option<IdempotencyEntry>, IdempotencyStoreError> {
1654            if self.fail_reads.load(Ordering::SeqCst) {
1655                Err(IdempotencyStoreError::backend(
1656                    "simulated consumed-token lookup failure",
1657                ))
1658            } else {
1659                self.inner.try_get(key)
1660            }
1661        }
1662
1663        fn set(&self, key: &str, record: IdempotencyRecord, body_hash: Vec<u8>, ttl: Duration) {
1664            self.inner.set(key, record, body_hash, ttl);
1665        }
1666
1667        fn try_lock(&self, key: &str, lock_ttl: Duration) -> bool {
1668            self.inner.try_lock(key, lock_ttl)
1669        }
1670
1671        fn unlock(&self, key: &str) {
1672            self.inner.unlock(key);
1673        }
1674    }
1675
1676    /// Finding (fail closed on read): once a token has been consumed, a backend
1677    /// read failure on the consumed-token lookup must NOT collapse to a cache
1678    /// miss that acquires a fresh lock and re-runs the mutation. The guard uses
1679    /// the fallible `try_get`, so a lookup error fails closed with `503` and the
1680    /// handler is not re-run — matching `IdempotencyService`'s lookup path.
1681    #[tokio::test]
1682    async fn read_failure_fails_closed_and_does_not_rerun() {
1683        let store = Arc::new(FailingGetStore::new());
1684        let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
1685        let count = Arc::new(AtomicUsize::new(0));
1686        let count_inner = count.clone();
1687        let app = Router::new()
1688            .route(
1689                "/submit",
1690                post(move || {
1691                    let count = count_inner.clone();
1692                    async move {
1693                        count.fetch_add(1, Ordering::SeqCst);
1694                        "created"
1695                    }
1696                }),
1697            )
1698            .layer(layer_with_store(store_dyn));
1699
1700        let token = "tok-read-fail";
1701
1702        // First submit consumes the token and records the response.
1703        let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
1704        assert_eq!(first.status(), StatusCode::OK);
1705        assert_eq!(count.load(Ordering::SeqCst), 1);
1706
1707        // Now make consumed-token lookups fail. A replay must fail closed with
1708        // 503 rather than treating the errored lookup as a miss and re-running.
1709        store.fail_reads.store(true, Ordering::SeqCst);
1710        let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
1711        assert_eq!(
1712            second.status(),
1713            StatusCode::SERVICE_UNAVAILABLE,
1714            "a consumed-token lookup failure must fail closed, not re-run the handler"
1715        );
1716        assert_eq!(
1717            count.load(Ordering::SeqCst),
1718            1,
1719            "the handler must not re-run when the consumed-token lookup fails"
1720        );
1721    }
1722
1723    /// Finding J (preserve body read errors): when the request body stream
1724    /// yields an error mid-read while the guard is scanning for the token, the
1725    /// request must FAIL (400) rather than reach the handler with a silently
1726    /// truncated form. A leading `_submit_token` followed by a lost tail must
1727    /// never be forwarded as if the short read had succeeded.
1728    #[tokio::test]
1729    async fn body_read_error_rejects_request_and_does_not_reach_handler() {
1730        let store: Arc<dyn IdempotencyStore> =
1731            Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1732        let count = Arc::new(AtomicUsize::new(0));
1733        let count_inner = count.clone();
1734        let app = Router::new()
1735            .route(
1736                "/submit",
1737                post(move |_body: Bytes| {
1738                    let count = count_inner.clone();
1739                    async move {
1740                        count.fetch_add(1, Ordering::SeqCst);
1741                        "created"
1742                    }
1743                }),
1744            )
1745            .layer(layer_with_store(store));
1746
1747        // A urlencoded body whose stream delivers a leading chunk (with the
1748        // token near the front) and then errors before EOF.
1749        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
1750            Ok(Bytes::from("_submit_token=tok-read-err&ti")),
1751            Err(std::io::Error::other("simulated body read failure")),
1752        ];
1753        let body = Body::from_stream(futures::stream::iter(chunks));
1754        let req = Request::builder()
1755            .method("POST")
1756            .uri("/submit")
1757            .header("Content-Type", "application/x-www-form-urlencoded")
1758            .body(body)
1759            .unwrap();
1760
1761        let resp = app.oneshot(req).await.unwrap();
1762        assert_eq!(
1763            resp.status(),
1764            StatusCode::BAD_REQUEST,
1765            "a mid-read body-stream error must reject the request, not forward a truncated form"
1766        );
1767        assert_eq!(
1768            count.load(Ordering::SeqCst),
1769            0,
1770            "the handler must never see a truncated body when the stream errors mid-read"
1771        );
1772    }
1773
1774    /// Store that records the TTL handed to `try_lock` (the in-flight lock
1775    /// duration) versus `set` (the consumed-record replay window), so a test can
1776    /// assert the two are governed by SEPARATE config knobs. Locking and storage
1777    /// otherwise behave like the in-memory store.
1778    struct TtlRecordingStore {
1779        inner: MemoryIdempotencyStore,
1780        lock_ttl: std::sync::Mutex<Option<Duration>>,
1781        set_ttl: std::sync::Mutex<Option<Duration>>,
1782    }
1783
1784    impl TtlRecordingStore {
1785        fn new() -> Self {
1786            Self {
1787                inner: MemoryIdempotencyStore::new(Duration::from_secs(600)),
1788                lock_ttl: std::sync::Mutex::new(None),
1789                set_ttl: std::sync::Mutex::new(None),
1790            }
1791        }
1792    }
1793
1794    impl IdempotencyStore for TtlRecordingStore {
1795        fn get(&self, key: &str) -> Option<IdempotencyEntry> {
1796            self.inner.get(key)
1797        }
1798
1799        fn set(&self, key: &str, record: IdempotencyRecord, body_hash: Vec<u8>, ttl: Duration) {
1800            *self.set_ttl.lock().unwrap() = Some(ttl);
1801            self.inner.set(key, record, body_hash, ttl);
1802        }
1803
1804        fn try_lock(&self, key: &str, lock_ttl: Duration) -> bool {
1805            *self.lock_ttl.lock().unwrap() = Some(lock_ttl);
1806            self.inner.try_lock(key, lock_ttl)
1807        }
1808
1809        fn unlock(&self, key: &str) {
1810            self.inner.unlock(key);
1811        }
1812    }
1813
1814    /// Finding L (decouple in-flight lock TTL from replay TTL): the in-flight
1815    /// submission lock must be governed by `in_flight_ttl_secs`, NOT by the
1816    /// `ttl_secs` replay window. An operator who lowers `ttl_secs` must not
1817    /// shorten how long an active submission is excluded from re-entry — else a
1818    /// create/update that outruns the shrunken TTL would let a retry with the
1819    /// same token acquire a FRESH lock before the consumed record lands and both
1820    /// requests execute the mutation.
1821    #[tokio::test]
1822    async fn in_flight_lock_ttl_is_decoupled_from_replay_ttl() {
1823        let store = Arc::new(TtlRecordingStore::new());
1824        let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
1825        // A deliberately tiny replay window paired with a normal in-flight TTL.
1826        let config = SubmitTokenConfig {
1827            enabled: true,
1828            ttl_secs: 1,
1829            in_flight_ttl_secs: 86_400,
1830            ..Default::default()
1831        };
1832        let app = Router::new()
1833            .route("/submit", post(|| async { "created" }))
1834            .layer(SubmitTokenLayer::new(store_dyn, &config));
1835
1836        let resp = app.oneshot(urlencoded_post("tok-decoupled")).await.unwrap();
1837        assert_eq!(resp.status(), StatusCode::OK);
1838
1839        // The lock excluding a concurrent retry lives for the full
1840        // in_flight_ttl_secs — lowering ttl_secs does NOT open the re-entry gap.
1841        let lock_ttl = store
1842            .lock_ttl
1843            .lock()
1844            .unwrap()
1845            .expect("the guard must acquire an in-flight lock");
1846        assert_eq!(
1847            lock_ttl,
1848            Duration::from_secs(86_400),
1849            "the in-flight lock TTL must come from in_flight_ttl_secs, not ttl_secs"
1850        );
1851        // ...while the consumed-record replay window still tracks ttl_secs.
1852        let set_ttl = store
1853            .set_ttl
1854            .lock()
1855            .unwrap()
1856            .expect("the guard must record the consumed token");
1857        assert_eq!(
1858            set_ttl,
1859            Duration::from_secs(1),
1860            "the consumed-record replay TTL must still come from ttl_secs"
1861        );
1862    }
1863
1864    /// Finding L (behavioural): even with a tiny replay `ttl_secs`, a token whose
1865    /// submission is still in-flight (lock held, consumed record not yet stored)
1866    /// excludes a retry with a `409` rather than letting it re-run the mutation.
1867    #[tokio::test]
1868    async fn in_flight_token_excludes_retry_with_small_replay_ttl() {
1869        let store = Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
1870        let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
1871        let config = SubmitTokenConfig {
1872            enabled: true,
1873            ttl_secs: 1,
1874            in_flight_ttl_secs: 86_400,
1875            ..Default::default()
1876        };
1877        let count = Arc::new(AtomicUsize::new(0));
1878        let count_inner = count.clone();
1879        let app = Router::new()
1880            .route(
1881                "/submit",
1882                post(move || {
1883                    let count = count_inner.clone();
1884                    async move {
1885                        count.fetch_add(1, Ordering::SeqCst);
1886                        "created"
1887                    }
1888                }),
1889            )
1890            .layer(SubmitTokenLayer::new(store_dyn, &config));
1891
1892        // Simulate the first request being mid-flight: hold the in-flight lock
1893        // for the token key with the normal in-flight TTL, without recording a
1894        // consumed response yet.
1895        let token = "tok-inflight";
1896        let key = storage_key(token);
1897        assert!(store.try_lock(&key, Duration::from_secs(86_400)));
1898
1899        // A concurrent retry with the same token is rejected in-flight and never
1900        // reaches the handler.
1901        let resp = app.oneshot(urlencoded_post(token)).await.unwrap();
1902        assert_eq!(
1903            resp.status(),
1904            StatusCode::CONFLICT,
1905            "a retry against a still-in-flight token must be excluded, not re-run"
1906        );
1907        assert_eq!(
1908            count.load(Ordering::SeqCst),
1909            0,
1910            "the handler must not run for a retry held out by the in-flight lock"
1911        );
1912    }
1913}