use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use axum::body::{Body, Bytes};
use axum::extract::{FromRequestParts, OptionalFromRequestParts};
use axum::http::{HeaderMap, Method, Request, Response, StatusCode};
use futures::StreamExt as _;
use sha2::Digest as _;
use tower::{Layer, Service};
use uuid::Uuid;
use super::config::SubmitTokenConfig;
use crate::idempotency::{IdempotencyRecord, IdempotencyStore};
const SUBMIT_TOKEN_REPLAYED: &str = "x-submit-token-replayed";
const MAX_CACHEABLE_RESPONSE_BODY: usize = 10 * 1024 * 1024;
const MAX_SCAN_BYTES_CAP: usize = 2 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct SubmitFormField(pub String);
#[derive(Clone, Debug)]
pub struct SubmitToken(String);
impl SubmitToken {
#[must_use]
pub fn token(&self) -> &str {
&self.0
}
#[cfg(test)]
pub(crate) const fn new(token: String) -> Self {
Self(token)
}
}
impl std::fmt::Display for SubmitToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl<S> FromRequestParts<S> for SubmitToken
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
parts.extensions.get::<Self>().cloned().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Submit token not found in request extensions. Is SubmitTokenLayer enabled?",
))
}
}
impl<S> OptionalFromRequestParts<S> for SubmitToken
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Option<Self>, Self::Rejection> {
Ok(parts.extensions.get::<Self>().cloned())
}
}
impl<S> FromRequestParts<S> for SubmitFormField
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
parts.extensions.get::<Self>().cloned().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Submit form field not found in request extensions. Is SubmitTokenLayer enabled?",
))
}
}
impl<S> OptionalFromRequestParts<S> for SubmitFormField
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Option<Self>, Self::Rejection> {
Ok(parts.extensions.get::<Self>().cloned())
}
}
const fn is_mutating_method(method: &Method) -> bool {
matches!(
*method,
Method::POST | Method::PUT | Method::PATCH | Method::DELETE
)
}
fn hex_lower(bytes: impl AsRef<[u8]>) -> String {
bytes.as_ref().iter().fold(
String::with_capacity(bytes.as_ref().len() * 2),
|mut out, byte| {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
out
},
)
}
fn storage_key(token: &str) -> String {
let mut hasher = sha2::Sha256::new();
hasher.update(b"autumn.submit_token:v1:");
hasher.update(token.as_bytes());
format!("submit:{}", hex_lower(hasher.finalize()))
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() {
return Some(0);
}
haystack.windows(needle.len()).position(|w| w == needle)
}
fn scan_multipart_field<'a>(bytes: &'a [u8], boundary: &str, field_name: &str) -> Option<&'a str> {
let delimiter = format!("--{boundary}");
let delim = delimiter.as_bytes();
let end_marker = format!("\r\n{delimiter}");
let end_bytes = end_marker.as_bytes();
let mut pos = 0;
loop {
let rel = find_bytes(&bytes[pos..], delim)?;
pos += rel + delim.len();
match bytes.get(pos..pos + 2) {
Some(b"\r\n") => pos += 2,
_ => break,
}
let header_end = find_bytes(&bytes[pos..], b"\r\n\r\n")?;
let headers = std::str::from_utf8(&bytes[pos..pos + header_end]).ok()?;
let value_start = pos + header_end + 4;
let is_match = headers.lines().any(|line| {
if !line
.to_ascii_lowercase()
.starts_with("content-disposition:")
{
return false;
}
line.split(';').skip(1).any(|attr| {
attr.trim()
.strip_prefix("name=")
.map(|v| v.trim_matches('"'))
== Some(field_name)
})
});
if is_match {
let end = find_bytes(&bytes[value_start..], end_bytes)
.map_or(bytes.len(), |i| value_start + i);
return std::str::from_utf8(&bytes[value_start..end]).ok();
}
let next = find_bytes(&bytes[value_start..], end_bytes)?;
pos = value_start + next + 2;
}
None
}
fn scan_for_token(
bytes: &[u8],
is_urlencoded: bool,
boundary: Option<&str>,
field: &str,
) -> Option<String> {
if is_urlencoded {
url::form_urlencoded::parse(bytes)
.find(|(key, _)| key == field)
.map(|(_, value)| value.into_owned())
} else if let Some(boundary) = boundary {
scan_multipart_field(bytes, boundary, field).map(str::to_owned)
} else {
None
}
}
enum CollectedBody {
Full(Bytes),
Oversized { prefix: Bytes, body: Body },
Errored(axum::Error),
}
async fn collect_body(body: Body, limit: usize) -> CollectedBody {
let mut buf = Vec::<u8>::new();
let mut stream = body.into_data_stream();
loop {
match stream.next().await {
None => break,
Some(Err(err)) => return CollectedBody::Errored(err),
Some(Ok(chunk)) => {
let remaining = limit.saturating_sub(buf.len());
if chunk.len() > remaining {
let mut prefix_buf = buf.clone();
prefix_buf.extend_from_slice(&chunk[..remaining]);
let prefix = Bytes::from(prefix_buf);
let mut leading = Vec::with_capacity(2);
if !buf.is_empty() {
leading.push(Ok::<Bytes, axum::Error>(Bytes::from(buf)));
}
leading.push(Ok::<Bytes, axum::Error>(chunk));
let body = Body::from_stream(futures::stream::iter(leading).chain(stream));
return CollectedBody::Oversized { prefix, body };
}
buf.extend_from_slice(&chunk);
}
}
}
CollectedBody::Full(Bytes::from(buf))
}
async fn extract_submitted_token(
req: Request<Body>,
field: &str,
max_scan_bytes: usize,
) -> Result<(Option<String>, Request<Body>), axum::Error> {
let (parts, body) = req.into_parts();
let content_type = parts
.headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
let is_urlencoded = content_type
.trim_start()
.to_ascii_lowercase()
.starts_with("application/x-www-form-urlencoded");
let boundary = multer::parse_boundary(content_type).ok();
if !is_urlencoded && boundary.is_none() {
return Ok((None, Request::from_parts(parts, body)));
}
match collect_body(body, max_scan_bytes).await {
CollectedBody::Full(bytes) => {
let token = scan_for_token(&bytes, is_urlencoded, boundary.as_deref(), field);
Ok((token, Request::from_parts(parts, Body::from(bytes))))
}
CollectedBody::Oversized { prefix, body } => {
let token = scan_for_token(&prefix, is_urlencoded, boundary.as_deref(), field);
Ok((token, Request::from_parts(parts, body)))
}
CollectedBody::Errored(err) => Err(err),
}
}
fn replay_headers(headers: &HeaderMap) -> Vec<(String, Vec<u8>)> {
const SKIP: &[&str] = &[
"connection",
"transfer-encoding",
"keep-alive",
"upgrade",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"set-cookie",
SUBMIT_TOKEN_REPLAYED,
];
headers
.iter()
.filter(|(name, _)| !SKIP.contains(&name.as_str()))
.map(|(name, value)| (name.to_string(), value.as_bytes().to_vec()))
.collect()
}
fn replay_response(record: &IdempotencyRecord) -> Response<Body> {
let mut builder = Response::builder().status(record.status);
for (name, value) in &record.headers {
builder = builder.header(name.as_str(), value.as_slice());
}
builder
.header(SUBMIT_TOKEN_REPLAYED, "true")
.body(Body::from(record.body.clone()))
.unwrap_or_else(|_| {
let mut resp = Response::new(Body::empty());
*resp.status_mut() =
StatusCode::from_u16(record.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
resp
})
}
fn in_flight_conflict_response() -> Response<Body> {
Response::builder()
.status(StatusCode::CONFLICT)
.header("retry-after", "1")
.body(Body::from(
"this form submission is already being processed; retry after 1 second",
))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
fn body_read_error_response() -> Response<Body> {
Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("could not read the request body"))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
fn response_read_error_response() -> Response<Body> {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("could not read the response body"))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
struct SubmitTokenSettings {
store: Arc<dyn IdempotencyStore>,
field_name: String,
ttl: Duration,
in_flight_ttl: Duration,
exempt_paths: Vec<String>,
max_scan_bytes: usize,
}
impl Clone for SubmitTokenSettings {
fn clone(&self) -> Self {
Self {
store: Arc::clone(&self.store),
field_name: self.field_name.clone(),
ttl: self.ttl,
in_flight_ttl: self.in_flight_ttl,
exempt_paths: self.exempt_paths.clone(),
max_scan_bytes: self.max_scan_bytes,
}
}
}
#[derive(Clone)]
pub struct SubmitTokenLayer {
settings: Arc<SubmitTokenSettings>,
}
impl std::fmt::Debug for SubmitTokenLayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubmitTokenLayer")
.field("field_name", &self.settings.field_name)
.field("ttl", &self.settings.ttl)
.finish_non_exhaustive()
}
}
impl SubmitTokenLayer {
#[must_use]
pub fn new(store: Arc<dyn IdempotencyStore>, config: &SubmitTokenConfig) -> Self {
let ttl = Duration::from_secs(config.ttl_secs);
let in_flight_ttl = Duration::from_secs(config.in_flight_ttl_secs);
Self {
settings: Arc::new(SubmitTokenSettings {
store,
field_name: config.field_name.clone(),
ttl,
in_flight_ttl,
exempt_paths: config.exempt_paths.clone(),
max_scan_bytes: MAX_SCAN_BYTES_CAP,
}),
}
}
#[must_use]
pub fn with_max_scan_bytes(mut self, n: usize) -> Self {
Arc::make_mut(&mut self.settings).max_scan_bytes = n.min(MAX_SCAN_BYTES_CAP);
self
}
#[must_use]
pub fn with_exempt_path(mut self, path: impl Into<String>) -> Self {
Arc::make_mut(&mut self.settings)
.exempt_paths
.push(path.into());
self
}
}
impl<S> Layer<S> for SubmitTokenLayer {
type Service = SubmitTokenService<S>;
fn layer(&self, inner: S) -> Self::Service {
SubmitTokenService {
inner,
settings: Arc::clone(&self.settings),
}
}
}
#[derive(Clone)]
pub struct SubmitTokenService<S> {
inner: S,
settings: Arc<SubmitTokenSettings>,
}
impl<S> Service<Request<Body>> for SubmitTokenService<S>
where
S: Service<Request<Body>, Response = Response<Body>, Error = std::convert::Infallible>
+ Clone
+ Send
+ 'static,
S::Future: Send + 'static,
{
type Response = Response<Body>;
type Error = std::convert::Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<Body>) -> Self::Future {
let minted = Uuid::new_v4().to_string();
req.extensions_mut().insert(SubmitToken(minted));
req.extensions_mut()
.insert(SubmitFormField(self.settings.field_name.clone()));
let clean = crate::security::path::clean_path(req.uri().path());
let path = clean.as_str();
let is_exempt = self.settings.exempt_paths.iter().any(|prefix| {
if path == prefix {
true
} else if let Some(stripped) = path.strip_prefix(prefix) {
prefix.ends_with('/') || stripped.starts_with('/')
} else {
false
}
});
let is_guarded = !is_exempt && is_mutating_method(req.method());
let settings = Arc::clone(&self.settings);
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
Box::pin(async move {
if !is_guarded {
return inner.call(req).await;
}
let (submitted, req) =
match extract_submitted_token(req, &settings.field_name, settings.max_scan_bytes)
.await
{
Ok(pair) => pair,
Err(error) => {
tracing::warn!(
error = %error,
"Submit-token body scan failed on a read error; rejecting the request"
);
return Ok(body_read_error_response());
}
};
let Some(token) = submitted.filter(|t| !t.is_empty()) else {
return inner.call(req).await;
};
let key = storage_key(&token);
match settings.store.try_get(&key) {
Ok(Some(entry)) => return Ok(replay_response(&entry.record)),
Ok(None) => {}
Err(error) => {
tracing::error!(
error = %error,
"Submit-token consumed-token lookup failed; failing closed"
);
return Ok(crate::idempotency::persistence_failed_response());
}
}
if !settings.store.try_lock(&key, settings.in_flight_ttl) {
return Ok(in_flight_conflict_response());
}
match settings.store.try_get(&key) {
Ok(Some(entry)) => {
settings.store.unlock(&key);
return Ok(replay_response(&entry.record));
}
Ok(None) => {}
Err(error) => {
tracing::error!(
error = %error,
"Submit-token consumed-token lookup failed after lock acquisition; failing closed"
);
return Ok(crate::idempotency::persistence_failed_response());
}
}
let response = inner.call(req).await?;
Ok(cache_consumed_token_response(response, &settings, &key).await)
})
}
}
async fn cache_consumed_token_response(
response: Response<Body>,
settings: &SubmitTokenSettings,
key: &str,
) -> Response<Body> {
let (parts, body) = response.into_parts();
match collect_body(body, MAX_CACHEABLE_RESPONSE_BODY).await {
CollectedBody::Full(bytes) => {
let status = parts.status.as_u16();
if (200..400).contains(&status) {
let record = IdempotencyRecord {
status,
headers: replay_headers(&parts.headers),
body: bytes.to_vec(),
metadata: Vec::new(),
};
if let Err(error) = settings
.store
.try_set(key, record, Vec::new(), settings.ttl)
{
tracing::error!(
error = %error,
"Submit-token persistence failed after handler success; failing closed"
);
return crate::idempotency::persistence_failed_response();
}
}
settings.store.unlock(key);
Response::from_parts(parts, Body::from(bytes))
}
CollectedBody::Oversized { body, .. } => {
settings.store.unlock(key);
Response::from_parts(parts, body)
}
CollectedBody::Errored(error) => {
let status = parts.status.as_u16();
tracing::error!(
error = %error,
"Submit-token response buffering failed on a read error; failing closed"
);
if !(200..400).contains(&status) {
settings.store.unlock(key);
}
response_read_error_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::idempotency::{IdempotencyEntry, IdempotencyStoreError, MemoryIdempotencyStore};
use axum::Router;
use axum::routing::{get, post};
use std::sync::atomic::{AtomicUsize, Ordering};
use tower::ServiceExt;
fn default_config() -> SubmitTokenConfig {
SubmitTokenConfig {
enabled: true,
..Default::default()
}
}
fn layer_with_store(store: Arc<dyn IdempotencyStore>) -> SubmitTokenLayer {
SubmitTokenLayer::new(store, &default_config())
}
fn urlencoded_post(token: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(format!("_submit_token={token}&title=hello")))
.unwrap()
}
fn multipart_post(content_type: &str, boundary: &str, token: &str) -> Request<Body> {
let body = format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"_submit_token\"\r\n\
\r\n\
{token}\r\n\
--{boundary}--\r\n"
);
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", content_type)
.body(Body::from(body))
.unwrap()
}
#[test]
fn submit_token_extractor_exposes_value() {
let token = SubmitToken::new("abc-123".to_owned());
assert_eq!(token.token(), "abc-123");
assert_eq!(token.to_string(), "abc-123");
}
#[tokio::test]
async fn mints_token_available_to_extractor() {
async fn handler(submit_token: SubmitToken) -> String {
submit_token.token().to_owned()
}
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let app = Router::new()
.route("/", get(handler))
.layer(layer_with_store(store));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let token = String::from_utf8(body.to_vec()).unwrap();
assert!(
Uuid::parse_str(&token).is_ok(),
"minted token should be a uuid: {token}"
);
}
#[tokio::test]
async fn first_use_runs_handler_replay_short_circuits() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store));
let token = "tok-replay";
let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
let first_body = axum::body::to_bytes(first.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&first_body[..], b"created");
let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(second.status(), StatusCode::OK);
assert_eq!(
second
.headers()
.get(SUBMIT_TOKEN_REPLAYED)
.map(|v| v.to_str().unwrap()),
Some("true")
);
let second_body = axum::body::to_bytes(second.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&second_body[..], b"created");
assert_eq!(
count.load(Ordering::SeqCst),
1,
"handler must run exactly once"
);
}
#[tokio::test]
async fn lowercase_multipart_consumes_token_and_replay_short_circuits() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store));
let token = "tok-mp-lower";
let ct = "multipart/form-data; boundary=simpleboundary123";
let boundary = "simpleboundary123";
let first = app
.clone()
.oneshot(multipart_post(ct, boundary, token))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
let second = app
.clone()
.oneshot(multipart_post(ct, boundary, token))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::OK);
assert_eq!(
second
.headers()
.get(SUBMIT_TOKEN_REPLAYED)
.map(|v| v.to_str().unwrap()),
Some("true")
);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"handler must run exactly once"
);
}
#[tokio::test]
async fn mixed_case_multipart_consumes_token_and_replay_short_circuits() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store));
let token = "tok-mp-mixed";
let ct = "Multipart/Form-Data; Boundary=BoUnDaRy-XyZ-123";
let boundary = "BoUnDaRy-XyZ-123";
let first = app
.clone()
.oneshot(multipart_post(ct, boundary, token))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
let second = app
.clone()
.oneshot(multipart_post(ct, boundary, token))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::OK);
assert_eq!(
second
.headers()
.get(SUBMIT_TOKEN_REPLAYED)
.map(|v| v.to_str().unwrap()),
Some("true")
);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"handler must run exactly once"
);
}
#[tokio::test]
async fn quoted_semicolon_boundary_consumes_token_and_replay_short_circuits() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store));
let token = "tok-mp-quoted-semi";
let ct = "multipart/form-data; boundary=\"x;y\"";
let boundary = "x;y";
let first = app
.clone()
.oneshot(multipart_post(ct, boundary, token))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
let second = app
.clone()
.oneshot(multipart_post(ct, boundary, token))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::OK);
assert_eq!(
second
.headers()
.get(SUBMIT_TOKEN_REPLAYED)
.map(|v| v.to_str().unwrap()),
Some("true")
);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"handler must run exactly once"
);
}
#[tokio::test]
async fn uppercase_urlencoded_consumes_token_and_replay_short_circuits() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store));
let token = "tok-ue-upper";
let make_req = || {
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "APPLICATION/X-WWW-FORM-URLENCODED")
.body(Body::from(format!("_submit_token={token}&title=hello")))
.unwrap()
};
let first = app.clone().oneshot(make_req()).await.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
let second = app.clone().oneshot(make_req()).await.unwrap();
assert_eq!(second.status(), StatusCode::OK);
assert_eq!(
second
.headers()
.get(SUBMIT_TOKEN_REPLAYED)
.map(|v| v.to_str().unwrap()),
Some("true")
);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"handler must run exactly once"
);
}
#[tokio::test]
async fn missing_token_passes_through() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"ok"
}
}),
)
.layer(layer_with_store(store));
for _ in 0..2 {
let req = Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("title=hello"))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
assert_eq!(count.load(Ordering::SeqCst), 2);
}
#[test]
fn expired_token_re_runs_after_ttl() {
let store = MemoryIdempotencyStore::new(Duration::from_millis(10));
let key = storage_key("ttl-token");
store.set(
&key,
IdempotencyRecord {
status: 200,
headers: Vec::new(),
body: b"first".to_vec(),
metadata: Vec::new(),
},
Vec::new(),
Duration::from_millis(10),
);
assert!(store.get(&key).is_some());
std::thread::sleep(Duration::from_millis(30));
assert!(
store.get(&key).is_none(),
"record must expire after its TTL"
);
}
#[tokio::test]
async fn distinct_from_csrf_replayed_submit_token_short_circuits() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store));
let token = "tok-csrf-distinct";
let build = || {
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(format!(
"_csrf=valid-csrf&_submit_token={token}"
)))
.unwrap()
};
let first = app.clone().oneshot(build()).await.unwrap();
assert_eq!(first.status(), StatusCode::OK);
let second = app.clone().oneshot(build()).await.unwrap();
assert_eq!(
second
.headers()
.get(SUBMIT_TOKEN_REPLAYED)
.map(|v| v.to_str().unwrap()),
Some("true")
);
assert_eq!(count.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ten_concurrent_posts_persist_exactly_one_row() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let rows = Arc::new(AtomicUsize::new(0));
let rows_inner = rows.clone();
let app = Router::new()
.route(
"/posts",
post(move || {
let rows = rows_inner.clone();
async move {
tokio::time::sleep(Duration::from_millis(20)).await;
rows.fetch_add(1, Ordering::SeqCst);
(StatusCode::SEE_OTHER, [("location", "/posts")], "")
}
}),
)
.layer(layer_with_store(store));
let token = "concurrent-token";
let mut handles = Vec::new();
for _ in 0..10 {
let app = app.clone();
handles.push(tokio::spawn(async move {
let req = Request::builder()
.method("POST")
.uri("/posts")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(format!("_submit_token={token}&title=hello")))
.unwrap();
app.oneshot(req).await.unwrap().status()
}));
}
let mut succeeded = 0;
let mut conflicts = 0;
for h in handles {
let status = h.await.unwrap();
if status == StatusCode::SEE_OTHER {
succeeded += 1;
} else if status == StatusCode::CONFLICT {
conflicts += 1;
} else {
panic!("unexpected status: {status}");
}
}
assert_eq!(
rows.load(Ordering::SeqCst),
1,
"exactly one row must be persisted from 10 concurrent identical POSTs"
);
assert_eq!(
succeeded + conflicts,
10,
"every request must either return the first response or be rejected in-flight"
);
assert!(
succeeded >= 1,
"at least the first submission must succeed and be replayable"
);
}
#[tokio::test]
async fn over_limit_first_chunk_detects_leading_token_and_preserves_body() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let seen_len = Arc::new(AtomicUsize::new(0));
let seen_len_inner = seen_len.clone();
let app = Router::new()
.route(
"/submit",
post(move |body: Bytes| {
let count = count_inner.clone();
let seen_len = seen_len_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
seen_len.store(body.len(), Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store).with_max_scan_bytes(64));
let token = "over-limit-token";
let filler = "x".repeat(4096);
let full_body = format!("_submit_token={token}&title={filler}");
let full_len = full_body.len();
assert!(full_len > 64, "body must exceed the scan cap for this test");
let make = || {
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(full_body.clone()))
.unwrap()
};
let first = app.clone().oneshot(make()).await.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert!(first.headers().get(SUBMIT_TOKEN_REPLAYED).is_none());
assert_eq!(
seen_len.load(Ordering::SeqCst),
full_len,
"handler must receive the complete body, not just the scanned prefix"
);
let second = app.clone().oneshot(make()).await.unwrap();
assert_eq!(
second
.headers()
.get(SUBMIT_TOKEN_REPLAYED)
.map(|v| v.to_str().unwrap()),
Some("true"),
"an over-limit first chunk with a leading token must still be guarded"
);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"handler must run exactly once"
);
}
struct FailingSetStore {
inner: MemoryIdempotencyStore,
}
impl FailingSetStore {
fn new() -> Self {
Self {
inner: MemoryIdempotencyStore::new(Duration::from_secs(600)),
}
}
}
impl IdempotencyStore for FailingSetStore {
fn get(&self, key: &str) -> Option<IdempotencyEntry> {
self.inner.get(key)
}
fn set(&self, _key: &str, _record: IdempotencyRecord, _body_hash: Vec<u8>, _ttl: Duration) {
}
fn try_set(
&self,
_key: &str,
_record: IdempotencyRecord,
_body_hash: Vec<u8>,
_ttl: Duration,
) -> Result<(), IdempotencyStoreError> {
Err(IdempotencyStoreError::backend(
"simulated consumed-token persistence failure",
))
}
fn try_lock(&self, key: &str, lock_ttl: Duration) -> bool {
self.inner.try_lock(key, lock_ttl)
}
fn unlock(&self, key: &str) {
self.inner.unlock(key);
}
}
#[tokio::test]
async fn persistence_failure_fails_closed_and_holds_lock() {
let store: Arc<dyn IdempotencyStore> = Arc::new(FailingSetStore::new());
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store));
let token = "tok-persist-fail";
let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(
first.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a persistence failure after handler success must fail closed, not return the success"
);
let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(
second.status(),
StatusCode::CONFLICT,
"a retry after a persistence failure must be rejected in-flight, not re-run"
);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"the handler must run at most once even when persistence fails"
);
}
#[tokio::test]
async fn response_stream_error_fails_closed_and_holds_lock() {
let store = Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
Ok(Bytes::from("created")),
Err(std::io::Error::other("simulated response read failure")),
];
Response::new(Body::from_stream(futures::stream::iter(chunks)))
}
}),
)
.layer(layer_with_store(store_dyn));
let token = "tok-resp-stream-fail";
let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(
first.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"a response-stream error after handler commit must fail closed, not return a truncated body"
);
let key = storage_key(token);
assert!(
store.get(&key).is_none(),
"a response-stream error must not persist a consumed-token record"
);
let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(
second.status(),
StatusCode::CONFLICT,
"a retry after a response-stream error must be rejected in-flight, not re-run"
);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"the handler must run at most once even when the response stream errors"
);
}
#[tokio::test]
async fn response_stream_error_on_non_success_releases_lock() {
let store = Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
Ok(Bytes::from("invalid")),
Err(std::io::Error::other("simulated response read failure")),
];
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.body(Body::from_stream(futures::stream::iter(chunks)))
.unwrap()
}
}),
)
.layer(layer_with_store(store_dyn));
let token = "tok-resp-stream-fail-422";
let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(
first.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"a response-stream error must fail closed with 500 rather than a truncated body"
);
let key = storage_key(token);
assert!(
store.get(&key).is_none(),
"a non-success response-stream error must not persist a consumed-token record"
);
let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_ne!(
second.status(),
StatusCode::CONFLICT,
"a retry after a non-success response-stream error must be retryable, not a 409 in-flight conflict"
);
assert_eq!(
count.load(Ordering::SeqCst),
2,
"the handler must re-run on retry once the non-success lock is released"
);
}
struct FailingGetStore {
inner: MemoryIdempotencyStore,
fail_reads: std::sync::atomic::AtomicBool,
}
impl FailingGetStore {
fn new() -> Self {
Self {
inner: MemoryIdempotencyStore::new(Duration::from_secs(600)),
fail_reads: std::sync::atomic::AtomicBool::new(false),
}
}
}
impl IdempotencyStore for FailingGetStore {
fn get(&self, key: &str) -> Option<IdempotencyEntry> {
if self.fail_reads.load(Ordering::SeqCst) {
None
} else {
self.inner.get(key)
}
}
fn try_get(&self, key: &str) -> Result<Option<IdempotencyEntry>, IdempotencyStoreError> {
if self.fail_reads.load(Ordering::SeqCst) {
Err(IdempotencyStoreError::backend(
"simulated consumed-token lookup failure",
))
} else {
self.inner.try_get(key)
}
}
fn set(&self, key: &str, record: IdempotencyRecord, body_hash: Vec<u8>, ttl: Duration) {
self.inner.set(key, record, body_hash, ttl);
}
fn try_lock(&self, key: &str, lock_ttl: Duration) -> bool {
self.inner.try_lock(key, lock_ttl)
}
fn unlock(&self, key: &str) {
self.inner.unlock(key);
}
}
#[tokio::test]
async fn read_failure_fails_closed_and_does_not_rerun() {
let store = Arc::new(FailingGetStore::new());
let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store_dyn));
let token = "tok-read-fail";
let first = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert_eq!(count.load(Ordering::SeqCst), 1);
store.fail_reads.store(true, Ordering::SeqCst);
let second = app.clone().oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(
second.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a consumed-token lookup failure must fail closed, not re-run the handler"
);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"the handler must not re-run when the consumed-token lookup fails"
);
}
#[tokio::test]
async fn body_read_error_rejects_request_and_does_not_reach_handler() {
let store: Arc<dyn IdempotencyStore> =
Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move |_body: Bytes| {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(layer_with_store(store));
let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
Ok(Bytes::from("_submit_token=tok-read-err&ti")),
Err(std::io::Error::other("simulated body read failure")),
];
let body = Body::from_stream(futures::stream::iter(chunks));
let req = Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"a mid-read body-stream error must reject the request, not forward a truncated form"
);
assert_eq!(
count.load(Ordering::SeqCst),
0,
"the handler must never see a truncated body when the stream errors mid-read"
);
}
struct TtlRecordingStore {
inner: MemoryIdempotencyStore,
lock_ttl: std::sync::Mutex<Option<Duration>>,
set_ttl: std::sync::Mutex<Option<Duration>>,
}
impl TtlRecordingStore {
fn new() -> Self {
Self {
inner: MemoryIdempotencyStore::new(Duration::from_secs(600)),
lock_ttl: std::sync::Mutex::new(None),
set_ttl: std::sync::Mutex::new(None),
}
}
}
impl IdempotencyStore for TtlRecordingStore {
fn get(&self, key: &str) -> Option<IdempotencyEntry> {
self.inner.get(key)
}
fn set(&self, key: &str, record: IdempotencyRecord, body_hash: Vec<u8>, ttl: Duration) {
*self.set_ttl.lock().unwrap() = Some(ttl);
self.inner.set(key, record, body_hash, ttl);
}
fn try_lock(&self, key: &str, lock_ttl: Duration) -> bool {
*self.lock_ttl.lock().unwrap() = Some(lock_ttl);
self.inner.try_lock(key, lock_ttl)
}
fn unlock(&self, key: &str) {
self.inner.unlock(key);
}
}
#[tokio::test]
async fn in_flight_lock_ttl_is_decoupled_from_replay_ttl() {
let store = Arc::new(TtlRecordingStore::new());
let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
let config = SubmitTokenConfig {
enabled: true,
ttl_secs: 1,
in_flight_ttl_secs: 86_400,
..Default::default()
};
let app = Router::new()
.route("/submit", post(|| async { "created" }))
.layer(SubmitTokenLayer::new(store_dyn, &config));
let resp = app.oneshot(urlencoded_post("tok-decoupled")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let lock_ttl = store
.lock_ttl
.lock()
.unwrap()
.expect("the guard must acquire an in-flight lock");
assert_eq!(
lock_ttl,
Duration::from_secs(86_400),
"the in-flight lock TTL must come from in_flight_ttl_secs, not ttl_secs"
);
let set_ttl = store
.set_ttl
.lock()
.unwrap()
.expect("the guard must record the consumed token");
assert_eq!(
set_ttl,
Duration::from_secs(1),
"the consumed-record replay TTL must still come from ttl_secs"
);
}
#[tokio::test]
async fn in_flight_token_excludes_retry_with_small_replay_ttl() {
let store = Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(600)));
let store_dyn: Arc<dyn IdempotencyStore> = store.clone();
let config = SubmitTokenConfig {
enabled: true,
ttl_secs: 1,
in_flight_ttl_secs: 86_400,
..Default::default()
};
let count = Arc::new(AtomicUsize::new(0));
let count_inner = count.clone();
let app = Router::new()
.route(
"/submit",
post(move || {
let count = count_inner.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
"created"
}
}),
)
.layer(SubmitTokenLayer::new(store_dyn, &config));
let token = "tok-inflight";
let key = storage_key(token);
assert!(store.try_lock(&key, Duration::from_secs(86_400)));
let resp = app.oneshot(urlencoded_post(token)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::CONFLICT,
"a retry against a still-in-flight token must be excluded, not re-run"
);
assert_eq!(
count.load(Ordering::SeqCst),
0,
"the handler must not run for a retry held out by the in-flight lock"
);
}
}