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 get(&self, name: &str) -> Option<&[u8]> {
80        self.spans
81            .iter()
82            .find(|&&(s, e, _, _)| &self.names[s as usize..e as usize] == name.as_bytes())
83            .map(|&(_, _, vs, ve)| &self.values[vs as usize..ve as usize])
84    }
85
86    pub(crate) fn has(&self, name: &str) -> bool {
87        self.spans
88            .iter()
89            .any(|&(s, e, _, _)| &self.names[s as usize..e as usize] == name.as_bytes())
90    }
91}
92
93const fn is_tchar(b: u8) -> bool {
94    matches!(b,
95        b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9'
96        | b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.'
97        | b'^' | b'_' | b'`' | b'|' | b'~')
98}
99
100/// A site-relative path that is safe as a redirect target: it starts with
101/// one `/` (never `//` or `/\`, which browsers read as another host) and
102/// holds only visible ASCII.
103pub(crate) fn is_local_target(p: &str) -> bool {
104    let b = p.as_bytes();
105    b.first() == Some(&b'/')
106        && !matches!(b.get(1), Some(b'/' | b'\\'))
107        && b.iter().all(|&c| (0x21..0x7f).contains(&c) && c != b'\\')
108}
109
110/// The context a handler receives: proof that the request passed its
111/// route's access check, the identity it carries, and the reply.
112///
113/// There is no public way to make one: web-net builds it only after the
114/// gate cleared the request.
115pub struct Cx<'r, I> {
116    pub(crate) req: Request<'r>,
117    pub(crate) who: &'r I,
118    pub(crate) caps: Captures,
119    pub(crate) names: &'r [Box<str>],
120    pub(crate) out: &'r mut Out,
121    pub(crate) shared: &'r Shared,
122    pub(crate) template: Option<TemplateId>,
123    pub(crate) csrf: crate::csrf::State,
124    pub(crate) binding: Option<&'r [u8]>,
125    pub(crate) limits: &'r form_net::Limits,
126    pub(crate) before_render: &'r [crate::app::HookFn<I>],
127    pub(crate) rendering: bool,
128}
129
130impl<'r, I> Cx<'r, I> {
131    // ---------------------------------------------------------------- request
132
133    /// The request.
134    #[must_use]
135    pub fn request(&self) -> &Request<'r> {
136        &self.req
137    }
138
139    /// The method.
140    #[must_use]
141    pub fn method(&self) -> Method {
142        self.req.method()
143    }
144
145    /// The canonical path inside the site.
146    #[must_use]
147    pub fn path(&self) -> &'r str {
148        self.req.path
149    }
150
151    /// The first value of a request field.
152    #[must_use]
153    pub fn header(&self, name: &str) -> Option<&'r [u8]> {
154        self.req.header(name)
155    }
156
157    /// Who the request comes from: always present, possibly a guest.
158    #[must_use]
159    pub fn identity(&self) -> &'r I {
160        self.who
161    }
162
163    /// The request's reference, as shown on error pages.
164    #[must_use]
165    pub fn id(&self) -> RequestId {
166        self.req.id()
167    }
168
169    /// A route parameter (`:id` or `*rest`), percent-decoded.
170    #[must_use]
171    pub fn param(&self, name: &str) -> Option<Cow<'r, str>> {
172        let i = self.names.iter().position(|n| n.as_ref() == name)?;
173        let (s, e) = self.caps.get(i)?;
174        Some(url::decode(&self.req.path[s..e]))
175    }
176
177    /// A route parameter parsed as `T`. A value that does not parse means
178    /// the page does not exist (`/users/abc` for a numeric id): 404.
179    ///
180    /// # Errors
181    ///
182    /// 404 `route.param` when the parameter is missing or does not parse.
183    pub fn param_as<T: FromStr>(&self, name: &str) -> Result<T> {
184        self.param(name)
185            .and_then(|v| v.parse().ok())
186            .ok_or_else(|| Error::not_found("route.param"))
187    }
188
189    /// The first value of a query parameter, decoded (`+` is a space).
190    ///
191    /// # Errors
192    ///
193    /// 400 `query.duplicate` when the name appears twice (parameter
194    /// pollution is refused, never resolved by picking one), 400
195    /// `query.invalid` when the value does not decode to UTF-8.
196    pub fn query(&self, name: &str) -> Result<Option<Cow<'r, str>>> {
197        let Some(q) = self.req.query() else {
198            return Ok(None);
199        };
200        let mut found = None;
201        for pair in q.split('&') {
202            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
203            if decode_query(k).as_deref() != Some(name) {
204                continue;
205            }
206            if found.is_some() {
207                return Err(Error::bad_request("query.duplicate"));
208            }
209            found = Some(decode_query(v).ok_or_else(|| Error::bad_request("query.invalid"))?);
210        }
211        Ok(found)
212    }
213
214    /// The raw request body.
215    ///
216    /// # Errors
217    ///
218    /// 403 `csrf.unverified` for a multipart body whose CSRF token has not
219    /// been checked yet: read it with [`form`](Self::form) or
220    /// `upload` (feature `uploads`), which check the token in the same pass.
221    pub fn body(&self) -> Result<&'r [u8]> {
222        if self.csrf == crate::csrf::State::Pending {
223            return Err(Error::forbidden("csrf.unverified"));
224        }
225        Ok(self.req.http.body_bytes())
226    }
227
228    /// Reads the body as a typed form (urlencoded or multipart text fields),
229    /// with the route's [`form_limits`](crate::Route::form_limits).
230    ///
231    /// A multipart body's CSRF token is checked in the same pass (it must
232    /// come before any file part). A form type that is posted with a token
233    /// lists the token field in its `ignore` list (`ignore =
234    /// ["csrf_token"]`).
235    ///
236    /// The error keeps form-net's detail, so an action can render the form
237    /// again with 422; `?` turns it into an [`Error`] with its status.
238    ///
239    /// ```
240    /// use web_net::form_net::{self, FromForm};
241    ///
242    /// form_net::form! {
243    ///     pub struct Signup { pub email: String }
244    ///     ignore = ["csrf_token"];
245    /// }
246    ///
247    /// let app = web_net::App::builder()
248    ///     .route(web_net::post("/signup", |cx| {
249    ///         let form: Signup = cx.form()?;
250    ///         cx.text(200, &form.email);
251    ///         Ok(())
252    ///     }).public().csrf_exempt("example without a session"))
253    ///     .build()?;
254    /// let r = serve_net::testing::respond(&app,
255    ///     b"POST /signup HTTP/1.1\r\nHost: a\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 15\r\n\r\nemail=a%40b.com");
256    /// assert_eq!(r.text(), "a@b.com");
257    /// # Ok::<(), web_net::BuildError>(())
258    /// ```
259    ///
260    /// # Errors
261    ///
262    /// form-net's [`FormError`](form_net::FormError): 415 for another media
263    /// type, 413 for a budget passed, 400 for malformed input, 422 for
264    /// fields that do not fit `T`, 403 for a CSRF token.
265    pub fn form<T: form_net::FromForm>(&mut self) -> std::result::Result<T, form_net::FormError> {
266        self.form_inspected(&mut form_net::NoInspector)
267    }
268
269    /// As [`form`](Self::form), with an inspector beside the parse (a
270    /// content policy that sees every part and field first).
271    ///
272    /// # Errors
273    ///
274    /// As [`form`](Self::form), plus the inspector's refusals.
275    pub fn form_inspected<T: form_net::FromForm, N: form_net::Inspector>(
276        &mut self,
277        inspector: &mut N,
278    ) -> std::result::Result<T, form_net::FormError> {
279        let content_type = self.req.header("content-type").unwrap_or(b"");
280        let body = self.req.http.body_bytes();
281        match (self.csrf, self.shared.csrf.as_ref(), self.binding) {
282            (crate::csrf::State::Pending, Some(engine), Some(binding)) => {
283                let mut check = crate::csrf::token_check(engine, binding);
284                let result = T::from_body_inspected(
285                    content_type,
286                    body,
287                    self.limits,
288                    &crate::csrf::TOKEN_POLICY,
289                    &mut (&mut check, inspector),
290                );
291                if check.verified() {
292                    self.csrf = crate::csrf::State::Verified;
293                }
294                result
295            }
296            _ => T::from_body_inspected(
297                content_type,
298                body,
299                self.limits,
300                &form_net::Policy::DEFAULT,
301                inspector,
302            ),
303        }
304    }
305
306    /// Reads a multipart upload into `store` (feature `uploads`): text
307    /// fields in memory, files in the store's private temporary files, with
308    /// the route's form limits. The CSRF token is checked before any file
309    /// byte is stored. Turn the result into a typed form with
310    /// `T::from_upload(upload)`.
311    ///
312    /// # Errors
313    ///
314    /// As [`form`](Self::form), plus the store's errors.
315    #[cfg(feature = "uploads")]
316    #[cfg_attr(docsrs, doc(cfg(feature = "uploads")))]
317    pub fn upload(
318        &mut self,
319        store: &form_net::Store,
320    ) -> std::result::Result<form_net::Upload, form_net::FormError> {
321        self.upload_inspected(store, &mut form_net::NoInspector)
322    }
323
324    /// As [`upload`](Self::upload), with an inspector beside the parse.
325    ///
326    /// # Errors
327    ///
328    /// As [`upload`](Self::upload), plus the inspector's refusals.
329    #[cfg(feature = "uploads")]
330    #[cfg_attr(docsrs, doc(cfg(feature = "uploads")))]
331    pub fn upload_inspected<N: form_net::Inspector>(
332        &mut self,
333        store: &form_net::Store,
334        inspector: &mut N,
335    ) -> std::result::Result<form_net::Upload, form_net::FormError> {
336        let content_type = self.req.header("content-type").unwrap_or(b"");
337        let body = self.req.http.body_bytes();
338        match (self.csrf, self.shared.csrf.as_ref(), self.binding) {
339            (crate::csrf::State::Pending, Some(engine), Some(binding)) => {
340                let mut check = crate::csrf::token_check(engine, binding);
341                let mut hooks = (&mut check, inspector);
342                let mut session = form_net::UploadSession::new(
343                    content_type,
344                    self.limits,
345                    &crate::csrf::TOKEN_POLICY,
346                    store,
347                )?;
348                session.push(body, &mut hooks)?;
349                let upload = session.finish(&mut hooks)?;
350                if hooks.0.verified() {
351                    self.csrf = crate::csrf::State::Verified;
352                }
353                Ok(upload)
354            }
355            _ => {
356                let mut session = form_net::UploadSession::new(
357                    content_type,
358                    self.limits,
359                    &form_net::Policy::DEFAULT,
360                    store,
361                )?;
362                session.push(body, inspector)?;
363                session.finish(inspector)
364            }
365        }
366    }
367
368    /// A CSRF token for the visitor's session, to put in a form's hidden
369    /// `csrf_token` field or a script's `X-CSRF-Token` header. Different on
370    /// every call (masked), so a compression side channel cannot recover it.
371    ///
372    /// # Errors
373    ///
374    /// 500 `csrf.no_engine` without [`Builder::csrf`](crate::Builder::csrf);
375    /// 500 `csrf.no_binding` when the identity has no binding.
376    pub fn csrf_token(&self) -> Result<String> {
377        let engine = self
378            .shared
379            .csrf
380            .as_ref()
381            .ok_or_else(|| Error::internal("csrf.no_engine"))?;
382        let binding = self
383            .binding
384            .filter(|b| !b.is_empty())
385            .ok_or_else(|| Error::internal("csrf.no_binding"))?;
386        engine
387            .issue(binding)
388            .map(|t| t.as_str().to_owned())
389            .map_err(|e| Error::internal("csrf.issue").with_source(e))
390    }
391
392    /// Checks `If-Match` and `If-Unmodified-Since` against the current
393    /// version of what an unsafe request would change (RFC 9110 section
394    /// 13.2): `tag` is its entity tag's opaque part, `modified` its
395    /// modification time. Call it before changing anything.
396    ///
397    /// # Errors
398    ///
399    /// 412 `precondition.failed` when a condition is false. Without either
400    /// field, `Ok`.
401    pub fn precondition(
402        &self,
403        tag: Option<&str>,
404        modified: Option<std::time::SystemTime>,
405    ) -> Result {
406        let current = tag.and_then(|t| parse_net::EntityTag::strong(t.as_bytes()).ok());
407        if let Some(v) = self.req.header("if-match") {
408            if !parse_net::if_match(v, current.as_ref()).unwrap_or(false) {
409                return Err(Error::new(412, "precondition.failed"));
410            }
411            return Ok(());
412        }
413        if let (Some(v), Some(m)) = (self.req.header("if-unmodified-since"), modified) {
414            let (Ok(since), Ok(lm)) = (
415                parse_net::http_date(v),
416                parse_net::HttpDate::from_system_time(m),
417            ) else {
418                return Ok(());
419            };
420            if lm > since {
421                return Err(Error::new(412, "precondition.failed"));
422            }
423        }
424        Ok(())
425    }
426
427    // ---------------------------------------------------------------- reply
428
429    /// Sets the status (default 200).
430    pub fn status(&mut self, status: u16) -> &mut Self {
431        self.out.status = status;
432        self
433    }
434
435    /// Adds a reply field. A name that is not a token, a value with a
436    /// control character, or a framing field (`content-length`, ...) turns
437    /// the reply into a 500 when the handler returns.
438    pub fn set_header(&mut self, name: &str, value: impl AsRef<[u8]>) -> &mut Self {
439        self.out.header(name, value.as_ref());
440        self
441    }
442
443    /// Sets the body.
444    pub fn body_bytes(&mut self, bytes: &[u8]) -> &mut Self {
445        self.out.body.clear();
446        self.out.body.extend_from_slice(bytes);
447        self
448    }
449
450    /// The best of `offers` for the request's `Accept-Language`
451    /// (`["en", "fr"]`), or `None` when the request states no preference
452    /// any offer meets.
453    #[must_use]
454    pub fn language(&self, offers: &[&'static str]) -> Option<&'static str> {
455        let i = parse_net::best_language(self.req.header("accept-language"), offers)?;
456        offers.get(i).copied()
457    }
458
459    /// The best of `offers` for the request's `Accept` (`["text/html",
460    /// "application/json"]`).
461    #[must_use]
462    pub fn media(&self, offers: &[&'static str]) -> Option<&'static str> {
463        let i = parse_net::best_media(self.req.header("accept"), offers)?;
464        offers.get(i).copied()
465    }
466
467    /// Status, `content-type: text/html; charset=utf-8`, and the HTML.
468    pub fn html(&mut self, status: u16, html: &str) -> &mut Self {
469        self.content_type("text/html; charset=utf-8");
470        self.status(status).body_bytes(html.as_bytes())
471    }
472
473    /// Status, `content-type: text/plain; charset=utf-8`, and the text.
474    pub fn text(&mut self, status: u16, text: &str) -> &mut Self {
475        self.content_type("text/plain; charset=utf-8");
476        self.status(status).body_bytes(text.as_bytes())
477    }
478
479    /// Adds a `Set-Cookie` field built with cookie-net (re-exported as
480    /// [`web_net::cookie_net`](crate::cookie_net)).
481    ///
482    /// ```
483    /// use web_net::{App, get, cookie_net::SetCookie};
484    ///
485    /// let app = App::builder()
486    ///     .route(get("/", |cx| {
487    ///         cx.set_cookie(&SetCookie::new("theme", "dark").map_err(|_| web_net::Error::internal("cookie"))?);
488    ///         cx.text(200, "ok");
489    ///         Ok(())
490    ///     }).public())
491    ///     .build()?;
492    /// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: a\r\n\r\n");
493    /// assert!(r.header("set-cookie").is_some());
494    /// # Ok::<(), web_net::BuildError>(())
495    /// ```
496    pub fn set_cookie(&mut self, cookie: &cookie_net::SetCookie<'_>) -> &mut Self {
497        let line = cookie.encode();
498        self.out.header("set-cookie", line.as_bytes());
499        self
500    }
501
502    /// The conditional fast path for a page: sets `ETag: "<tag>"` and, when
503    /// the request's `If-None-Match` matches it (a `GET` or `HEAD`), turns the
504    /// reply into `304 Not Modified` and returns `true`, so the handler
505    /// returns before building the page.
506    ///
507    /// `tag` is the opaque part (letters, digits, and `-._~` are safe), for
508    /// example a revision number. An unusable tag sets nothing and returns
509    /// `false`.
510    ///
511    /// ```
512    /// use web_net::{App, get};
513    ///
514    /// let app = App::builder()
515    ///     .route(get("/", |cx| {
516    ///         if cx.not_modified("rev-7") {
517    ///             return Ok(());
518    ///         }
519    ///         cx.html(200, "<h1>page</h1>");
520    ///         Ok(())
521    ///     }).public())
522    ///     .build()?;
523    /// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: a\r\nIf-None-Match: \"rev-7\"\r\n\r\n");
524    /// assert_eq!(r.status, 304);
525    /// # Ok::<(), web_net::BuildError>(())
526    /// ```
527    pub fn not_modified(&mut self, tag: &str) -> bool {
528        let Ok(current) = parse_net::EntityTag::strong(tag.as_bytes()) else {
529            return false;
530        };
531        let quoted = format!("\"{tag}\"");
532        self.out.header("etag", quoted.as_bytes());
533        if !matches!(self.method(), Method::Get | Method::Head) {
534            return false;
535        }
536        let matched = self
537            .req
538            .header("if-none-match")
539            .is_some_and(|v| parse_net::if_none_match(v, &current).unwrap_or(false));
540        if matched {
541            self.out.status = 304;
542            self.out.body.clear();
543        }
544        matched
545    }
546
547    fn content_type(&mut self, value: &str) {
548        if !self.out.has_type {
549            self.out.header("content-type", value.as_bytes());
550        }
551    }
552
553    /// The site-relative `path` as a URL path: the site's base path in
554    /// front (`/blog` + `/posts/1`). Every link a page writes goes through
555    /// this, so the site works installed in a folder.
556    #[must_use]
557    pub fn url(&self, path: &str) -> String {
558        let base = self.shared.base.as_ref();
559        if path == "/" && !base.is_empty() {
560            format!("{base}/")
561        } else {
562            format!("{base}{path}")
563        }
564    }
565
566    /// `303 See Other` to a site-relative path: the answer to a form that
567    /// was accepted, so a reload does not post it again.
568    ///
569    /// # Errors
570    ///
571    /// 500 `redirect.not_local` when `path` is not a local path (it must
572    /// start with one `/`): redirects to other hosts are never built from a
573    /// string a handler passes along.
574    pub fn see_other(&mut self, path: &str) -> Result {
575        self.redirect(303, path)
576    }
577
578    /// A redirect to another site, only to a host listed with
579    /// [`Builder::redirect_hosts`](crate::Builder::redirect_hosts):
580    /// `https://` (or `http://`), the host, and a visible-ASCII rest.
581    ///
582    /// # Errors
583    ///
584    /// 500 `redirect.not_allowed` for any other target, 500
585    /// `redirect.status` for a status that is not a redirect.
586    pub fn redirect_external(&mut self, status: u16, url: &str) -> Result {
587        if !matches!(status, 301 | 302 | 303 | 307 | 308) {
588            return Err(Error::internal("redirect.status"));
589        }
590        let rest = url
591            .strip_prefix("https://")
592            .or_else(|| url.strip_prefix("http://"))
593            .ok_or_else(|| Error::internal("redirect.not_allowed"))?;
594        let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
595        let authority = rest[..end].to_ascii_lowercase();
596        let host = authority
597            .rsplit_once(':')
598            .map_or(authority.as_str(), |(h, p)| {
599                if p.bytes().all(|b| b.is_ascii_digit()) && !p.is_empty() {
600                    h
601                } else {
602                    ""
603                }
604            });
605        let listed = self
606            .shared
607            .redirect_hosts
608            .iter()
609            .any(|h| h.as_ref() == authority || h.as_ref() == host);
610        let visible = url.bytes().all(|c| (0x21..0x7f).contains(&c) && c != b'\\');
611        if !listed || !visible || authority.contains('@') {
612            return Err(Error::internal("redirect.not_allowed"));
613        }
614        self.out.header("location", url.as_bytes());
615        self.out.status = status;
616        self.out.body.clear();
617        Ok(())
618    }
619
620    /// A redirect with `status` (301, 302, 303, 307, 308) to a
621    /// site-relative path.
622    ///
623    /// # Errors
624    ///
625    /// As [`see_other`](Self::see_other); 500 `redirect.status` for a
626    /// status that is not a redirect.
627    pub fn redirect(&mut self, status: u16, path: &str) -> Result {
628        if !matches!(status, 301 | 302 | 303 | 307 | 308) {
629            return Err(Error::internal("redirect.status"));
630        }
631        if !is_local_target(path) {
632            return Err(Error::internal("redirect.not_local"));
633        }
634        let location = self.url(path);
635        self.out.header("location", location.as_bytes());
636        self.out.status = status;
637        self.out.body.clear();
638        Ok(())
639    }
640
641    /// Renders the route's template (declared with
642    /// [`Route::template`](crate::Route::template)) against `view` into the
643    /// body. The status stays as set (200 by default); `content-type` is
644    /// HTML unless the handler set one.
645    ///
646    /// # Errors
647    ///
648    /// 500 `render.no_engine` or `render.no_template` when nothing can
649    /// render; the engine's own [`RenderError`](crate::RenderError) as a 500;
650    /// a checkpoint's denial.
651    pub fn render(&mut self, view: &dyn View) -> Result {
652        let t = self
653            .template
654            .ok_or_else(|| Error::internal("render.no_template"))?;
655        self.render_id(t, view)
656    }
657
658    /// Renders the template `name` against `view`. The name is resolved by
659    /// the engine on every call; prefer [`render`](Self::render) with a
660    /// template declared on the route, resolved once at build.
661    ///
662    /// # Errors
663    ///
664    /// As [`render`](Self::render).
665    pub fn render_named(&mut self, name: &str, view: &dyn View) -> Result {
666        let engine = self
667            .shared
668            .render
669            .as_deref()
670            .ok_or_else(|| Error::internal("render.no_engine"))?;
671        let t = engine
672            .template(name)
673            .ok_or_else(|| Error::internal("render.no_template"))?;
674        self.render_id(t, view)
675    }
676
677    fn render_id(&mut self, t: TemplateId, view: &dyn View) -> Result {
678        let engine = self
679            .shared
680            .render
681            .as_deref()
682            .ok_or_else(|| Error::internal("render.no_engine"))?;
683        if !self.rendering {
684            // Hooks may render too; they do not run their own hooks again.
685            self.rendering = true;
686            let hooks = self.before_render;
687            let ran = hooks.iter().try_for_each(|h| h(self));
688            self.rendering = false;
689            ran?;
690        }
691        if let Some(cp) = &self.shared.checkpoint {
692            match cp.check(self.req.env, Stage::BeforeRender) {
693                Verdict::Pass => {}
694                Verdict::Deny { status } => return Err(Error::new(status, "checkpoint.denied")),
695                _ => return Err(Error::forbidden("checkpoint.killed")),
696            }
697        }
698        self.out.body.clear();
699        let mut output = Output::new(&mut self.out.body, self.shared.page_limit);
700        let rendered = engine.render(t, view, &mut output);
701        if let Err(e) = rendered {
702            self.out.body.clear();
703            return Err(e.into());
704        }
705        self.content_type("text/html; charset=utf-8");
706        Ok(())
707    }
708}
709
710impl<I> fmt::Debug for Cx<'_, I> {
711    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
712        f.debug_struct("Cx")
713            .field("request", &self.req)
714            .field("status", &self.out.status)
715            .finish_non_exhaustive()
716    }
717}
718
719/// Decodes one query name or value: `+` is a space, escapes decoded once.
720fn decode_query(s: &str) -> Option<Cow<'_, str>> {
721    if !s.contains(['%', '+']) {
722        return Some(Cow::Borrowed(s));
723    }
724    let b = s.as_bytes();
725    let mut out = Vec::with_capacity(b.len());
726    let mut i = 0;
727    while i < b.len() {
728        match b[i] {
729            b'+' => {
730                out.push(b' ');
731                i += 1;
732            }
733            b'%' => {
734                let h = b.get(i + 1).and_then(|&c| (c as char).to_digit(16))?;
735                let l = b.get(i + 2).and_then(|&c| (c as char).to_digit(16))?;
736                out.push(((h << 4) | l) as u8);
737                i += 3;
738            }
739            c => {
740                out.push(c);
741                i += 1;
742            }
743        }
744    }
745    String::from_utf8(out).ok().map(Cow::Owned)
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn test_local_targets() {
754        for ok in ["/", "/a", "/a/b?c=d", "/a%20b"] {
755            assert!(is_local_target(ok), "{ok}");
756        }
757        for bad in [
758            "",
759            "a",
760            "//evil.example",
761            "/\\evil.example",
762            "https://x",
763            "/a\r\nx",
764            "/a b",
765            "/a\\b",
766        ] {
767            assert!(!is_local_target(bad), "{bad:?}");
768        }
769    }
770
771    #[test]
772    fn test_decode_query() {
773        assert_eq!(decode_query("a+b%21").as_deref(), Some("a b!"));
774        assert_eq!(decode_query("plain").as_deref(), Some("plain"));
775        assert_eq!(decode_query("%zz"), None);
776        assert_eq!(decode_query("%ff"), None);
777    }
778
779    #[test]
780    fn test_out_refuses_bad_fields() {
781        let mut o = Out::default();
782        o.header("x-ok", b"v");
783        assert!(!o.invalid);
784        o.header("content-length", b"1");
785        assert!(o.invalid);
786        let mut o = Out::default();
787        o.header("x", b"a\r\nb");
788        assert!(o.invalid);
789        let mut o = Out::default();
790        o.header("bad name", b"v");
791        assert!(o.invalid);
792        let mut o = Out::default();
793        o.header("Content-Type", b"text/x");
794        assert!(o.has_type && o.has("content-type"));
795    }
796}