1use crate::error::{Error, Result};
21use crate::middleware::{Middleware, Next};
22use crate::request::Request;
23use crate::response::Response;
24use async_trait::async_trait;
25
26pub fn etag_for(body: &[u8]) -> String {
29 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
30 for &b in body {
31 hash ^= u64::from(b);
32 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
33 }
34 format!("\"{:016x}-{:x}\"", hash, body.len())
35}
36
37fn tags(header: &str) -> Vec<(bool, &str)> {
40 header
41 .split(',')
42 .map(str::trim)
43 .filter(|t| !t.is_empty())
44 .map(|t| match t.strip_prefix("W/") {
45 Some(rest) => (true, rest),
46 None => (false, t),
47 })
48 .collect()
49}
50
51pub fn if_none_match_hits(header: &str, current: &str) -> bool {
53 let current = current.strip_prefix("W/").unwrap_or(current);
54 tags(header).iter().any(|&(_, t)| t == "*" || t == current)
55}
56
57pub fn if_match_hits(header: &str, current: &str) -> bool {
59 tags(header)
60 .iter()
61 .any(|&(weak, t)| t == "*" || (!weak && t == current))
62}
63
64pub fn require_if_match(req: &Request, current_etag: &str) -> Result<()> {
69 let Some(header) = req.headers.get(http::header::IF_MATCH) else {
70 return Err(Error::custom(
71 428,
72 "This resource requires an If-Match header with its current ETag",
73 ));
74 };
75 let header = header
76 .to_str()
77 .map_err(|_| Error::bad_request("If-Match is not valid text"))?;
78 if if_match_hits(header, current_etag) {
79 Ok(())
80 } else {
81 Err(Error::custom(
82 412,
83 "The resource changed; fetch it again and retry with the new ETag",
84 ))
85 }
86}
87
88#[derive(Debug, Default, Clone, Copy)]
90pub struct ETags;
91
92#[async_trait]
93impl Middleware for ETags {
94 async fn handle(&self, req: Request, next: Next) -> Result<Response> {
95 let is_get = req.method == http::Method::GET;
96 let if_none_match = req
97 .headers
98 .get(http::header::IF_NONE_MATCH)
99 .and_then(|v| v.to_str().ok())
100 .map(str::to_owned);
101
102 let mut res = match next.run(req).await {
103 Ok(res) => res,
104 Err(err) => Response::from_error(err),
105 };
106 if !is_get || res.status != http::StatusCode::OK || res.is_streaming() {
107 return Ok(res);
108 }
109
110 let etag = match res
111 .headers
112 .get(http::header::ETAG)
113 .and_then(|v| v.to_str().ok())
114 {
115 Some(existing) => existing.to_string(),
116 None => {
117 let tag = etag_for(&res.body);
118 res = res.with_header("etag", tag.clone());
119 tag
120 }
121 };
122
123 if if_none_match.is_some_and(|h| if_none_match_hits(&h, &etag)) {
124 res.status = http::StatusCode::NOT_MODIFIED;
125 res.body = bytes::Bytes::new();
126 res.headers.remove(http::header::CONTENT_LENGTH);
127 res.headers.remove(http::header::CONTENT_TYPE);
128 }
129 Ok(res)
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn etag_is_stable_and_content_dependent() {
139 assert_eq!(etag_for(b""), "\"cbf29ce484222325-0\"");
141 assert_eq!(etag_for(b"a"), "\"af63dc4c8601ec8c-1\"");
142 assert_ne!(etag_for(b"ab"), etag_for(b"ba"));
143 assert_eq!(etag_for(b"same"), etag_for(b"same"));
144 }
145
146 #[test]
147 fn if_none_match_uses_weak_comparison() {
148 let t = "\"abc\"";
149 assert!(if_none_match_hits("\"abc\"", t));
150 assert!(if_none_match_hits("W/\"abc\"", t));
151 assert!(if_none_match_hits("\"x\", W/\"abc\" ,\"y\"", t));
152 assert!(if_none_match_hits("*", t));
153 assert!(if_none_match_hits("\"abc\"", "W/\"abc\""));
154 assert!(!if_none_match_hits("\"abd\"", t));
155 assert!(!if_none_match_hits("", t));
156 assert!(!if_none_match_hits("abc", t)); }
158
159 #[test]
160 fn if_match_uses_strong_comparison() {
161 let t = "\"abc\"";
162 assert!(if_match_hits("\"abc\"", t));
163 assert!(if_match_hits("\"x\", \"abc\"", t));
164 assert!(if_match_hits("*", t));
165 assert!(!if_match_hits("W/\"abc\"", t));
166 assert!(!if_match_hits("\"abd\"", t));
167 assert!(!if_match_hits("", t));
168 }
169
170 fn req_with(if_match: Option<&str>) -> Request {
171 let mut headers = http::HeaderMap::new();
172 if let Some(v) = if_match {
173 headers.insert(http::header::IF_MATCH, v.parse().unwrap());
174 }
175 Request::new(
176 http::Method::PATCH,
177 "/".parse().unwrap(),
178 headers,
179 bytes::Bytes::new(),
180 )
181 }
182
183 #[test]
184 fn require_if_match_statuses() {
185 assert!(require_if_match(&req_with(Some("\"v1\"")), "\"v1\"").is_ok());
186 assert_eq!(
187 require_if_match(&req_with(None), "\"v1\"")
188 .unwrap_err()
189 .status_code(),
190 428
191 );
192 assert_eq!(
193 require_if_match(&req_with(Some("\"v0\"")), "\"v1\"")
194 .unwrap_err()
195 .status_code(),
196 412
197 );
198 }
199}