Skip to main content

ullun/
conditional.rs

1//! ETags and conditional requests
2//!
3//! * [`ETags`] middleware: tags `GET` responses with an `ETag` derived from the
4//!   body and answers `304 Not Modified` when the client's `If-None-Match`
5//!   still matches. Responses that already carry an `ETag` keep it.
6//! * [`require_if_match`]: optimistic concurrency for updates. The client
7//!   sends the `ETag` it last saw as `If-Match`; a stale one gets `412`, a
8//!   missing one `428`, so concurrent edits cannot silently overwrite each
9//!   other.
10//!
11//! ```
12//! use ullun::conditional::{etag_for, if_none_match_hits};
13//!
14//! let tag = etag_for(b"hello");
15//! assert!(if_none_match_hits(&tag, &tag));
16//! assert!(if_none_match_hits("\"other\", *", &tag));
17//! assert!(!if_none_match_hits("\"other\"", &tag));
18//! ```
19
20use crate::error::{Error, Result};
21use crate::middleware::{Middleware, Next};
22use crate::request::Request;
23use crate::response::Response;
24use async_trait::async_trait;
25
26/// Strong ETag for a body: 64-bit FNV-1a plus the length, e.g. `"2f3c...-5"`.
27/// Stable across processes and releases (unlike `DefaultHasher`).
28pub 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
37/// Entity tags of a header value (`"a", W/"b"`) with the weak marker removed;
38/// `None` for `*`
39fn 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
51/// `If-None-Match` evaluation: weak comparison, `*` matches anything
52pub 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
57/// `If-Match` evaluation: strong comparison, so weak tags never match
58pub fn if_match_hits(header: &str, current: &str) -> bool {
59    tags(header)
60        .iter()
61        .any(|&(weak, t)| t == "*" || (!weak && t == current))
62}
63
64/// Precondition for updating a resource whose current tag is `current_etag`
65///
66/// `428 Precondition Required` without an `If-Match` header, `412 Precondition
67/// Failed` when it does not match.
68pub 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/// Middleware adding `ETag` and `304 Not Modified` handling to `GET` responses
89#[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 || req.method == http::Method::HEAD;
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        // FNV-1a 64 of "" and "a" are published test vectors
140        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)); // unquoted is not a tag
157    }
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}