Skip to main content

web_net/
policy.rs

1//! Per-route cache policy, the cacheability report, and response
2//! compression.
3
4use serve_net::Outcome;
5
6use crate::RouteId;
7
8/// How a route's pages may be cached. Applied as `Cache-Control` unless the
9/// handler set one itself.
10#[non_exhaustive]
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum CachePolicy {
13    /// No `Cache-Control` from web-net (the default): the handler decides.
14    #[default]
15    Unset,
16    /// `no-store`: never kept (account pages, anything personal).
17    NoStore,
18    /// `private, max-age=N`: the visitor's browser only.
19    Private {
20        /// Seconds.
21        max_age: u32,
22    },
23    /// `public, max-age=N` for everyone.
24    Public {
25        /// Seconds.
26        max_age: u32,
27    },
28    /// `public, max-age=N` for guests, `private, no-store` for signed-in
29    /// visitors: the same page, cached only while it is the same for
30    /// everyone. A cache in front of web-net may keep guests' copies
31    /// ([`Served::cache`]).
32    Guests {
33        /// Seconds.
34        max_age: u32,
35    },
36}
37
38impl CachePolicy {
39    /// The `Cache-Control` value for a visitor.
40    pub(crate) fn value(self, guest: bool) -> Option<String> {
41        match self {
42            Self::Unset => None,
43            Self::NoStore => Some("no-store".into()),
44            Self::Private { max_age } => Some(format!("private, max-age={max_age}")),
45            Self::Public { max_age } => Some(format!("public, max-age={max_age}")),
46            Self::Guests { max_age } if guest => Some(format!("public, max-age={max_age}")),
47            Self::Guests { .. } => Some("private, no-store".into()),
48        }
49    }
50}
51
52/// Whether a cache in front of web-net may keep this reply for guests.
53#[non_exhaustive]
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum Cacheable {
56    /// Do not keep it.
57    #[default]
58    No,
59    /// Keep it for guests' `GET` and `HEAD` of the same target, this long.
60    /// Said only when the route's policy allows it, the request was a
61    /// guest's `GET` or `HEAD`, the status is 200, and the reply sets no
62    /// cookie and varies on nothing but `Accept-Encoding`.
63    Guests {
64        /// Seconds.
65        max_age: u32,
66    },
67}
68
69/// What [`App::serve`](crate::App::serve) reports with the outcome.
70#[non_exhaustive]
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub struct Served {
73    /// serve-net's outcome.
74    pub outcome: Outcome,
75    /// The route that ran, if one did.
76    pub route: Option<RouteId>,
77    /// Whether a cache in front may keep the reply.
78    pub cache: Cacheable,
79}
80
81/// Response compression with gzip (compress-lib), for deployments where
82/// nothing in front compresses.
83///
84/// Compression together with secrets and reflected input in one page can
85/// leak the secrets (BREACH). csrf-sec masks its tokens per response, which
86/// is the defence for them; other secrets in compressed pages are the
87/// application's to consider.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub struct Compression {
90    pub(crate) min_bytes: usize,
91    pub(crate) level: u8,
92}
93
94impl Default for Compression {
95    fn default() -> Self {
96        Self {
97            min_bytes: 1024,
98            level: 6,
99        }
100    }
101}
102
103impl Compression {
104    /// Bodies smaller than this are sent as they are (default 1024).
105    #[must_use]
106    pub fn min_bytes(mut self, bytes: usize) -> Self {
107        self.min_bytes = bytes;
108        self
109    }
110
111    /// The level, 1 (fastest) to 9 (smallest); default 6.
112    #[must_use]
113    pub fn level(mut self, level: u8) -> Self {
114        self.level = level.clamp(1, 9);
115        self
116    }
117}
118
119/// Media types worth compressing.
120pub(crate) fn compressible(content_type: &[u8]) -> bool {
121    let t = content_type.split(|&b| b == b';').next().unwrap_or(b"");
122    let t = t.trim_ascii();
123    t.starts_with(b"text/")
124        || matches!(
125            t,
126            b"application/json"
127                | b"application/javascript"
128                | b"application/xml"
129                | b"application/manifest+json"
130                | b"application/rss+xml"
131                | b"application/atom+xml"
132                | b"image/svg+xml"
133        )
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_policy_values() {
142        assert_eq!(CachePolicy::Unset.value(true), None);
143        assert_eq!(
144            CachePolicy::Guests { max_age: 60 }.value(true).unwrap(),
145            "public, max-age=60"
146        );
147        assert_eq!(
148            CachePolicy::Guests { max_age: 60 }.value(false).unwrap(),
149            "private, no-store"
150        );
151        assert_eq!(
152            CachePolicy::Private { max_age: 5 }.value(false).unwrap(),
153            "private, max-age=5"
154        );
155    }
156
157    #[test]
158    fn test_compressible() {
159        assert!(compressible(b"text/html; charset=utf-8"));
160        assert!(compressible(b"application/json"));
161        assert!(!compressible(b"image/png"));
162        assert!(!compressible(b""));
163    }
164}