Skip to main content

rover/fetcher/
cache_control.rs

1//! RFC 9111 §5.2 `Cache-Control` directive parser (response side).
2//!
3//! Only the directives we use today: `max-age`, `s-maxage`, `no-store`,
4//! `no-cache`, `must-revalidate`, `public`, `private`. Unknown directives
5//! are tolerated and ignored — robust to non-compliant origins.
6
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct CacheControl {
9    pub max_age: Option<u64>,
10    pub s_maxage: Option<u64>,
11    pub no_store: bool,
12    pub no_cache: bool,
13    pub must_revalidate: bool,
14    pub public: bool,
15    pub private: bool,
16}
17
18impl CacheControl {
19    /// Parse a `Cache-Control` header value. Multiple `Cache-Control` headers
20    /// may be combined by the caller into a single comma-separated string
21    /// before parsing.
22    pub fn parse(header: &str) -> Self {
23        let mut out = Self::default();
24        for token in header.split(',') {
25            let token = token.trim();
26            if token.is_empty() {
27                continue;
28            }
29            let (name, value) = match token.split_once('=') {
30                Some((n, v)) => (n.trim(), Some(strip_quotes(v.trim()))),
31                None => (token, None),
32            };
33            match name.to_ascii_lowercase().as_str() {
34                "max-age" => out.max_age = value.and_then(|v| v.parse().ok()),
35                "s-maxage" => out.s_maxage = value.and_then(|v| v.parse().ok()),
36                "no-store" => out.no_store = true,
37                "no-cache" => out.no_cache = true,
38                "must-revalidate" => out.must_revalidate = true,
39                "public" => out.public = true,
40                "private" => out.private = true,
41                _ => {} // ignore unknowns
42            }
43        }
44        out
45    }
46}
47
48fn strip_quotes(s: &str) -> &str {
49    let s = s.trim();
50    if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
51        &s[1..s.len() - 1]
52    } else {
53        s
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn empty_is_default() {
63        assert_eq!(CacheControl::parse(""), CacheControl::default());
64    }
65
66    #[test]
67    fn parses_max_age() {
68        let cc = CacheControl::parse("max-age=3600");
69        assert_eq!(cc.max_age, Some(3600));
70    }
71
72    #[test]
73    fn parses_quoted_values() {
74        let cc = CacheControl::parse(r#"max-age="600""#);
75        assert_eq!(cc.max_age, Some(600));
76    }
77
78    #[test]
79    fn case_insensitive_directives() {
80        let cc = CacheControl::parse("MAX-AGE=42, NO-STORE");
81        assert_eq!(cc.max_age, Some(42));
82        assert!(cc.no_store);
83    }
84
85    #[test]
86    fn parses_combined_directives() {
87        let cc = CacheControl::parse("public, max-age=300, must-revalidate");
88        assert!(cc.public);
89        assert_eq!(cc.max_age, Some(300));
90        assert!(cc.must_revalidate);
91        assert!(!cc.no_store);
92    }
93
94    #[test]
95    fn s_maxage_separate_from_max_age() {
96        let cc = CacheControl::parse("max-age=60, s-maxage=600");
97        assert_eq!(cc.max_age, Some(60));
98        assert_eq!(cc.s_maxage, Some(600));
99    }
100
101    #[test]
102    fn ignores_unknown_directives() {
103        let cc = CacheControl::parse("immutable, max-age=100, stale-while-revalidate=30");
104        assert_eq!(cc.max_age, Some(100));
105        assert!(!cc.no_store);
106    }
107
108    #[test]
109    fn no_store_no_cache_are_independent() {
110        let cc = CacheControl::parse("no-store");
111        assert!(cc.no_store && !cc.no_cache);
112        let cc = CacheControl::parse("no-cache");
113        assert!(!cc.no_store && cc.no_cache);
114    }
115
116    #[test]
117    fn malformed_max_age_yields_none() {
118        let cc = CacheControl::parse("max-age=not-a-number");
119        assert_eq!(cc.max_age, None);
120    }
121}