Skip to main content

http_fresh/
lib.rs

1//! # http-fresh — HTTP response freshness (conditional GET) checking
2//!
3//! Decide whether a cached response is still *fresh* for a request, i.e. whether the
4//! server may answer `304 Not Modified` instead of resending the body. This evaluates
5//! the `If-None-Match` / `If-Modified-Since` request headers against the response's
6//! `ETag` / `Last-Modified`, honoring `Cache-Control: no-cache`.
7//!
8//! A faithful Rust port of the [`fresh`](https://www.npmjs.com/package/fresh) npm
9//! package (the logic behind Express's `req.fresh`). **Zero dependencies, `#![no_std]`,
10//! and zero heap allocation.**
11//!
12//! ```
13//! use http_fresh::{fresh, Request, Response};
14//!
15//! // ETag matches → response is fresh → caller can send 304.
16//! let req = Request::new().if_none_match("\"abc\"");
17//! let res = Response::new().etag("\"abc\"");
18//! assert!(fresh(&req, &res));
19//!
20//! // A different ETag → stale → caller must send the body.
21//! let res = Response::new().etag("\"xyz\"");
22//! assert!(!fresh(&req, &res));
23//!
24//! // `Cache-Control: no-cache` always forces stale.
25//! let req = req.cache_control("no-cache");
26//! assert!(!fresh(&req, &Response::new().etag("\"abc\"")));
27//! ```
28//!
29//! ## Differences from the npm package
30//!
31//! The npm package parses dates with JavaScript's `Date.parse`, which is lenient and —
32//! for timezone-less formats such as `asctime` — interprets them in the host machine's
33//! *local* timezone (so its result is not reproducible across machines). This crate
34//! instead parses exactly the three date formats mandated by
35//! [RFC 9110 §5.6.7](https://www.rfc-editor.org/rfc/rfc9110#section-5.6.7)
36//! (`IMF-fixdate`, `RFC 850`, and `asctime`), always in **GMT**. Any string outside
37//! those formats is treated as unparseable, which makes the response **stale** (a safe,
38//! revalidating default). For real-world HTTP traffic — which uses `IMF-fixdate` — the
39//! behavior is identical to the npm package.
40
41#![no_std]
42#![forbid(unsafe_code)]
43#![doc(html_root_url = "https://docs.rs/http-fresh/0.1.0")]
44
45// Compile-test the README's examples as part of `cargo test`.
46#[cfg(doctest)]
47#[doc = include_str!("../README.md")]
48struct ReadmeDoctests;
49
50/// The request-side headers that drive a freshness check.
51///
52/// Build one with [`Request::new`] and the chainable setters, or construct it directly
53/// (all fields are public). A field left `None` — or set to an empty string — is treated
54/// as absent, mirroring the npm package's falsy-header handling.
55///
56/// ```
57/// use http_fresh::Request;
58/// let req = Request::new()
59///     .if_none_match("\"v1\"")
60///     .cache_control("max-age=0");
61/// assert_eq!(req.if_none_match, Some("\"v1\""));
62/// ```
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub struct Request<'a> {
65    /// The `If-None-Match` header value (an `ETag` list or `*`).
66    pub if_none_match: Option<&'a str>,
67    /// The `If-Modified-Since` header value (an HTTP date).
68    pub if_modified_since: Option<&'a str>,
69    /// The `Cache-Control` header value.
70    pub cache_control: Option<&'a str>,
71}
72
73impl<'a> Request<'a> {
74    /// Create an empty [`Request`] with all headers absent.
75    #[must_use]
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// Set the `If-None-Match` header.
81    #[must_use]
82    pub fn if_none_match(mut self, value: &'a str) -> Self {
83        self.if_none_match = Some(value);
84        self
85    }
86
87    /// Set the `If-Modified-Since` header.
88    #[must_use]
89    pub fn if_modified_since(mut self, value: &'a str) -> Self {
90        self.if_modified_since = Some(value);
91        self
92    }
93
94    /// Set the `Cache-Control` header.
95    #[must_use]
96    pub fn cache_control(mut self, value: &'a str) -> Self {
97        self.cache_control = Some(value);
98        self
99    }
100}
101
102/// The response-side validators that a fresh request must match.
103///
104/// Build one with [`Response::new`] and the chainable setters, or construct it directly.
105/// A field left `None` — or set to an empty string — is treated as absent.
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
107pub struct Response<'a> {
108    /// The response `ETag` header value.
109    pub etag: Option<&'a str>,
110    /// The response `Last-Modified` header value (an HTTP date).
111    pub last_modified: Option<&'a str>,
112}
113
114impl<'a> Response<'a> {
115    /// Create an empty [`Response`] with all validators absent.
116    #[must_use]
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Set the `ETag` header.
122    #[must_use]
123    pub fn etag(mut self, value: &'a str) -> Self {
124        self.etag = Some(value);
125        self
126    }
127
128    /// Set the `Last-Modified` header.
129    #[must_use]
130    pub fn last_modified(mut self, value: &'a str) -> Self {
131        self.last_modified = Some(value);
132        self
133    }
134}
135
136/// Returns `true` if the response is **fresh** for this request — meaning the server may
137/// answer `304 Not Modified` rather than resending the body.
138///
139/// The check mirrors the npm `fresh` package:
140///
141/// 1. An *unconditional* request (no `If-None-Match` and no `If-Modified-Since`) is never
142///    fresh.
143/// 2. `Cache-Control: no-cache` always forces stale.
144/// 3. If `If-None-Match` is present and not `*`, the response `ETag` must match one of its
145///    entries (with weak/strong `W/` comparison).
146/// 4. If `If-Modified-Since` is present, the response `Last-Modified` must be no newer than
147///    it.
148///
149/// Empty-string headers are treated as absent.
150///
151/// ```
152/// use http_fresh::{fresh, Request, Response};
153///
154/// let req = Request::new().if_modified_since("Sun, 06 Nov 1994 08:49:37 GMT");
155/// let res = Response::new().last_modified("Sat, 05 Nov 1994 08:49:37 GMT");
156/// assert!(fresh(&req, &res)); // not modified since → fresh
157/// ```
158#[must_use]
159pub fn fresh(req: &Request<'_>, res: &Response<'_>) -> bool {
160    let modified_since = present(req.if_modified_since);
161    let none_match = present(req.if_none_match);
162
163    // Unconditional request.
164    if modified_since.is_none() && none_match.is_none() {
165        return false;
166    }
167
168    // `Cache-Control: no-cache` forces an end-to-end reload.
169    if let Some(cc) = present(req.cache_control) {
170        if has_no_cache(cc) {
171            return false;
172        }
173    }
174
175    // If-None-Match.
176    if let Some(none_match) = none_match {
177        if none_match != "*" {
178            let Some(etag) = present(res.etag) else {
179                return false;
180            };
181            if !none_match
182                .split(',')
183                .any(|tok| etag_matches(trim_spaces(tok), etag))
184            {
185                return false;
186            }
187        }
188    }
189
190    // If-Modified-Since.
191    if let Some(modified_since) = modified_since {
192        let Some(last_modified) = present(res.last_modified) else {
193            return false;
194        };
195        let modified_stale = match (
196            parse_http_date(last_modified),
197            parse_http_date(modified_since),
198        ) {
199            (Some(last), Some(since)) => last > since,
200            // An unparseable date compares like `NaN` in JS: never `<=`, so stale.
201            _ => true,
202        };
203        if modified_stale {
204            return false;
205        }
206    }
207
208    true
209}
210
211/// Map a `None`/empty header to absent, mirroring JavaScript falsy-string handling.
212fn present(value: Option<&str>) -> Option<&str> {
213    value.filter(|v| !v.is_empty())
214}
215
216/// Trim leading and trailing ASCII spaces (`0x20` only — not tabs), matching the
217/// reference's `parseTokenList`.
218fn trim_spaces(s: &str) -> &str {
219    s.trim_matches(' ')
220}
221
222/// Whether `tag` from an `If-None-Match` list matches `etag`, with weak/strong comparison:
223/// `tag == etag`, `tag == "W/" + etag`, or `"W/" + tag == etag`.
224fn etag_matches(tag: &str, etag: &str) -> bool {
225    tag == etag || tag.strip_prefix("W/") == Some(etag) || etag.strip_prefix("W/") == Some(tag)
226}
227
228/// Replicates `/(?:^|,)\s*?no-cache\s*?(?:,|$)/` (case-sensitive) without a regex engine.
229///
230/// A `no-cache` token qualifies when it is bounded — on each side, after skipping any
231/// JavaScript-`\s` whitespace — by a comma or the string boundary.
232fn has_no_cache(cc: &str) -> bool {
233    let needle = "no-cache";
234    let mut search_from = 0;
235    while let Some(rel) = cc[search_from..].find(needle) {
236        let start = search_from + rel;
237        let end = start + needle.len();
238
239        let left_ok = {
240            // Walk left over whitespace; reached the start, or the bounding char is ','.
241            let mut iter = cc[..start]
242                .chars()
243                .rev()
244                .skip_while(|&c| is_js_whitespace(c));
245            matches!(iter.next(), None | Some(','))
246        };
247        let right_ok = {
248            // Walk right over whitespace; reached the end, or the bounding char is ','.
249            let mut iter = cc[end..].chars().skip_while(|&c| is_js_whitespace(c));
250            matches!(iter.next(), None | Some(','))
251        };
252        if left_ok && right_ok {
253            return true;
254        }
255        search_from = start + 1;
256    }
257    false
258}
259
260/// The set of characters matched by JavaScript's `\s` (without the `u` flag).
261fn is_js_whitespace(c: char) -> bool {
262    matches!(
263        c,
264        '\u{0009}'
265            | '\u{000A}'
266            | '\u{000B}'
267            | '\u{000C}'
268            | '\u{000D}'
269            | '\u{0020}'
270            | '\u{00A0}'
271            | '\u{1680}'
272            | '\u{2000}'
273            ..='\u{200A}'
274                | '\u{2028}'
275                | '\u{2029}'
276                | '\u{202F}'
277                | '\u{205F}'
278                | '\u{3000}'
279                | '\u{FEFF}'
280    )
281}
282
283/// Parse an HTTP date (RFC 9110 §5.6.7) into Unix epoch seconds, in GMT.
284///
285/// Accepts the three mandated formats: `IMF-fixdate`, obsolete `RFC 850`, and `asctime`.
286/// Returns `None` for any other string.
287fn parse_http_date(s: &str) -> Option<i64> {
288    parse_imf(s)
289        .or_else(|| parse_rfc850(s))
290        .or_else(|| parse_asctime(s))
291}
292
293/// `Sun, 06 Nov 1994 08:49:37 GMT`
294fn parse_imf(s: &str) -> Option<i64> {
295    let comma = s.find(',')?;
296    let rest = s[comma + 1..].trim_start_matches(' ');
297    let mut it = rest.split(' ').filter(|p| !p.is_empty());
298    let day = parse_fixed_digits(it.next()?, 2)?;
299    let month = parse_month(it.next()?)?;
300    let year_str = it.next()?;
301    if year_str.len() != 4 {
302        return None;
303    }
304    let year = parse_fixed_digits(year_str, 4)?;
305    let (h, m, sec) = parse_time(it.next()?)?;
306    if !it.next()?.eq_ignore_ascii_case("GMT") || it.next().is_some() {
307        return None;
308    }
309    make_epoch(i64::from(year), month, i64::from(day), h, m, sec)
310}
311
312/// `Sunday, 06-Nov-94 08:49:37 GMT`
313fn parse_rfc850(s: &str) -> Option<i64> {
314    let comma = s.find(',')?;
315    let rest = s[comma + 1..].trim_start_matches(' ');
316    let mut it = rest.split(' ').filter(|p| !p.is_empty());
317    let date_part = it.next()?;
318    let (h, m, sec) = parse_time(it.next()?)?;
319    if !it.next()?.eq_ignore_ascii_case("GMT") || it.next().is_some() {
320        return None;
321    }
322    let mut dit = date_part.split('-');
323    let day = parse_fixed_digits(dit.next()?, 2)?;
324    let month = parse_month(dit.next()?)?;
325    let yy_str = dit.next()?;
326    if dit.next().is_some() || yy_str.len() != 2 {
327        return None;
328    }
329    let yy = i64::from(parse_fixed_digits(yy_str, 2)?);
330    // V8 pivot: 00–49 → 2000–2049, 50–99 → 1950–1999.
331    let year = if yy < 50 { 2000 + yy } else { 1900 + yy };
332    make_epoch(year, month, i64::from(day), h, m, sec)
333}
334
335/// `Sun Nov  6 08:49:37 1994` (day is space-padded)
336fn parse_asctime(s: &str) -> Option<i64> {
337    if s.contains(',') {
338        return None;
339    }
340    let mut it = s.split(' ').filter(|p| !p.is_empty());
341    let _weekday = it.next()?;
342    let month = parse_month(it.next()?)?;
343    let day = parse_day_1_or_2(it.next()?)?;
344    let (h, m, sec) = parse_time(it.next()?)?;
345    let year_str = it.next()?;
346    if it.next().is_some() || year_str.len() != 4 {
347        return None;
348    }
349    let year = i64::from(parse_fixed_digits(year_str, 4)?);
350    make_epoch(year, month, i64::from(day), h, m, sec)
351}
352
353/// Parse `HH:MM:SS` into `(h, m, s)`.
354fn parse_time(s: &str) -> Option<(i64, i64, i64)> {
355    let mut it = s.split(':');
356    let h = parse_fixed_digits(it.next()?, 2)?;
357    let m = parse_fixed_digits(it.next()?, 2)?;
358    let sec = parse_fixed_digits(it.next()?, 2)?;
359    if it.next().is_some() || h > 23 || m > 59 || sec > 60 {
360        return None;
361    }
362    Some((i64::from(h), i64::from(m), i64::from(sec)))
363}
364
365/// Parse exactly `width` ASCII digits into a `u32`.
366fn parse_fixed_digits(s: &str, width: usize) -> Option<u32> {
367    if s.len() != width || !s.bytes().all(|b| b.is_ascii_digit()) {
368        return None;
369    }
370    s.parse().ok()
371}
372
373/// Parse a 1- or 2-digit day (for `asctime`, where single days are space-padded so the
374/// field arrives as `"6"` after whitespace splitting).
375fn parse_day_1_or_2(s: &str) -> Option<u32> {
376    if s.is_empty() || s.len() > 2 || !s.bytes().all(|b| b.is_ascii_digit()) {
377        return None;
378    }
379    s.parse().ok()
380}
381
382/// Map a 3-letter English month abbreviation (case-insensitive) to `1..=12`.
383fn parse_month(s: &str) -> Option<i64> {
384    if s.len() != 3 {
385        return None;
386    }
387    let mut buf = [0u8; 3];
388    for (i, b) in s.bytes().enumerate() {
389        buf[i] = b.to_ascii_lowercase();
390    }
391    match &buf {
392        b"jan" => Some(1),
393        b"feb" => Some(2),
394        b"mar" => Some(3),
395        b"apr" => Some(4),
396        b"may" => Some(5),
397        b"jun" => Some(6),
398        b"jul" => Some(7),
399        b"aug" => Some(8),
400        b"sep" => Some(9),
401        b"oct" => Some(10),
402        b"nov" => Some(11),
403        b"dec" => Some(12),
404        _ => None,
405    }
406}
407
408/// Convert a GMT calendar date/time to Unix epoch seconds.
409fn make_epoch(year: i64, month: i64, day: i64, h: i64, m: i64, s: i64) -> Option<i64> {
410    if !(1..=31).contains(&day) {
411        return None;
412    }
413    let days = days_from_civil(year, month, day);
414    Some(days * 86_400 + h * 3_600 + m * 60 + s)
415}
416
417/// Days since 1970-01-01 for a proleptic-Gregorian date (Howard Hinnant's algorithm).
418fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
419    let y = if month <= 2 { year - 1 } else { year };
420    let era = if y >= 0 { y } else { y - 399 } / 400;
421    let yoe = y - era * 400; // [0, 399]
422    let mp = if month > 2 { month - 3 } else { month + 9 }; // Mar=0..Feb=11
423    let doy = (153 * mp + 2) / 5 + day - 1; // [0, 365]
424    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
425    era * 146_097 + doe - 719_468
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn unconditional_request_is_stale() {
434        assert!(!fresh(&Request::new(), &Response::new()));
435    }
436
437    #[test]
438    fn star_if_none_match_is_fresh() {
439        let req = Request::new().if_none_match("*");
440        assert!(fresh(&req, &Response::new()));
441    }
442
443    #[test]
444    fn etag_strong_and_weak_matching() {
445        let req = Request::new().if_none_match("\"foo\"");
446        assert!(fresh(&req, &Response::new().etag("\"foo\"")));
447        assert!(!fresh(&req, &Response::new().etag("\"bar\"")));
448        // weak request tag vs strong response tag and vice versa
449        assert!(fresh(
450            &Request::new().if_none_match("W/\"foo\""),
451            &Response::new().etag("\"foo\"")
452        ));
453        assert!(fresh(&req, &Response::new().etag("W/\"foo\"")));
454    }
455
456    #[test]
457    fn etag_list_matches_any() {
458        let req = Request::new().if_none_match("\"a\" , \"b\", \"c\"");
459        assert!(fresh(&req, &Response::new().etag("\"b\"")));
460        assert!(!fresh(&req, &Response::new().etag("\"z\"")));
461    }
462
463    #[test]
464    fn missing_response_etag_is_stale() {
465        let req = Request::new().if_none_match("\"foo\"");
466        assert!(!fresh(&req, &Response::new()));
467    }
468
469    #[test]
470    fn no_cache_forces_stale() {
471        let res = Response::new().etag("\"foo\"");
472        assert!(!fresh(
473            &Request::new()
474                .if_none_match("\"foo\"")
475                .cache_control("no-cache"),
476            &res
477        ));
478        assert!(!fresh(
479            &Request::new()
480                .if_none_match("\"foo\"")
481                .cache_control("max-age=0, no-cache"),
482            &res
483        ));
484        // not a bounded no-cache token → ignored
485        assert!(fresh(
486            &Request::new()
487                .if_none_match("\"foo\"")
488                .cache_control("no-cachex"),
489            &res
490        ));
491        assert!(fresh(
492            &Request::new()
493                .if_none_match("\"foo\"")
494                .cache_control("public, max-age=0"),
495            &res
496        ));
497    }
498
499    #[test]
500    fn modified_since_dates() {
501        let res = Response::new().last_modified("Sun, 06 Nov 1994 08:49:37 GMT");
502        // last-modified == if-modified-since → fresh
503        assert!(fresh(
504            &Request::new().if_modified_since("Sun, 06 Nov 1994 08:49:37 GMT"),
505            &res
506        ));
507        // last-modified older → fresh
508        assert!(fresh(
509            &Request::new().if_modified_since("Mon, 07 Nov 1994 08:49:37 GMT"),
510            &res
511        ));
512        // last-modified newer → stale
513        assert!(!fresh(
514            &Request::new().if_modified_since("Sat, 05 Nov 1994 08:49:37 GMT"),
515            &res
516        ));
517    }
518
519    #[test]
520    fn missing_last_modified_is_stale() {
521        let req = Request::new().if_modified_since("Sun, 06 Nov 1994 08:49:37 GMT");
522        assert!(!fresh(&req, &Response::new()));
523    }
524
525    #[test]
526    fn unparseable_date_is_stale() {
527        let req = Request::new().if_modified_since("not a date");
528        let res = Response::new().last_modified("Sun, 06 Nov 1994 08:49:37 GMT");
529        assert!(!fresh(&req, &res));
530    }
531
532    #[test]
533    fn empty_headers_treated_as_absent() {
534        assert!(!fresh(
535            &Request::new().if_modified_since("").if_none_match(""),
536            &Response::new()
537        ));
538    }
539
540    #[test]
541    fn all_three_date_formats_parse_equal() {
542        let imf = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
543        let rfc850 = parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").unwrap();
544        let asctime = parse_http_date("Sun Nov  6 08:49:37 1994").unwrap();
545        assert_eq!(imf, rfc850);
546        assert_eq!(imf, asctime);
547        assert_eq!(imf, 784_111_777);
548    }
549
550    #[test]
551    fn rfc850_two_digit_year_pivot() {
552        let y49 = parse_http_date("Sun, 06-Nov-49 00:00:00 GMT").unwrap();
553        let y50 = parse_http_date("Sun, 06-Nov-50 00:00:00 GMT").unwrap();
554        assert!(y49 > y50); // 2049 vs 1950
555    }
556}