use crate::error::{Error, Result};
use crate::middleware::{Middleware, Next};
use crate::request::Request;
use crate::response::Response;
use async_trait::async_trait;
pub fn etag_for(body: &[u8]) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &b in body {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("\"{:016x}-{:x}\"", hash, body.len())
}
fn tags(header: &str) -> Vec<(bool, &str)> {
header
.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.map(|t| match t.strip_prefix("W/") {
Some(rest) => (true, rest),
None => (false, t),
})
.collect()
}
pub fn if_none_match_hits(header: &str, current: &str) -> bool {
let current = current.strip_prefix("W/").unwrap_or(current);
tags(header).iter().any(|&(_, t)| t == "*" || t == current)
}
pub fn if_match_hits(header: &str, current: &str) -> bool {
tags(header)
.iter()
.any(|&(weak, t)| t == "*" || (!weak && t == current))
}
pub fn require_if_match(req: &Request, current_etag: &str) -> Result<()> {
let Some(header) = req.headers.get(http::header::IF_MATCH) else {
return Err(Error::custom(
428,
"This resource requires an If-Match header with its current ETag",
));
};
let header = header
.to_str()
.map_err(|_| Error::bad_request("If-Match is not valid text"))?;
if if_match_hits(header, current_etag) {
Ok(())
} else {
Err(Error::custom(
412,
"The resource changed; fetch it again and retry with the new ETag",
))
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ETags;
#[async_trait]
impl Middleware for ETags {
async fn handle(&self, req: Request, next: Next) -> Result<Response> {
let is_get = req.method == http::Method::GET || req.method == http::Method::HEAD;
let if_none_match = req
.headers
.get(http::header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let mut res = match next.run(req).await {
Ok(res) => res,
Err(err) => Response::from_error(err),
};
if !is_get || res.status != http::StatusCode::OK || res.is_streaming() {
return Ok(res);
}
let etag = match res
.headers
.get(http::header::ETAG)
.and_then(|v| v.to_str().ok())
{
Some(existing) => existing.to_string(),
None => {
let tag = etag_for(&res.body);
res = res.with_header("etag", tag.clone());
tag
}
};
if if_none_match.is_some_and(|h| if_none_match_hits(&h, &etag)) {
res.status = http::StatusCode::NOT_MODIFIED;
res.body = bytes::Bytes::new();
res.headers.remove(http::header::CONTENT_LENGTH);
res.headers.remove(http::header::CONTENT_TYPE);
}
Ok(res)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn etag_is_stable_and_content_dependent() {
assert_eq!(etag_for(b""), "\"cbf29ce484222325-0\"");
assert_eq!(etag_for(b"a"), "\"af63dc4c8601ec8c-1\"");
assert_ne!(etag_for(b"ab"), etag_for(b"ba"));
assert_eq!(etag_for(b"same"), etag_for(b"same"));
}
#[test]
fn if_none_match_uses_weak_comparison() {
let t = "\"abc\"";
assert!(if_none_match_hits("\"abc\"", t));
assert!(if_none_match_hits("W/\"abc\"", t));
assert!(if_none_match_hits("\"x\", W/\"abc\" ,\"y\"", t));
assert!(if_none_match_hits("*", t));
assert!(if_none_match_hits("\"abc\"", "W/\"abc\""));
assert!(!if_none_match_hits("\"abd\"", t));
assert!(!if_none_match_hits("", t));
assert!(!if_none_match_hits("abc", t)); }
#[test]
fn if_match_uses_strong_comparison() {
let t = "\"abc\"";
assert!(if_match_hits("\"abc\"", t));
assert!(if_match_hits("\"x\", \"abc\"", t));
assert!(if_match_hits("*", t));
assert!(!if_match_hits("W/\"abc\"", t));
assert!(!if_match_hits("\"abd\"", t));
assert!(!if_match_hits("", t));
}
fn req_with(if_match: Option<&str>) -> Request {
let mut headers = http::HeaderMap::new();
if let Some(v) = if_match {
headers.insert(http::header::IF_MATCH, v.parse().unwrap());
}
Request::new(
http::Method::PATCH,
"/".parse().unwrap(),
headers,
bytes::Bytes::new(),
)
}
#[test]
fn require_if_match_statuses() {
assert!(require_if_match(&req_with(Some("\"v1\"")), "\"v1\"").is_ok());
assert_eq!(
require_if_match(&req_with(None), "\"v1\"")
.unwrap_err()
.status_code(),
428
);
assert_eq!(
require_if_match(&req_with(Some("\"v0\"")), "\"v1\"")
.unwrap_err()
.status_code(),
412
);
}
}