Skip to main content

tower_http/services/fs/serve_dir/
headers.rs

1use http::header::HeaderValue;
2use httpdate::HttpDate;
3use std::time::SystemTime;
4
5/// A strong ETag derived from file metadata (size + mtime with nanosecond precision).
6///
7/// Format is an implementation detail and may change between versions. Clients should
8/// treat ETags as opaque values per RFC 9110 §8.8.3.
9#[derive(Clone, Debug)]
10pub(super) struct ETag(HeaderValue);
11
12impl ETag {
13    /// Generate an ETag from file size and modification time.
14    ///
15    /// Returns `None` only for pre-epoch modification times, which are unsupported.
16    pub(super) fn from_metadata(size: u64, modified: SystemTime) -> Option<Self> {
17        let duration = modified.duration_since(SystemTime::UNIX_EPOCH).ok()?;
18        // NOTE: Changing this format is a cache-busting event for all clients,
19        // but is not a semver break (ETags are opaque per RFC 9110 §8.8.3).
20        let value = format!(
21            "\"{:x}.{:08x}-{:x}\"",
22            duration.as_secs(),
23            duration.subsec_nanos(),
24            size
25        );
26        HeaderValue::from_str(&value).ok().map(ETag)
27    }
28
29    pub(super) fn into_header_value(self) -> HeaderValue {
30        self.0
31    }
32
33    /// Strong comparison per RFC 9110 §8.8.3.2: both must not be weak,
34    /// and the opaque-tags must be identical.
35    fn strong_eq(&self, other: &[u8]) -> bool {
36        if other.starts_with(b"W/") {
37            return false;
38        }
39        self.0.as_bytes() == other
40    }
41
42    /// Weak comparison per RFC 9110 §8.8.3.2: ignore W/ prefix,
43    /// compare opaque-tags.
44    fn weak_eq(&self, other: &[u8]) -> bool {
45        let this = self.0.as_bytes();
46        let other = other.strip_prefix(b"W/").unwrap_or(other);
47        let this = this.strip_prefix(b"W/").unwrap_or(this);
48        this == other
49    }
50}
51
52/// Parsed `If-None-Match` header (RFC 9110 §13.1.2).
53pub(super) struct IfNoneMatch(HeaderValue);
54
55impl IfNoneMatch {
56    pub(super) fn from_header_value(value: &HeaderValue) -> Option<Self> {
57        // Reject empty values
58        if value.as_bytes().is_empty() {
59            return None;
60        }
61        Some(IfNoneMatch(value.clone()))
62    }
63
64    /// Returns true if the precondition passes (none of the ETags match).
65    /// A failed precondition (returns false) means we should return 304.
66    ///
67    /// Uses weak comparison per RFC 9110 §13.1.2.
68    pub(super) fn precondition_passes(&self, etag: &ETag) -> bool {
69        let bytes = self.0.as_bytes();
70        if bytes == b"*" {
71            return false;
72        }
73        !for_each_etag(bytes, |tag| etag.weak_eq(tag))
74    }
75}
76
77/// Parsed `If-Match` header (RFC 9110 §13.1.1).
78pub(super) struct IfMatch(HeaderValue);
79
80impl IfMatch {
81    pub(super) fn from_header_value(value: &HeaderValue) -> Option<Self> {
82        if value.as_bytes().is_empty() {
83            return None;
84        }
85        Some(IfMatch(value.clone()))
86    }
87
88    /// Returns true if the precondition passes (at least one ETag matches).
89    /// A failed precondition (returns false) means we should return 412.
90    ///
91    /// Uses strong comparison per RFC 9110 §13.1.1.
92    pub(super) fn precondition_passes(&self, etag: &ETag) -> bool {
93        let bytes = self.0.as_bytes();
94        if bytes == b"*" {
95            return true;
96        }
97        for_each_etag(bytes, |tag| etag.strong_eq(tag))
98    }
99}
100
101/// Iterate over comma-separated ETags in a header value, trimming OWS.
102/// Returns true if `predicate` returns true for any tag (short-circuits).
103///
104/// Handles commas inside quoted strings per RFC 9110 §8.8.3 (ETags are quoted).
105fn for_each_etag(header: &[u8], mut predicate: impl FnMut(&[u8]) -> bool) -> bool {
106    let mut start = 0;
107    let mut in_quotes = false;
108    for i in 0..header.len() {
109        match header[i] {
110            b'"' => in_quotes = !in_quotes,
111            b',' if !in_quotes => {
112                let trimmed = trim_ows(&header[start..i]);
113                if !trimmed.is_empty() && predicate(trimmed) {
114                    return true;
115                }
116                start = i + 1;
117            }
118            _ => {}
119        }
120    }
121    let trimmed = trim_ows(&header[start..]);
122    if !trimmed.is_empty() && predicate(trimmed) {
123        return true;
124    }
125    false
126}
127
128/// Trim leading/trailing OWS (SP / HTAB) per RFC 9110.
129fn trim_ows(bytes: &[u8]) -> &[u8] {
130    let start = bytes
131        .iter()
132        .position(|&b| b != b' ' && b != b'\t')
133        .unwrap_or(bytes.len());
134    let end = bytes
135        .iter()
136        .rposition(|&b| b != b' ' && b != b'\t')
137        .map(|i| i + 1)
138        .unwrap_or(0);
139    if start >= end {
140        &[]
141    } else {
142        &bytes[start..end]
143    }
144}
145
146pub(super) struct LastModified(pub(super) HttpDate);
147
148impl From<SystemTime> for LastModified {
149    fn from(time: SystemTime) -> Self {
150        LastModified(time.into())
151    }
152}
153
154pub(super) struct IfModifiedSince(HttpDate);
155
156impl IfModifiedSince {
157    /// Check if the supplied time means the resource has been modified.
158    pub(super) fn is_modified(&self, last_modified: &LastModified) -> bool {
159        self.0 < last_modified.0
160    }
161
162    /// Convert a header value into a IfModifiedSince. Invalid values are silently ignored
163    pub(super) fn from_header_value(value: &HeaderValue) -> Option<IfModifiedSince> {
164        std::str::from_utf8(value.as_bytes())
165            .ok()
166            .and_then(|value| httpdate::parse_http_date(value).ok())
167            .map(|time| IfModifiedSince(time.into()))
168    }
169}
170
171pub(super) struct IfUnmodifiedSince(HttpDate);
172
173impl IfUnmodifiedSince {
174    /// Check if the supplied time passes the precondtion.
175    pub(super) fn precondition_passes(&self, last_modified: &LastModified) -> bool {
176        self.0 >= last_modified.0
177    }
178
179    /// Convert a header value into a IfUnmodifiedSince. Invalid values are silently ignored
180    pub(super) fn from_header_value(value: &HeaderValue) -> Option<IfUnmodifiedSince> {
181        std::str::from_utf8(value.as_bytes())
182            .ok()
183            .and_then(|value| httpdate::parse_http_date(value).ok())
184            .map(|time| IfUnmodifiedSince(time.into()))
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    /// Helper: collect all ETags parsed from a header value.
193    fn collect_etags(header: &[u8]) -> Vec<Vec<u8>> {
194        let mut tags = Vec::new();
195        for_each_etag(header, |tag| {
196            tags.push(tag.to_vec());
197            false // don't short-circuit, collect all
198        });
199        tags
200    }
201
202    #[test]
203    fn for_each_etag_simple_list() {
204        let tags = collect_etags(b"\"foo\", \"bar\", \"baz\"");
205        assert_eq!(
206            tags,
207            vec![
208                b"\"foo\"".to_vec(),
209                b"\"bar\"".to_vec(),
210                b"\"baz\"".to_vec()
211            ]
212        );
213    }
214
215    #[test]
216    fn for_each_etag_comma_inside_quotes() {
217        // An ETag containing a comma inside the quoted string should not be split
218        let tags = collect_etags(b"\"foo,bar\", \"baz\"");
219        assert_eq!(tags, vec![b"\"foo,bar\"".to_vec(), b"\"baz\"".to_vec()]);
220    }
221
222    #[test]
223    fn for_each_etag_multiple_commas_inside_quotes() {
224        let tags = collect_etags(b"\"a,b,c\", \"d\"");
225        assert_eq!(tags, vec![b"\"a,b,c\"".to_vec(), b"\"d\"".to_vec()]);
226    }
227
228    #[test]
229    fn for_each_etag_weak_with_comma_inside() {
230        let tags = collect_etags(b"W/\"foo,bar\", \"baz\"");
231        assert_eq!(tags, vec![b"W/\"foo,bar\"".to_vec(), b"\"baz\"".to_vec()]);
232    }
233
234    #[test]
235    fn for_each_etag_single_tag() {
236        let tags = collect_etags(b"\"only\"");
237        assert_eq!(tags, vec![b"\"only\"".to_vec()]);
238    }
239
240    #[test]
241    fn for_each_etag_empty() {
242        let tags = collect_etags(b"");
243        assert!(tags.is_empty());
244    }
245
246    #[test]
247    fn for_each_etag_whitespace_only() {
248        let tags = collect_etags(b"  ,  , ");
249        assert!(tags.is_empty());
250    }
251
252    #[test]
253    fn for_each_etag_short_circuits() {
254        let mut count = 0;
255        let found = for_each_etag(b"\"a\", \"b\", \"c\"", |_tag| {
256            count += 1;
257            count == 2 // match on second tag
258        });
259        assert!(found);
260        assert_eq!(count, 2);
261    }
262}