Skip to main content

nano_web/
response_buffer.rs

1use bytes::Bytes;
2use hyper::header::{self, HeaderMap, HeaderName, HeaderValue};
3use std::sync::Arc;
4
5/// Security headers identical on every 200 response. Stamped from `&'static str`
6/// so each insertion is a cheap shared `HeaderValue`, not a parse.
7const STATIC_SECURITY_HEADERS: [(HeaderName, &str); 6] = [
8    (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9    (header::X_FRAME_OPTIONS, "SAMEORIGIN"),
10    (header::REFERRER_POLICY, "strict-origin-when-cross-origin"),
11    (
12        header::STRICT_TRANSPORT_SECURITY,
13        "max-age=63072000; includeSubDomains",
14    ),
15    (
16        HeaderName::from_static("permissions-policy"),
17        "camera=(), microphone=(), geolocation=()",
18    ),
19    (HeaderName::from_static("x-dns-prefetch-control"), "off"),
20];
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum Encoding {
24    Identity,
25    Gzip,
26    Brotli,
27    Zstd,
28}
29
30impl Encoding {
31    pub const ALL: [Self; 4] = [Self::Identity, Self::Gzip, Self::Brotli, Self::Zstd];
32
33    /// Parse Accept-Encoding header, priority: br > zstd > gzip > identity.
34    /// Splits on comma to avoid substring false positives (e.g. "br" matching "vibrant").
35    /// Respects q=0 (encoding explicitly rejected by client).
36    pub fn from_accept_encoding(accept: &str) -> Self {
37        let mut best = Self::Identity;
38        for part in accept.split(',') {
39            let mut segments = part.split(';');
40            let token = segments.next().unwrap_or("").trim();
41
42            // q=0 means the encoding is explicitly rejected
43            let rejected = segments.any(|s| {
44                s.trim()
45                    .strip_prefix("q=")
46                    .and_then(|v| v.trim().parse::<f32>().ok())
47                    .is_some_and(|q| q == 0.0)
48            });
49            if rejected {
50                continue;
51            }
52
53            match token {
54                "br" => return Self::Brotli, // highest priority, short-circuit
55                "zstd" => best = Self::Zstd,
56                "gzip" if !matches!(best, Self::Zstd) => best = Self::Gzip,
57                _ => {}
58            }
59        }
60        best
61    }
62}
63
64#[derive(Debug, Clone)]
65pub struct ResponseBuffer {
66    pub body: Bytes,
67    pub content_type: Arc<str>,
68    pub content_encoding: Option<&'static str>,
69    pub etag: Arc<str>,
70    pub last_modified: Arc<str>,
71    pub cache_control: Arc<str>,
72    pub content_length: Arc<str>,
73    /// Whether Vary: Accept-Encoding should be sent (true for all compressible types)
74    pub vary_encoding: bool,
75    /// Fully-built header block for the 200 response. Precomputed once at route
76    /// creation so the hot path clones it instead of re-inserting ~13 headers
77    /// per request. The body is appended by the server (or dropped for HEAD).
78    pub headers: HeaderMap,
79}
80
81impl ResponseBuffer {
82    pub fn new(
83        body: Bytes,
84        content_type: Arc<str>,
85        content_encoding: Option<&'static str>,
86        etag: Arc<str>,
87        last_modified: Arc<str>,
88        cache_control: Arc<str>,
89        vary_encoding: bool,
90    ) -> Self {
91        let content_length: Arc<str> = Arc::from(body.len().to_string().as_str());
92        let headers = build_headers(
93            &content_type,
94            content_encoding,
95            &etag,
96            &last_modified,
97            &cache_control,
98            &content_length,
99            vary_encoding,
100        );
101        Self {
102            body,
103            content_type,
104            content_encoding,
105            etag,
106            last_modified,
107            cache_control,
108            content_length,
109            vary_encoding,
110            headers,
111        }
112    }
113}
114
115/// Build the complete 200-response header block. All values are server-controlled
116/// (mime types, hex etags, HTTP dates, digit content-lengths), so they are always
117/// valid header values — an invalid one is a bug, hence `expect`.
118fn build_headers(
119    content_type: &str,
120    content_encoding: Option<&'static str>,
121    etag: &str,
122    last_modified: &str,
123    cache_control: &str,
124    content_length: &str,
125    vary_encoding: bool,
126) -> HeaderMap {
127    let mut h = HeaderMap::with_capacity(13);
128    let val = |s: &str| HeaderValue::from_str(s).expect("server-controlled header value");
129
130    h.insert(header::CONTENT_TYPE, val(content_type));
131    h.insert(header::ETAG, val(etag));
132    h.insert(header::LAST_MODIFIED, val(last_modified));
133    h.insert(header::CACHE_CONTROL, val(cache_control));
134    h.insert(header::CONTENT_LENGTH, val(content_length));
135    if let Some(encoding) = content_encoding {
136        h.insert(header::CONTENT_ENCODING, HeaderValue::from_static(encoding));
137    }
138    if vary_encoding {
139        h.insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
140    }
141    for (name, value) in STATIC_SECURITY_HEADERS {
142        h.insert(name, HeaderValue::from_static(value));
143    }
144    h
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_encoding_priority() {
153        assert_eq!(
154            Encoding::from_accept_encoding("gzip, br, zstd"),
155            Encoding::Brotli
156        );
157        assert_eq!(Encoding::from_accept_encoding("br"), Encoding::Brotli);
158        assert_eq!(Encoding::from_accept_encoding("gzip, zstd"), Encoding::Zstd);
159        assert_eq!(Encoding::from_accept_encoding("zstd"), Encoding::Zstd);
160        assert_eq!(Encoding::from_accept_encoding("gzip"), Encoding::Gzip);
161        assert_eq!(
162            Encoding::from_accept_encoding("deflate"),
163            Encoding::Identity
164        );
165        assert_eq!(Encoding::from_accept_encoding(""), Encoding::Identity);
166    }
167
168    #[test]
169    fn test_encoding_no_substring_false_positives() {
170        assert_eq!(
171            Encoding::from_accept_encoding("vibrant"),
172            Encoding::Identity
173        );
174        assert_eq!(Encoding::from_accept_encoding("broken"), Encoding::Identity);
175    }
176
177    #[test]
178    fn test_encoding_with_quality_values() {
179        assert_eq!(
180            Encoding::from_accept_encoding("gzip;q=1.0, br;q=0.8"),
181            Encoding::Brotli
182        );
183        assert_eq!(
184            Encoding::from_accept_encoding("gzip;q=0.5, zstd;q=1.0"),
185            Encoding::Zstd
186        );
187    }
188
189    #[test]
190    fn test_encoding_respects_q_zero() {
191        // q=0 means explicitly rejected
192        assert_eq!(
193            Encoding::from_accept_encoding("br;q=0, gzip"),
194            Encoding::Gzip
195        );
196        assert_eq!(
197            Encoding::from_accept_encoding("br;q=0, zstd;q=0, gzip"),
198            Encoding::Gzip
199        );
200        assert_eq!(
201            Encoding::from_accept_encoding("br;q=0, zstd;q=0, gzip;q=0"),
202            Encoding::Identity
203        );
204        assert_eq!(
205            Encoding::from_accept_encoding("gzip;q=0"),
206            Encoding::Identity
207        );
208    }
209}