Skip to main content

web_net/
cx.rs

1//! The handler's context: the cleared request, its identity, its route
2//! parameters, and the reply being written.
3
4use std::borrow::Cow;
5use std::fmt;
6use std::str::FromStr;
7
8use serve_net::{Method, Verdict};
9
10use crate::app::Shared;
11use crate::error::Error;
12use crate::router::Captures;
13use crate::{Output, Request, RequestId, Result, Stage, TemplateId, View, url};
14
15/// The reply a handler writes. Held by web-net until the handler returns, so
16/// an error or a panic replaces it whole: nothing a failed handler set (a
17/// cookie, a header) leaves.
18#[derive(Default)]
19pub(crate) struct Out {
20    pub(crate) status: u16,
21    pub(crate) names: Vec<u8>,
22    pub(crate) values: Vec<u8>,
23    pub(crate) spans: Vec<(u32, u32, u32, u32)>,
24    pub(crate) body: Vec<u8>,
25    pub(crate) invalid: bool,
26    pub(crate) has_type: bool,
27}
28
29/// Fields a handler may not set: framing is serve-net's.
30const FRAMING: [&str; 5] = [
31    "content-length",
32    "transfer-encoding",
33    "connection",
34    "keep-alive",
35    "upgrade",
36];
37
38impl Out {
39    pub(crate) fn reset(&mut self) {
40        self.status = 200;
41        self.names.clear();
42        self.values.clear();
43        self.spans.clear();
44        self.body.clear();
45        self.invalid = false;
46        self.has_type = false;
47    }
48
49    pub(crate) fn wipe(&mut self) {
50        // black_box keeps the zeroing from being dropped as a dead store.
51        for v in [&mut self.names, &mut self.values, &mut self.body] {
52            v.iter_mut().for_each(|b| *b = 0);
53            std::hint::black_box(&v);
54        }
55    }
56
57    pub(crate) fn header(&mut self, name: &str, value: &[u8]) {
58        let ok_name = !name.is_empty()
59            && name.bytes().all(is_tchar)
60            && !FRAMING.iter().any(|f| f.eq_ignore_ascii_case(name));
61        let ok_value = value
62            .iter()
63            .all(|&b| b == b'\t' || (b >= 0x20 && b != 0x7f));
64        if !ok_name || !ok_value || self.spans.len() >= 64 {
65            self.invalid = true;
66            return;
67        }
68        if name.eq_ignore_ascii_case("content-type") {
69            self.has_type = true;
70        }
71        let (ns, vs) = (self.names.len() as u32, self.values.len() as u32);
72        self.names
73            .extend(name.bytes().map(|b| b.to_ascii_lowercase()));
74        self.values.extend_from_slice(value);
75        self.spans
76            .push((ns, self.names.len() as u32, vs, self.values.len() as u32));
77    }
78
79    pub(crate) fn has(&self, name: &str) -> bool {
80        self.spans
81            .iter()
82            .any(|&(s, e, _, _)| &self.names[s as usize..e as usize] == name.as_bytes())
83    }
84}
85
86const fn is_tchar(b: u8) -> bool {
87    matches!(b,
88        b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9'
89        | b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.'
90        | b'^' | b'_' | b'`' | b'|' | b'~')
91}
92
93/// A site-relative path that is safe as a redirect target: it starts with
94/// one `/` (never `//` or `/\`, which browsers read as another host) and
95/// holds only visible ASCII.
96pub(crate) fn is_local_target(p: &str) -> bool {
97    let b = p.as_bytes();
98    b.first() == Some(&b'/')
99        && !matches!(b.get(1), Some(b'/' | b'\\'))
100        && b.iter().all(|&c| (0x21..0x7f).contains(&c) && c != b'\\')
101}
102
103/// The context a handler receives: proof that the request passed its
104/// route's access check, the identity it carries, and the reply.
105///
106/// There is no public way to make one: web-net builds it only after the
107/// gate cleared the request.
108pub struct Cx<'r, I> {
109    pub(crate) req: Request<'r>,
110    pub(crate) who: &'r I,
111    pub(crate) caps: Captures,
112    pub(crate) names: &'r [Box<str>],
113    pub(crate) out: &'r mut Out,
114    pub(crate) shared: &'r Shared,
115    pub(crate) template: Option<TemplateId>,
116}
117
118impl<'r, I> Cx<'r, I> {
119    // ---------------------------------------------------------------- request
120
121    /// The request.
122    #[must_use]
123    pub fn request(&self) -> &Request<'r> {
124        &self.req
125    }
126
127    /// The method.
128    #[must_use]
129    pub fn method(&self) -> Method {
130        self.req.method()
131    }
132
133    /// The canonical path inside the site.
134    #[must_use]
135    pub fn path(&self) -> &'r str {
136        self.req.path
137    }
138
139    /// The first value of a request field.
140    #[must_use]
141    pub fn header(&self, name: &str) -> Option<&'r [u8]> {
142        self.req.header(name)
143    }
144
145    /// Who the request comes from: always present, possibly a guest.
146    #[must_use]
147    pub fn identity(&self) -> &'r I {
148        self.who
149    }
150
151    /// The request's reference, as shown on error pages.
152    #[must_use]
153    pub fn id(&self) -> RequestId {
154        self.req.id()
155    }
156
157    /// A route parameter (`:id` or `*rest`), percent-decoded.
158    #[must_use]
159    pub fn param(&self, name: &str) -> Option<Cow<'r, str>> {
160        let i = self.names.iter().position(|n| n.as_ref() == name)?;
161        let (s, e) = self.caps.get(i)?;
162        Some(url::decode(&self.req.path[s..e]))
163    }
164
165    /// A route parameter parsed as `T`. A value that does not parse means
166    /// the page does not exist (`/users/abc` for a numeric id): 404.
167    ///
168    /// # Errors
169    ///
170    /// 404 `route.param` when the parameter is missing or does not parse.
171    pub fn param_as<T: FromStr>(&self, name: &str) -> Result<T> {
172        self.param(name)
173            .and_then(|v| v.parse().ok())
174            .ok_or_else(|| Error::not_found("route.param"))
175    }
176
177    /// The first value of a query parameter, decoded (`+` is a space).
178    ///
179    /// # Errors
180    ///
181    /// 400 `query.duplicate` when the name appears twice (parameter
182    /// pollution is refused, never resolved by picking one), 400
183    /// `query.invalid` when the value does not decode to UTF-8.
184    pub fn query(&self, name: &str) -> Result<Option<Cow<'r, str>>> {
185        let Some(q) = self.req.query() else {
186            return Ok(None);
187        };
188        let mut found = None;
189        for pair in q.split('&') {
190            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
191            if decode_query(k).as_deref() != Some(name) {
192                continue;
193            }
194            if found.is_some() {
195                return Err(Error::bad_request("query.duplicate"));
196            }
197            found = Some(decode_query(v).ok_or_else(|| Error::bad_request("query.invalid"))?);
198        }
199        Ok(found)
200    }
201
202    /// The request body.
203    ///
204    /// # Errors
205    ///
206    /// None today; reading the body is fallible so CSRF and body inspection
207    /// can refuse it in later versions without changing handlers.
208    pub fn body(&self) -> Result<&'r [u8]> {
209        Ok(self.req.http.body_bytes())
210    }
211
212    // ---------------------------------------------------------------- reply
213
214    /// Sets the status (default 200).
215    pub fn status(&mut self, status: u16) -> &mut Self {
216        self.out.status = status;
217        self
218    }
219
220    /// Adds a reply field. A name that is not a token, a value with a
221    /// control character, or a framing field (`content-length`, ...) turns
222    /// the reply into a 500 when the handler returns.
223    pub fn set_header(&mut self, name: &str, value: impl AsRef<[u8]>) -> &mut Self {
224        self.out.header(name, value.as_ref());
225        self
226    }
227
228    /// Sets the body.
229    pub fn body_bytes(&mut self, bytes: &[u8]) -> &mut Self {
230        self.out.body.clear();
231        self.out.body.extend_from_slice(bytes);
232        self
233    }
234
235    /// Status, `content-type: text/html; charset=utf-8`, and the HTML.
236    pub fn html(&mut self, status: u16, html: &str) -> &mut Self {
237        self.content_type("text/html; charset=utf-8");
238        self.status(status).body_bytes(html.as_bytes())
239    }
240
241    /// Status, `content-type: text/plain; charset=utf-8`, and the text.
242    pub fn text(&mut self, status: u16, text: &str) -> &mut Self {
243        self.content_type("text/plain; charset=utf-8");
244        self.status(status).body_bytes(text.as_bytes())
245    }
246
247    fn content_type(&mut self, value: &str) {
248        if !self.out.has_type {
249            self.out.header("content-type", value.as_bytes());
250        }
251    }
252
253    /// The site-relative `path` as a URL path: the site's base path in
254    /// front (`/blog` + `/posts/1`). Every link a page writes goes through
255    /// this, so the site works installed in a folder.
256    #[must_use]
257    pub fn url(&self, path: &str) -> String {
258        let base = self.shared.base.as_ref();
259        if path == "/" && !base.is_empty() {
260            format!("{base}/")
261        } else {
262            format!("{base}{path}")
263        }
264    }
265
266    /// `303 See Other` to a site-relative path: the answer to a form that
267    /// was accepted, so a reload does not post it again.
268    ///
269    /// # Errors
270    ///
271    /// 500 `redirect.not_local` when `path` is not a local path (it must
272    /// start with one `/`): redirects to other hosts are never built from a
273    /// string a handler passes along.
274    pub fn see_other(&mut self, path: &str) -> Result {
275        self.redirect(303, path)
276    }
277
278    /// A redirect with `status` (301, 302, 303, 307, 308) to a
279    /// site-relative path.
280    ///
281    /// # Errors
282    ///
283    /// As [`see_other`](Self::see_other); 500 `redirect.status` for a
284    /// status that is not a redirect.
285    pub fn redirect(&mut self, status: u16, path: &str) -> Result {
286        if !matches!(status, 301 | 302 | 303 | 307 | 308) {
287            return Err(Error::internal("redirect.status"));
288        }
289        if !is_local_target(path) {
290            return Err(Error::internal("redirect.not_local"));
291        }
292        let location = self.url(path);
293        self.out.header("location", location.as_bytes());
294        self.out.status = status;
295        self.out.body.clear();
296        Ok(())
297    }
298
299    /// Renders the route's template (declared with
300    /// [`Route::template`](crate::Route::template)) against `view` into the
301    /// body. The status stays as set (200 by default); `content-type` is
302    /// HTML unless the handler set one.
303    ///
304    /// # Errors
305    ///
306    /// 500 `render.no_engine` or `render.no_template` when nothing can
307    /// render; the engine's own [`RenderError`](crate::RenderError) as a 500;
308    /// a checkpoint's denial.
309    pub fn render(&mut self, view: &dyn View) -> Result {
310        let t = self
311            .template
312            .ok_or_else(|| Error::internal("render.no_template"))?;
313        self.render_id(t, view)
314    }
315
316    /// Renders the template `name` against `view`. The name is resolved by
317    /// the engine on every call; prefer [`render`](Self::render) with a
318    /// template declared on the route, resolved once at build.
319    ///
320    /// # Errors
321    ///
322    /// As [`render`](Self::render).
323    pub fn render_named(&mut self, name: &str, view: &dyn View) -> Result {
324        let engine = self
325            .shared
326            .render
327            .as_deref()
328            .ok_or_else(|| Error::internal("render.no_engine"))?;
329        let t = engine
330            .template(name)
331            .ok_or_else(|| Error::internal("render.no_template"))?;
332        self.render_id(t, view)
333    }
334
335    fn render_id(&mut self, t: TemplateId, view: &dyn View) -> Result {
336        let engine = self
337            .shared
338            .render
339            .as_deref()
340            .ok_or_else(|| Error::internal("render.no_engine"))?;
341        if let Some(cp) = &self.shared.checkpoint {
342            match cp.check(self.req.env, Stage::BeforeRender) {
343                Verdict::Pass => {}
344                Verdict::Deny { status } => return Err(Error::new(status, "checkpoint.denied")),
345                _ => return Err(Error::forbidden("checkpoint.killed")),
346            }
347        }
348        self.out.body.clear();
349        let mut output = Output::new(&mut self.out.body, self.shared.page_limit);
350        let rendered = engine.render(t, view, &mut output);
351        if let Err(e) = rendered {
352            self.out.body.clear();
353            return Err(e.into());
354        }
355        self.content_type("text/html; charset=utf-8");
356        Ok(())
357    }
358}
359
360impl<I> fmt::Debug for Cx<'_, I> {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        f.debug_struct("Cx")
363            .field("request", &self.req)
364            .field("status", &self.out.status)
365            .finish_non_exhaustive()
366    }
367}
368
369/// Decodes one query name or value: `+` is a space, escapes decoded once.
370fn decode_query(s: &str) -> Option<Cow<'_, str>> {
371    if !s.contains(['%', '+']) {
372        return Some(Cow::Borrowed(s));
373    }
374    let b = s.as_bytes();
375    let mut out = Vec::with_capacity(b.len());
376    let mut i = 0;
377    while i < b.len() {
378        match b[i] {
379            b'+' => {
380                out.push(b' ');
381                i += 1;
382            }
383            b'%' => {
384                let h = b.get(i + 1).and_then(|&c| (c as char).to_digit(16))?;
385                let l = b.get(i + 2).and_then(|&c| (c as char).to_digit(16))?;
386                out.push(((h << 4) | l) as u8);
387                i += 3;
388            }
389            c => {
390                out.push(c);
391                i += 1;
392            }
393        }
394    }
395    String::from_utf8(out).ok().map(Cow::Owned)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_local_targets() {
404        for ok in ["/", "/a", "/a/b?c=d", "/a%20b"] {
405            assert!(is_local_target(ok), "{ok}");
406        }
407        for bad in [
408            "",
409            "a",
410            "//evil.example",
411            "/\\evil.example",
412            "https://x",
413            "/a\r\nx",
414            "/a b",
415            "/a\\b",
416        ] {
417            assert!(!is_local_target(bad), "{bad:?}");
418        }
419    }
420
421    #[test]
422    fn test_decode_query() {
423        assert_eq!(decode_query("a+b%21").as_deref(), Some("a b!"));
424        assert_eq!(decode_query("plain").as_deref(), Some("plain"));
425        assert_eq!(decode_query("%zz"), None);
426        assert_eq!(decode_query("%ff"), None);
427    }
428
429    #[test]
430    fn test_out_refuses_bad_fields() {
431        let mut o = Out::default();
432        o.header("x-ok", b"v");
433        assert!(!o.invalid);
434        o.header("content-length", b"1");
435        assert!(o.invalid);
436        let mut o = Out::default();
437        o.header("x", b"a\r\nb");
438        assert!(o.invalid);
439        let mut o = Out::default();
440        o.header("bad name", b"v");
441        assert!(o.invalid);
442        let mut o = Out::default();
443        o.header("Content-Type", b"text/x");
444        assert!(o.has_type && o.has("content-type"));
445    }
446}