1use 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
61const SUBMIT_TOKEN_REPLAYED: &str = "x-submit-token-replayed";
63
64const MAX_CACHEABLE_RESPONSE_BODY: usize = 10 * 1024 * 1024; const MAX_SCAN_BYTES_CAP: usize = 2 * 1024 * 1024; #[derive(Clone, Debug)]
78pub struct SubmitFormField(pub String);
79
80#[derive(Clone, Debug)]
87pub struct SubmitToken(String);
88
89impl SubmitToken {
90 #[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
188fn 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
198fn 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
208fn 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
274enum CollectedBody {
276 Full(Bytes),
278 Oversized { prefix: Bytes, body: Body },
284 Errored(axum::Error),
290}
291
292async 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 None => break,
300 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 let mut prefix_buf = buf.clone();
315 prefix_buf.extend_from_slice(&chunk[..remaining]);
316 let prefix = Bytes::from(prefix_buf);
317 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
337async 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 let is_urlencoded = content_type
358 .trim_start()
359 .to_ascii_lowercase()
360 .starts_with("application/x-www-form-urlencoded");
361 let boundary = multer::parse_boundary(content_type).ok();
372 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
391fn 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
439fn 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
449fn 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
461struct 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
472impl 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#[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 #[must_use]
511 pub fn new(store: Arc<dyn IdempotencyStore>, config: &SubmitTokenConfig) -> Self {
512 let ttl = Duration::from_secs(config.ttl_secs);
513 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 #[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 #[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#[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 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 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 return inner.call(req).await;
634 };
635
636 let key = storage_key(&token);
637
638 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 if !settings.store.try_lock(&key, settings.in_flight_ttl) {
659 return Ok(in_flight_conflict_response());
660 }
661
662 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
690async 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 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 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 settings.store.unlock(key);
739 Response::from_parts(parts, body)
740 }
741 CollectedBody::Errored(error) => {
742 let status = parts.status.as_u16();
743 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 #[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 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 .layer(layer_with_store(store).with_max_scan_bytes(64));
1334
1335 let token = "over-limit-token";
1336 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 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 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 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 }
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 #[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 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 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 #[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 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 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 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 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 #[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 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 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 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 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 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 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 #[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 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 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 #[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 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 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 #[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 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 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 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 #[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 let token = "tok-inflight";
1896 let key = storage_key(token);
1897 assert!(store.try_lock(&key, Duration::from_secs(86_400)));
1898
1899 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}