use std::{
convert::Infallible,
fmt,
hash::{DefaultHasher, Hasher},
pin::Pin,
task::{Context, Poll},
};
use axum::{
body::{Body, Bytes, HttpBody, to_bytes},
extract::Request,
http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header},
middleware::Next,
response::{IntoResponseParts, Response, ResponseParts},
};
use hyper::body::Frame;
use serde::Serialize;
use crate::{Error, Result, error::ErrorDetail};
pub const MAX_BODY: usize = 8 * 1024 * 1024;
const CONTENT_HEADERS: [HeaderName; 8] = [
header::CONTENT_TYPE,
header::CONTENT_LENGTH,
header::CONTENT_ENCODING,
header::CONTENT_LANGUAGE,
header::CONTENT_RANGE,
header::CONTENT_DISPOSITION,
header::TRANSFER_ENCODING,
header::TRAILER,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ETag(HeaderValue);
impl ETag {
pub fn weak(text: &str) -> Result<Self> {
Self::new("W/", text)
}
pub fn strong(text: &str) -> Result<Self> {
Self::new("", text)
}
pub fn weak_of<T: Serialize>(value: &T) -> Result<Self> {
Self::weak(&digest(&serde_json::to_vec(value)?))
}
pub fn strong_of<T: Serialize>(value: &T) -> Result<Self> {
Self::strong(&digest(&serde_json::to_vec(value)?))
}
fn new(prefix: &str, text: &str) -> Result<Self> {
if !text
.bytes()
.all(|byte| byte.is_ascii() && is_tag_byte(byte))
{
return Err(Error::string(
"an entity tag has a character it cannot hold",
));
}
HeaderValue::from_str(&format!("{prefix}\"{text}\""))
.map(Self)
.map_err(Error::wrap)
}
fn parsed(&self) -> Option<Tag<'_>> {
tag_alone(self.0.as_bytes())
}
}
impl fmt::Display for ETag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0.to_str().unwrap_or_default())
}
}
impl IntoResponseParts for ETag {
type Error = Infallible;
fn into_response_parts(
self,
mut parts: ResponseParts,
) -> std::result::Result<ResponseParts, Self::Error> {
parts.headers_mut().insert(header::ETAG, self.0);
Ok(parts)
}
}
fn digest(bytes: &[u8]) -> String {
let mut hasher = DefaultHasher::new();
hasher.write(bytes);
format!("{:x}-{:016x}", bytes.len(), hasher.finish())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Tag<'a> {
weak: bool,
opaque: &'a [u8],
}
fn weak_match(sent: Tag<'_>, current: Tag<'_>) -> bool {
sent.opaque == current.opaque
}
fn strong_match(sent: Tag<'_>, current: Tag<'_>) -> bool {
!sent.weak && !current.weak && sent.opaque == current.opaque
}
const fn is_tag_byte(byte: u8) -> bool {
matches!(byte, 0x21 | 0x23..=0x7e | 0x80..=0xff)
}
fn read_tag(bytes: &[u8]) -> Option<(Tag<'_>, &[u8])> {
let (weak, rest) = match bytes.strip_prefix(b"W/") {
Some(rest) => (true, rest),
None => (false, bytes),
};
let rest = rest.strip_prefix(b"\"")?;
let end = rest.iter().position(|byte| !is_tag_byte(*byte))?;
(rest[end] == b'"').then_some((
Tag {
weak,
opaque: &rest[..end],
},
&rest[end + 1..],
))
}
fn tag_alone(bytes: &[u8]) -> Option<Tag<'_>> {
let (tag, rest) = read_tag(bytes.trim_ascii())?;
rest.is_empty().then_some(tag)
}
fn read_tags<'a>(values: &[&'a HeaderValue], tags: &mut Vec<Tag<'a>>) -> Option<()> {
for value in values {
let mut rest = value.as_bytes().trim_ascii();
while !rest.is_empty() {
if let Some(after) = rest.strip_prefix(b",") {
rest = after.trim_ascii_start();
continue;
}
let (tag, after) = read_tag(rest)?;
tags.push(tag);
rest = after.trim_ascii_start();
if !rest.is_empty() && !rest.starts_with(b",") {
return None;
}
}
}
Some(())
}
fn holds(
values: &[&HeaderValue],
current: Option<Tag<'_>>,
compare: fn(Tag<'_>, Tag<'_>) -> bool,
) -> bool {
if let [value] = values
&& value.as_bytes().trim_ascii() == b"*"
{
return true;
}
let mut tags = Vec::new();
read_tags(values, &mut tags).is_some()
&& current.is_some_and(|current| tags.iter().any(|tag| compare(*tag, current)))
}
struct NoBody;
impl HttpBody for NoBody {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(
self: Pin<&mut Self>,
_: &mut Context<'_>,
) -> Poll<Option<std::result::Result<Frame<Bytes>, Infallible>>> {
Poll::Ready(None)
}
fn is_end_stream(&self) -> bool {
true
}
}
fn unchanged(response: Response) -> Response {
let (mut parts, _) = response.into_parts();
parts.status = StatusCode::NOT_MODIFIED;
for name in CONTENT_HEADERS {
parts.headers.remove(name);
}
Response::from_parts(parts, Body::new(NoBody))
}
pub async fn not_modified(request: Request, next: Next) -> Response {
let conditional = matches!(*request.method(), Method::GET | Method::HEAD);
let held: Vec<HeaderValue> = if conditional {
let values = request.headers().get_all(header::IF_NONE_MATCH);
values.iter().cloned().collect()
} else {
Vec::new()
};
let response = next.run(request).await;
if held.is_empty() || !response.status().is_success() {
return response;
}
let current = response
.headers()
.get(header::ETAG)
.and_then(|value| tag_alone(value.as_bytes()));
let held: Vec<&HeaderValue> = held.iter().collect();
if holds(&held, current, weak_match) {
unchanged(response)
} else {
response
}
}
fn is_no_store(headers: &HeaderMap) -> bool {
headers
.get_all(header::CACHE_CONTROL)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.any(|directive| directive.trim().eq_ignore_ascii_case("no-store"))
}
fn should_tag(response: &Response) -> bool {
response.status() == StatusCode::OK
&& !response.headers().contains_key(header::ETAG)
&& !response.headers().contains_key(header::CONTENT_ENCODING)
&& !is_no_store(response.headers())
&& response
.body()
.size_hint()
.exact()
.is_some_and(|size| size > 0 && size <= MAX_BODY as u64)
}
pub async fn etag(request: Request, next: Next) -> Response {
let is_get = request.method() == Method::GET;
let response = next.run(request).await;
if !is_get || !should_tag(&response) {
return response;
}
let (mut parts, body) = response.into_parts();
let Ok(bytes) = to_bytes(body, MAX_BODY).await else {
tracing::error!("response_body_failed");
return crate::error::internal_response();
};
if let Ok(tag) = ETag::weak(&digest(&bytes)) {
parts.headers.insert(header::ETAG, tag.0);
}
Response::from_parts(parts, Body::from(bytes))
}
#[must_use]
pub fn if_match_holds(headers: &HeaderMap, current: &ETag) -> Option<bool> {
let values: Vec<&HeaderValue> = headers.get_all(header::IF_MATCH).iter().collect();
(!values.is_empty()).then(|| holds(&values, current.parsed(), strong_match))
}
fn precondition_failed() -> Error {
Error::CustomError(
StatusCode::PRECONDITION_FAILED,
ErrorDetail::new(
"precondition_failed",
"this changed since it was read; read it again, then retry",
),
)
}
pub fn if_match(headers: &HeaderMap, current: &ETag) -> Result<()> {
if if_match_holds(headers, current) == Some(false) {
return Err(precondition_failed());
}
Ok(())
}
pub fn require_if_match(headers: &HeaderMap, current: &ETag) -> Result<()> {
match if_match_holds(headers, current) {
Some(true) => Ok(()),
Some(false) => Err(precondition_failed()),
None => Err(Error::CustomError(
StatusCode::PRECONDITION_REQUIRED,
ErrorDetail::new(
"precondition_required",
"send If-Match with the tag from your last read",
),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn value(text: &'static str) -> HeaderValue {
HeaderValue::from_static(text)
}
fn weakly(sent: &[&'static str], current: &'static str) -> bool {
let values: Vec<HeaderValue> = sent.iter().map(|text| value(text)).collect();
let values: Vec<&HeaderValue> = values.iter().collect();
holds(&values, tag_alone(current.as_bytes()), weak_match)
}
fn strongly(sent: &'static str, current: &'static str) -> bool {
let sent = value(sent);
holds(&[&sent], tag_alone(current.as_bytes()), strong_match)
}
#[test]
fn weak_comparison_ignores_who_is_weak() {
assert!(weakly(&["\"a\""], "\"a\""));
assert!(weakly(&["W/\"a\""], "\"a\""));
assert!(weakly(&["\"a\""], "W/\"a\""));
assert!(weakly(&["W/\"a\""], "W/\"a\""));
assert!(!weakly(&["W/\"a\""], "W/\"b\""));
}
#[test]
fn strong_comparison_refuses_any_weak_tag() {
assert!(strongly("\"a\"", "\"a\""));
assert!(!strongly("W/\"a\"", "\"a\""));
assert!(!strongly("\"a\"", "W/\"a\""));
assert!(!strongly("W/\"a\"", "W/\"a\""));
assert!(!strongly("\"a\"", "\"b\""));
}
#[test]
fn a_list_is_read_with_commas_inside_tags_and_empty_members() {
assert!(weakly(&["\"a\", W/\"b\" ,\"c\""], "\"b\""));
assert!(weakly(&["\"x,y\""], "\"x,y\""));
assert!(!weakly(&["\"x,y\""], "\"x\""));
assert!(!weakly(&["\"x,y\""], "\"y\""));
assert!(weakly(&[", \"a\",, \"b\","], "\"b\""));
}
#[test]
fn a_list_can_run_over_several_header_lines() {
assert!(weakly(&["\"a\"", "\"b\""], "\"b\""));
}
#[test]
fn a_star_stands_for_anything_but_only_alone() {
assert!(weakly(&["*"], "\"a\""));
assert!(weakly(&[" * "], "\"a\""));
assert!(!weakly(&["*, \"b\""], "\"a\""));
assert!(!weakly(&["\"b\"", "*"], "\"a\""));
let star = value("*");
assert!(holds(&[&star], None, weak_match));
}
#[test]
fn what_is_not_a_tag_matches_nothing() {
for sent in [
"a",
"w/\"a\"",
"\"a",
"W/ \"a\"",
"\"a\" \"a\"",
"\"a\" junk",
"\"a b\"",
"\"a\", *",
] {
assert!(!weakly(&[sent], "\"a\""), "{sent}");
}
assert!(!weakly(&["\"a\""], "a"));
}
#[test]
fn bytes_outside_ascii_are_compared_as_bytes() {
let sent = HeaderValue::from_bytes(b"\"\xE9\"").unwrap();
assert!(holds(&[&sent], tag_alone(b"\"\xE9\""), weak_match));
}
#[test]
fn no_store_is_read_as_a_directive() {
let with = |text: &'static str| {
let mut headers = HeaderMap::new();
headers.insert(header::CACHE_CONTROL, value(text));
is_no_store(&headers)
};
assert!(with("no-store"));
assert!(with("private, No-Store"));
assert!(!with("no-cache"));
assert!(!with("private, max-age=60"));
let mut headers = HeaderMap::new();
headers.append(header::CACHE_CONTROL, value("private"));
headers.append(header::CACHE_CONTROL, value("no-store"));
assert!(is_no_store(&headers));
}
#[test]
fn a_tag_holds_only_what_a_tag_can_hold() {
assert_eq!(ETag::strong("v7").unwrap().to_string(), "\"v7\"");
assert_eq!(ETag::weak("v7").unwrap().to_string(), "W/\"v7\"");
assert_eq!(ETag::strong("").unwrap().to_string(), "\"\"");
for text in ["a b", "a\"b", "a\nb", "é"] {
assert!(ETag::strong(text).is_err(), "{text}");
assert!(ETag::weak(text).is_err(), "{text}");
}
}
#[test]
fn a_digest_is_the_same_each_time_and_differs_by_content() {
assert_eq!(digest(b"a"), digest(b"a"));
assert_ne!(digest(b"a"), digest(b"b"));
assert!(digest(b"abc").starts_with("3-"));
}
}