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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub struct Served {
72    /// serve-net's outcome.
73    pub outcome: Outcome,
74    /// The route that ran, if one did.
75    pub route: Option<RouteId>,
76    /// Whether a cache in front may keep the reply.
77    pub cache: Cacheable,
78}
79
80/// Response compression with gzip (compress-lib), for deployments where
81/// nothing in front compresses.
82///
83/// Compression together with secrets and reflected input in one page can
84/// leak the secrets (BREACH). csrf-sec masks its tokens per response, which
85/// is the defence for them; other secrets in compressed pages are the
86/// application's to consider.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub struct Compression {
89    pub(crate) min_bytes: usize,
90    pub(crate) level: u8,
91}
92
93impl Default for Compression {
94    fn default() -> Self {
95        Self {
96            min_bytes: 1024,
97            level: 6,
98        }
99    }
100}
101
102impl Compression {
103    /// Bodies smaller than this are sent as they are (default 1024).
104    #[must_use]
105    pub fn min_bytes(mut self, bytes: usize) -> Self {
106        self.min_bytes = bytes;
107        self
108    }
109
110    /// The level, 1 (fastest) to 9 (smallest); default 6.
111    #[must_use]
112    pub fn level(mut self, level: u8) -> Self {
113        self.level = level.clamp(1, 9);
114        self
115    }
116}
117
118/// Media types worth compressing.
119pub(crate) fn compressible(content_type: &[u8]) -> bool {
120    let t = content_type.split(|&b| b == b';').next().unwrap_or(b"");
121    let t = t.trim_ascii();
122    t.starts_with(b"text/")
123        || matches!(
124            t,
125            b"application/json"
126                | b"application/javascript"
127                | b"application/xml"
128                | b"application/manifest+json"
129                | b"application/rss+xml"
130                | b"application/atom+xml"
131                | b"image/svg+xml"
132        )
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_policy_values() {
141        assert_eq!(CachePolicy::Unset.value(true), None);
142        assert_eq!(
143            CachePolicy::Guests { max_age: 60 }.value(true).unwrap(),
144            "public, max-age=60"
145        );
146        assert_eq!(
147            CachePolicy::Guests { max_age: 60 }.value(false).unwrap(),
148            "private, no-store"
149        );
150        assert_eq!(
151            CachePolicy::Private { max_age: 5 }.value(false).unwrap(),
152            "private, max-age=5"
153        );
154    }
155
156    #[test]
157    fn test_compressible() {
158        assert!(compressible(b"text/html; charset=utf-8"));
159        assert!(compressible(b"application/json"));
160        assert!(!compressible(b"image/png"));
161        assert!(!compressible(b""));
162    }
163}