Skip to main content

web_net/
app.rs

1//! The application: its site, its compiled routes, its gate, and the
2//! pipeline every request runs.
3
4use std::borrow::Cow;
5use std::cell::Cell;
6use std::fmt;
7use std::panic::{AssertUnwindSafe, catch_unwind};
8use std::sync::Arc;
9
10use serve_net::http_net::MethodSet;
11use serve_net::{
12    Envelope, HttpHead, HttpReply, HttpRequest, HttpService, Method, Outcome, Scheme, Verdict,
13    WorkClass,
14};
15
16use crate::assets::{self, Assets};
17use crate::csrf;
18use crate::cx::{Out, is_local_target};
19use crate::error::{self, BuildError, Problem};
20use crate::headers::SecurityHeaders;
21use crate::route::{Access, BoxHandler, Group, Route};
22use crate::router::{self, Captures, Trie};
23use crate::url::{self, TrailingSlash};
24use crate::{
25    Cx, Denial, DenialKind, Event, EventKind, Events, Fields, Gate, Identity, NoEvents, Open,
26    Output, Render, Request, RequestId, Result, Stage, TemplateId, Value,
27};
28
29/// The default budget for one rendered page.
30pub const DEFAULT_PAGE_LIMIT: usize = 8 << 20;
31
32/// Statuses with an error template lookup (`error/404`, ...).
33const ERROR_STATUSES: [u16; 14] = [
34    400, 401, 403, 404, 405, 409, 410, 413, 421, 422, 429, 500, 501, 503,
35];
36
37/// A named point in the pipeline where registered code runs, only for the
38/// routes it registered for. A route with no hooks pays nothing.
39#[non_exhaustive]
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Hook {
42    /// After the access check passed, before the handler: locale, feature
43    /// flags, an A/B variant. An error ends the request.
44    Routed,
45    /// After the handler succeeded, before the reply leaves: add headers.
46    /// An error replaces the reply with the error page.
47    Finish,
48}
49
50/// Which routes a hook runs for.
51#[derive(Clone, Debug)]
52pub struct Routes(Selector);
53
54#[derive(Clone, Debug)]
55enum Selector {
56    All,
57    Prefix(String),
58    Named(Vec<&'static str>),
59}
60
61impl Routes {
62    /// Every route.
63    #[must_use]
64    pub fn all() -> Self {
65        Self(Selector::All)
66    }
67
68    /// Routes whose pattern starts with `prefix` (`/blog`).
69    #[must_use]
70    pub fn prefix(prefix: impl Into<String>) -> Self {
71        Self(Selector::Prefix(prefix.into()))
72    }
73
74    /// Routes with one of these names.
75    #[must_use]
76    pub fn named(names: &[&'static str]) -> Self {
77        Self(Selector::Named(names.to_vec()))
78    }
79
80    fn matches(&self, pattern: &str, name: Option<&str>) -> bool {
81        match &self.0 {
82            Selector::All => true,
83            Selector::Prefix(p) => {
84                let p = p.trim_end_matches('/');
85                pattern == p
86                    || pattern
87                        .strip_prefix(p)
88                        .is_some_and(|rest| rest.starts_with('/'))
89            }
90            Selector::Named(names) => name.is_some_and(|n| names.contains(&n)),
91        }
92    }
93}
94
95type HookFn<I> = Arc<dyn Fn(&mut Cx<'_, I>) -> Result + Send + Sync>;
96
97/// What [`App::classify`] tells a dispatcher before admission.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub struct Classified {
100    /// The route that will run, if one matched.
101    pub route: Option<RouteId>,
102    /// How its handler behaves (`Quick` when nothing matched).
103    pub work: WorkClass,
104}
105
106/// A declared route (one method of one pattern), numbered in declaration
107/// order.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
109pub struct RouteId(pub u32);
110
111/// The parts of the application every handler can reach.
112pub(crate) struct Shared {
113    pub(crate) render: Option<Box<dyn Render>>,
114    pub(crate) checkpoint: Option<Box<dyn crate::Checkpoint>>,
115    pub(crate) events: Box<dyn Events>,
116    pub(crate) page_limit: usize,
117    pub(crate) base: Box<str>,
118    errors: Vec<(u16, TemplateId)>,
119    sign_in: Option<Box<str>>,
120    challenge: Box<str>,
121    wipe: bool,
122    security: SecurityHeaders,
123    pub(crate) csrf: Option<csrf_sec::Csrf>,
124    pub(crate) redirect_hosts: Vec<Box<str>>,
125}
126
127/// Where a reply is going: the request's envelope, and whether security
128/// headers for HTTPS apply (HTTPS, and not a development host).
129#[derive(Clone, Copy)]
130pub(crate) struct At<'a> {
131    pub(crate) env: &'a Envelope,
132    pub(crate) tls: bool,
133}
134
135type ReadyFn = Arc<dyn Fn() -> bool + Send + Sync>;
136
137#[derive(Clone)]
138enum Health {
139    Live,
140    Ready(ReadyFn),
141}
142
143#[derive(Clone, Debug, Default)]
144struct Site {
145    /// Lower case; the first is canonical.
146    hosts: Vec<Box<str>>,
147    aliases: Vec<Box<str>>,
148    development: bool,
149    slash: TrailingSlash,
150    lowercase: bool,
151    https_only: bool,
152}
153
154struct Entry<G: Gate> {
155    handler: BoxHandler<G::Identity>,
156    access: Access<G::Rule>,
157    names: Box<[Box<str>]>,
158    pattern: Box<str>,
159    work: WorkClass,
160    template: Option<TemplateId>,
161    csrf: bool,
162    limits: form_net::Limits,
163    routed: Box<[HookFn<G::Identity>]>,
164    finish: Box<[HookFn<G::Identity>]>,
165}
166
167struct Endpoint {
168    /// Entry per method, by `Method::index`.
169    methods: [Option<u32>; 10],
170    allow: Box<str>,
171}
172
173/// A web application: one site, its routes, and its gate. Built once at
174/// start-up with [`App::builder`] or [`App::with_gate`]; immutable after.
175///
176/// It is a `serve_net::HttpService`, so it runs on serve-net as it is, and
177/// it has [`respond`](Self::respond) and
178/// [`respond_identified`](Self::respond_identified) for a wrapper that
179/// calls it from its own service.
180///
181/// ```
182/// use web_net::{App, get};
183///
184/// let app = App::builder()
185///     .route(get("/", |cx| { cx.html(200, "<h1>Hello</h1>"); Ok(()) }).public())
186///     .route(get("/users/:id", |cx| {
187///         let id: u64 = cx.param_as("id")?;
188///         cx.text(200, &format!("user {id}"));
189///         Ok(())
190///     }).public())
191///     .build()?;
192///
193/// let reply = serve_net::testing::respond(&app, b"GET /users/7 HTTP/1.1\r\nHost: a\r\n\r\n");
194/// assert_eq!((reply.status, reply.text()), (200, "user 7"));
195///
196/// let reply = serve_net::testing::respond(&app, b"GET /users/7/ HTTP/1.1\r\nHost: a\r\n\r\n");
197/// assert_eq!(reply.status, 301);
198/// assert_eq!(reply.header("location"), Some(&b"/users/7"[..]));
199/// # Ok::<(), web_net::BuildError>(())
200/// ```
201pub struct App<G: Gate = Open> {
202    gate: G,
203    shared: Shared,
204    site: Site,
205    trie: Trie,
206    endpoints: Vec<Endpoint>,
207    entries: Vec<Entry<G>>,
208    allow_all: Box<str>,
209    assets: Vec<Assets>,
210    health: Vec<(Box<str>, Health)>,
211}
212
213impl App<Open> {
214    /// A builder with the [`Open`] gate: every visitor is a guest and every
215    /// route is declared public.
216    #[must_use]
217    pub fn builder() -> Builder<Open> {
218        Builder::new(Open)
219    }
220}
221
222impl<G: Gate> App<G> {
223    /// A builder with `gate` for authentication and authorization.
224    #[must_use]
225    pub fn with_gate(gate: G) -> Builder<G> {
226        Builder::new(gate)
227    }
228}
229
230/// Declares an application. Every mistake is reported by
231/// [`build`](Self::build) at once, before anything listens.
232pub struct Builder<G: Gate> {
233    gate: G,
234    routes: Vec<Route<G::Identity, G::Rule>>,
235    hooks: Vec<(Hook, Routes, HookFn<G::Identity>)>,
236    site: Site,
237    base: String,
238    render: Option<Box<dyn Render>>,
239    checkpoint: Option<Box<dyn crate::Checkpoint>>,
240    events: Box<dyn Events>,
241    page_limit: usize,
242    sign_in: Option<String>,
243    challenge: String,
244    wipe: bool,
245    security: SecurityHeaders,
246    assets: Vec<Assets>,
247    health: Vec<(String, Health)>,
248    csrf: Option<csrf_sec::Csrf>,
249    redirect_hosts: Vec<Box<str>>,
250}
251
252impl<G: Gate> Builder<G> {
253    fn new(gate: G) -> Self {
254        Self {
255            gate,
256            routes: Vec::new(),
257            hooks: Vec::new(),
258            site: Site::default(),
259            base: String::new(),
260            render: None,
261            checkpoint: None,
262            events: Box::new(NoEvents),
263            page_limit: DEFAULT_PAGE_LIMIT,
264            sign_in: None,
265            challenge: "Session".into(),
266            wipe: false,
267            security: SecurityHeaders::default(),
268            assets: Vec::new(),
269            health: Vec::new(),
270            csrf: None,
271            redirect_hosts: Vec::new(),
272        }
273    }
274
275    /// CSRF protection with csrf-sec (re-exported as
276    /// [`web_net::csrf_sec`](crate::csrf_sec)), built from a persisted
277    /// 32-byte key. Required as soon as one route takes an unsafe method and
278    /// is not [exempt](crate::Route::csrf_exempt).
279    #[must_use]
280    pub fn csrf(mut self, csrf: csrf_sec::Csrf) -> Self {
281        self.csrf = Some(csrf);
282        self
283    }
284
285    /// Hosts that [`Cx::redirect_external`] may send a visitor to
286    /// (`accounts.example.com`). Everything else is refused.
287    #[must_use]
288    pub fn redirect_hosts(mut self, hosts: &[&str]) -> Self {
289        self.redirect_hosts.extend(
290            hosts
291                .iter()
292                .map(|h| h.to_ascii_lowercase().into_boxed_str()),
293        );
294        self
295    }
296
297    /// Serves a folder of files under a mount path, before routing and
298    /// without the gate (files are public).
299    #[must_use]
300    pub fn assets(mut self, assets: Assets) -> Self {
301        self.assets.push(assets);
302        self
303    }
304
305    /// A liveness path (`/healthz`): `200 ok` whenever the process answers.
306    /// Answered before routing and without the gate.
307    #[must_use]
308    pub fn health(mut self, path: &str) -> Self {
309        self.health.push((path.to_owned(), Health::Live));
310        self
311    }
312
313    /// A readiness path (`/readyz`): `200 ready` when `check` says so,
314    /// `503 not ready` otherwise. Keep `check` to a flag read: it runs on the
315    /// request path.
316    #[must_use]
317    pub fn ready(mut self, path: &str, check: impl Fn() -> bool + Send + Sync + 'static) -> Self {
318        self.health
319            .push((path.to_owned(), Health::Ready(Arc::new(check))));
320        self
321    }
322
323    /// The security headers every reply carries (default
324    /// [`SecurityHeaders::default`]).
325    #[must_use]
326    pub fn security_headers(mut self, policy: SecurityHeaders) -> Self {
327        self.security = policy;
328        self
329    }
330
331    /// Adds a route.
332    #[must_use]
333    pub fn route(mut self, route: Route<G::Identity, G::Rule>) -> Self {
334        self.routes.push(route);
335        self
336    }
337
338    /// Adds a group's routes.
339    #[must_use]
340    pub fn group(mut self, group: Group<G::Identity, G::Rule>) -> Self {
341        self.routes.extend(group.into_routes());
342        self
343    }
344
345    /// Runs `f` at `hook` for the routes `routes` selects.
346    #[must_use]
347    pub fn hook<F>(mut self, hook: Hook, routes: Routes, f: F) -> Self
348    where
349        F: Fn(&mut Cx<'_, G::Identity>) -> Result + Send + Sync + 'static,
350    {
351        self.hooks.push((hook, routes, Arc::new(f)));
352        self
353    }
354
355    /// A host the site answers (`example.com`, or `localhost:8080` with its
356    /// port). The first one is canonical. With no host declared the site
357    /// answers any host; with hosts declared, any other host gets 421.
358    #[must_use]
359    pub fn host(mut self, host: &str) -> Self {
360        self.site.hosts.push(host.to_ascii_lowercase().into());
361        self
362    }
363
364    /// A host that is redirected (301, or 308 for unsafe methods) to the
365    /// canonical host (`www.example.com` to `example.com`).
366    #[must_use]
367    pub fn alias(mut self, host: &str) -> Self {
368        self.site.aliases.push(host.to_ascii_lowercase().into());
369        self
370    }
371
372    /// A development instance: `localhost`, `127.0.0.1`, `[::1]`, and
373    /// `*.localhost` are answered as the site (with any port), and HTTPS is
374    /// not required for them. Never set this in production: it is the only
375    /// way a `Host: localhost` is trusted.
376    #[must_use]
377    pub fn development(mut self) -> Self {
378        self.site.development = true;
379        self
380    }
381
382    /// The path the site is installed under (`/blog`). It is removed before
383    /// routing and added back by [`Cx::url`], so routes and links are written
384    /// as if the site were at `/`. Requests outside it get 404.
385    #[must_use]
386    pub fn base(mut self, base: &str) -> Self {
387        self.base = base.to_owned();
388        self
389    }
390
391    /// The trailing-slash policy (default [`TrailingSlash::Strip`]).
392    #[must_use]
393    pub fn trailing_slash(mut self, policy: TrailingSlash) -> Self {
394        self.site.slash = policy;
395        self
396    }
397
398    /// Paths are lower case: one with an upper-case letter is redirected.
399    #[must_use]
400    pub fn lowercase_paths(mut self) -> Self {
401        self.site.lowercase = true;
402        self
403    }
404
405    /// Plain HTTP is redirected to HTTPS on the canonical host (behind a
406    /// TLS proxy, serve-net must trust the proxy's scheme). Needs a host.
407    #[must_use]
408    pub fn https_only(mut self) -> Self {
409        self.site.https_only = true;
410        self
411    }
412
413    /// The site-relative sign-in page. A [`Denial::sign_in`] on a `GET` or
414    /// `HEAD` is then answered with a 303 to it, the page asked for passed
415    /// as `next`; without it, or for other methods, the answer is 401.
416    #[must_use]
417    pub fn sign_in(mut self, path: &str) -> Self {
418        self.sign_in = Some(path.to_owned());
419        self
420    }
421
422    /// The `WWW-Authenticate` challenge sent with a 401 (default
423    /// `Session`).
424    #[must_use]
425    pub fn challenge(mut self, challenge: &str) -> Self {
426        challenge.clone_into(&mut self.challenge);
427        self
428    }
429
430    /// The template engine.
431    #[must_use]
432    pub fn render(mut self, engine: impl Render) -> Self {
433        self.render = Some(Box::new(engine));
434        self
435    }
436
437    /// Where refusals, denials, and failures are reported.
438    #[must_use]
439    pub fn events(mut self, events: impl Events) -> Self {
440        self.events = Box::new(events);
441        self
442    }
443
444    /// The in-flight revocation check at web-net's stages.
445    #[must_use]
446    pub fn checkpoint(mut self, checkpoint: impl crate::Checkpoint) -> Self {
447        self.checkpoint = Some(Box::new(checkpoint));
448        self
449    }
450
451    /// The budget for one rendered page (default 8 MiB).
452    #[must_use]
453    pub fn page_limit(mut self, bytes: usize) -> Self {
454        self.page_limit = bytes;
455        self
456    }
457
458    /// Zero the reply buffers web-net reuses after every request, so one
459    /// visitor's page never lingers in memory another request reuses. Costs
460    /// one pass over the bytes written.
461    #[must_use]
462    pub fn wipe_buffers(mut self) -> Self {
463        self.wipe = true;
464        self
465    }
466
467    /// Compiles the application.
468    ///
469    /// # Errors
470    ///
471    /// A [`BuildError`] listing every problem: a route without declared
472    /// access, a malformed pattern, two handlers for one method of one
473    /// path, an unknown template, a malformed host or base path.
474    pub fn build(self) -> std::result::Result<App<G>, BuildError> {
475        let mut problems = Vec::new();
476        let mut problem = |subject: &str, message: String| {
477            problems.push(Problem {
478                subject: subject.to_owned(),
479                message,
480            });
481        };
482
483        // Site.
484        let base = self.base.trim_end_matches('/');
485        if !base.is_empty() {
486            match url::normalize(base, self.site.lowercase) {
487                Ok(n) if n == base && !base.contains(['?', '#']) => {}
488                _ => problem(
489                    "base",
490                    format!("'{}' is not a canonical path such as '/blog'", self.base),
491                ),
492            }
493        }
494        for h in self.site.hosts.iter().chain(&self.site.aliases) {
495            if !valid_host(h) {
496                problem("host", format!("'{h}' is not a host name or address"));
497            }
498        }
499        if self.site.https_only && self.site.hosts.is_empty() {
500            problem(
501                "https_only",
502                "redirecting to HTTPS needs a declared host; add .host(\"example.com\")".into(),
503            );
504        }
505        if let Some(s) = &self.sign_in {
506            if !is_local_target(s) {
507                problem("sign_in", format!("'{s}' is not a site-relative path"));
508            }
509        }
510        if self.challenge.is_empty() || !self.challenge.bytes().all(|b| (0x20..0x7f).contains(&b)) {
511            problem("challenge", "the challenge must be visible ASCII".into());
512        }
513
514        if !self.security.valid() {
515            problem(
516                "security_headers",
517                "every header value must be visible ASCII and not empty".into(),
518            );
519        }
520        let mut assets = self.assets;
521        for a in &mut assets {
522            let subject = format!("assets {}", a.mount);
523            if a.mount.is_empty()
524                || !is_local_target(&a.mount)
525                || url::normalize(&a.mount, self.site.lowercase).map_or(true, |n| n != a.mount)
526            {
527                problem(
528                    &subject,
529                    "the mount must be a canonical path such as '/static'".into(),
530                );
531            }
532            match std::fs::canonicalize(&a.dir) {
533                Ok(root) if root.is_dir() => a.root = root,
534                _ => problem(
535                    &subject,
536                    format!("'{}' is not a folder that exists", a.dir.display()),
537                ),
538            }
539            for r in &self.routes {
540                if a.strip(&r.pattern).is_some() {
541                    problem(
542                        &r.pattern,
543                        format!(
544                            "the route is under the assets mount '{}' and can never run",
545                            a.mount
546                        ),
547                    );
548                }
549            }
550        }
551        for h in &self.redirect_hosts {
552            if !valid_host(h) {
553                problem(
554                    "redirect_hosts",
555                    format!("'{h}' is not a host name or address"),
556                );
557            }
558        }
559        let mut health = Vec::new();
560        for (p, h) in self.health {
561            if !is_local_target(&p) || url::normalize(&p, false).map_or(true, |n| n != p) {
562                problem(
563                    "health",
564                    format!("'{p}' is not a canonical site-relative path"),
565                );
566            }
567            health.push((p.into_boxed_str(), h));
568        }
569
570        // Error templates.
571        let mut errors = Vec::new();
572        if let Some(r) = &self.render {
573            for s in ERROR_STATUSES {
574                if let Some(t) = r.template(&format!("error/{s}")) {
575                    errors.push((s, t));
576                }
577            }
578        }
579
580        // Routes.
581        let mut trie = Trie::default();
582        let mut endpoints: Vec<Endpoint> = Vec::new();
583        let mut entries: Vec<Entry<G>> = Vec::new();
584        let mut all = MethodSet::EMPTY;
585        for r in self.routes {
586            let parts = match router::parse(&r.pattern) {
587                Ok(p) => p,
588                Err(e) => {
589                    problem(&r.pattern, e.message());
590                    continue;
591                }
592            };
593            if r.handlers.is_empty() {
594                problem(&r.pattern, "the route has no handler".into());
595                continue;
596            }
597            if matches!(r.access, Access::Undeclared) {
598                problem(
599                    &r.pattern,
600                    "the route declares no access; add .public() or .allow(rule)".into(),
601                );
602                continue;
603            }
604            let template = match (r.template, &self.render) {
605                (None, _) => None,
606                (Some(t), None) => {
607                    problem(
608                        &r.pattern,
609                        format!("template '{t}' is declared but no render engine is set"),
610                    );
611                    None
612                }
613                (Some(t), Some(engine)) => {
614                    let id = engine.template(t);
615                    if id.is_none() {
616                        problem(&r.pattern, format!("the engine has no template '{t}'"));
617                    }
618                    id
619                }
620            };
621            let ep = trie.insert(&parts, endpoints.len() as u32) as usize;
622            if ep == endpoints.len() {
623                endpoints.push(Endpoint {
624                    methods: [None; 10],
625                    allow: Box::from(""),
626                });
627            }
628            let names: Box<[Box<str>]> = router::names(&parts).into();
629            if r.csrf_exempt.is_some_and(|reason| reason.trim().is_empty()) {
630                problem(&r.pattern, "a CSRF exemption needs its reason".into());
631            }
632            if let Err(e) = r.form_limits.validate() {
633                problem(&r.pattern, format!("the form limits are inconsistent: {e}"));
634            }
635            for (m, h) in r.handlers {
636                let subject = format!("{} {}", m.as_str().unwrap_or("?"), r.pattern);
637                if m == Method::Other || m == Method::Connect || m == Method::Trace {
638                    problem(&subject, "this method cannot be routed".into());
639                    continue;
640                }
641                let slot = &mut endpoints[ep].methods[m.index() as usize];
642                if slot.is_some() {
643                    problem(
644                        &subject,
645                        "a handler for this method and path exists already".into(),
646                    );
647                    continue;
648                }
649                *slot = Some(entries.len() as u32);
650                let csrf = r.csrf_exempt.is_none() && !m.is_safe();
651                if csrf && self.csrf.is_none() {
652                    problem(
653                        &subject,
654                        "an unsafe method needs CSRF protection; set Builder::csrf(..) or mark the route csrf_exempt(\"reason\")".into(),
655                    );
656                }
657                let pick = |at: Hook| -> Box<[HookFn<G::Identity>]> {
658                    self.hooks
659                        .iter()
660                        .filter(|(h, sel, _)| *h == at && sel.matches(&r.pattern, r.name))
661                        .map(|(_, _, f)| Arc::clone(f))
662                        .collect()
663                };
664                entries.push(Entry {
665                    handler: h,
666                    access: r.access.clone(),
667                    names: names.clone(),
668                    pattern: r.pattern.as_str().into(),
669                    work: r.work,
670                    template,
671                    csrf,
672                    limits: r.form_limits,
673                    routed: pick(Hook::Routed),
674                    finish: pick(Hook::Finish),
675                });
676            }
677        }
678        for e in &mut endpoints {
679            let mut set = MethodSet::EMPTY;
680            for m in [
681                Method::Get,
682                Method::Head,
683                Method::Post,
684                Method::Put,
685                Method::Delete,
686                Method::Options,
687                Method::Patch,
688            ] {
689                if e.methods[m.index() as usize].is_some() {
690                    set = set.with(m);
691                }
692            }
693            if set.contains(Method::Get) {
694                set = set.with(Method::Head);
695            }
696            set = set.with(Method::Options);
697            all = all.union(set);
698            let mut v = Vec::new();
699            set.write_allow(&mut v);
700            e.allow = String::from_utf8(v).unwrap_or_default().into();
701        }
702        if !problems.is_empty() {
703            return Err(BuildError::new(problems));
704        }
705        let mut v = Vec::new();
706        all.with(Method::Options).write_allow(&mut v);
707        Ok(App {
708            gate: self.gate,
709            shared: Shared {
710                render: self.render,
711                checkpoint: self.checkpoint,
712                events: self.events,
713                page_limit: self.page_limit,
714                base: base.into(),
715                errors,
716                sign_in: self.sign_in.map(Into::into),
717                challenge: self.challenge.into(),
718                wipe: self.wipe,
719                security: self.security,
720                csrf: self.csrf,
721                redirect_hosts: self.redirect_hosts,
722            },
723            site: self.site,
724            trie,
725            endpoints,
726            entries,
727            allow_all: String::from_utf8(v).unwrap_or_default().into(),
728            assets,
729            health,
730        })
731    }
732}
733
734impl<G: Gate> fmt::Debug for Builder<G> {
735    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
736        f.debug_struct("Builder")
737            .field("routes", &self.routes)
738            .field("site", &self.site)
739            .field("base", &self.base)
740            .finish_non_exhaustive()
741    }
742}
743
744fn valid_host(h: &str) -> bool {
745    let (host, port) = split_port(h);
746    let host_ok = if let Some(inner) = host.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
747        inner.parse::<std::net::Ipv6Addr>().is_ok()
748    } else {
749        !host.is_empty()
750            && host.len() <= 253
751            && host
752                .bytes()
753                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
754    };
755    host_ok && port.is_none_or(|p| !p.is_empty() && p.parse::<u16>().is_ok())
756}
757
758/// `example.com:8080` to (`example.com`, `Some("8080")`); `[::1]:80` to
759/// (`[::1]`, `Some("80")`).
760fn split_port(a: &str) -> (&str, Option<&str>) {
761    if a.starts_with('[') {
762        return match a.find(']') {
763            Some(i) => match a[i + 1..].strip_prefix(':') {
764                Some(p) => (&a[..=i], Some(p)),
765                None => (a, None),
766            },
767            None => (a, None),
768        };
769    }
770    match a.rsplit_once(':') {
771        Some((h, p)) => (h, Some(p)),
772        None => (a, None),
773    }
774}
775
776fn is_dev_host(host: &str) -> bool {
777    matches!(host, "localhost" | "127.0.0.1" | "[::1]") || host.ends_with(".localhost")
778}
779
780thread_local! {
781    /// Reply buffers reused between requests on one thread. Taken for the
782    /// length of one request and put back, so nothing about a request lives
783    /// here between calls.
784    static SCRATCH: Cell<Option<Box<Out>>> = const { Cell::new(None) };
785}
786
787/// Buffers bigger than this are dropped instead of kept.
788const KEEP_CAPACITY: usize = 1 << 20;
789
790/// Where the site check sent a request.
791enum Placed<'a> {
792    Here(Cow<'a, str>),
793    Redirect(String),
794    Refuse(u16, &'static str, Option<url::UrlError>),
795}
796
797impl<G: Gate> App<G> {
798    /// Runs the whole pipeline for one request, authentication included.
799    pub fn respond(&self, req: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
800        self.run(req, env, None, reply)
801    }
802
803    /// Runs the pipeline for a request whose caller authenticated it
804    /// already: web-net takes `who` as given and does not call the gate's
805    /// `authenticate`. Authorization still runs for every route that is
806    /// not public.
807    pub fn respond_identified(
808        &self,
809        req: &HttpRequest<'_>,
810        env: &Envelope,
811        who: &G::Identity,
812        reply: &mut HttpReply,
813    ) -> Outcome {
814        self.run(req, env, Some(who), reply)
815    }
816
817    /// Looks at a head before admission: which route would run and how it
818    /// behaves. Nothing is answered and no gate is called.
819    #[must_use]
820    pub fn classify(&self, head: &HttpHead<'_>) -> Classified {
821        let none = Classified {
822            route: None,
823            work: WorkClass::Quick,
824        };
825        let Placed::Here(path) = self.place(head.authority(), head.path(), Scheme::Https, true)
826        else {
827            return none;
828        };
829        let Some((ep, _)) = self.trie.find(&path) else {
830            return none;
831        };
832        match self.entry_for(ep as usize, head.method()) {
833            Some(i) => Classified {
834                route: Some(RouteId(i)),
835                work: self.entries[i as usize].work,
836            },
837            None => none,
838        }
839    }
840
841    /// The gate.
842    #[must_use]
843    pub fn gate(&self) -> &G {
844        &self.gate
845    }
846
847    fn entry_for(&self, ep: usize, m: Method) -> Option<u32> {
848        let methods = &self.endpoints[ep].methods;
849        match m {
850            Method::Other => None,
851            Method::Head => {
852                methods[Method::Head.index() as usize].or(methods[Method::Get.index() as usize])
853            }
854            m => methods[m.index() as usize],
855        }
856    }
857
858    /// Steps 1 and 2: which site, which canonical path. `quiet` (for
859    /// `classify`) skips building a redirect.
860    fn place<'a>(
861        &self,
862        authority: Option<&'a str>,
863        raw: &'a str,
864        scheme: Scheme,
865        quiet: bool,
866    ) -> Placed<'a> {
867        let site = &self.site;
868        let authority = authority.unwrap_or("");
869        let lower = authority.to_ascii_lowercase();
870        let lower = lower.trim_end_matches('.');
871        let host_only = split_port(lower).0.trim_end_matches('.');
872        let listed = |list: &[Box<str>]| {
873            list.iter().any(|h| {
874                let h = h.as_ref();
875                h == lower || (split_port(h).1.is_none() && h == host_only)
876            })
877        };
878        let dev = site.development && is_dev_host(host_only);
879        let mut target_host: Option<&str> = None;
880        if !site.hosts.is_empty() && !dev && !authority.is_empty() && !listed(&site.hosts) {
881            if listed(&site.aliases) {
882                target_host = site.hosts.first().map(AsRef::as_ref);
883            } else {
884                return Placed::Refuse(421, "host.unknown", None);
885            }
886        }
887        let mut https = scheme == Scheme::Https;
888        if site.https_only && !https && !dev {
889            https = true;
890            target_host = target_host.or(site.hosts.first().map(AsRef::as_ref));
891        }
892
893        // The path: canonical, inside the base, with the slash policy.
894        let canon = match url::normalize(raw, site.lowercase) {
895            Ok(c) => c,
896            Err(e) => return Placed::Refuse(400, e.code(), Some(e)),
897        };
898        let base = self.shared.base.as_ref();
899        let c: &str = &canon;
900        let rel_start = if base.is_empty() {
901            0
902        } else if c == base {
903            c.len()
904        } else if c.strip_prefix(base).is_some_and(|r| r.starts_with('/')) {
905            base.len()
906        } else {
907            return Placed::Refuse(404, "site.outside_base", None);
908        };
909        let rel = &c[rel_start..];
910        let mut fixed: Option<String> = None;
911        if rel.is_empty() {
912            fixed = Some("/".into());
913        } else if rel.len() > 1 {
914            match site.slash {
915                TrailingSlash::Strip => {
916                    if let Some(s) = rel.strip_suffix('/') {
917                        fixed = Some(s.to_owned());
918                    }
919                }
920                TrailingSlash::Add => {
921                    let last = rel.rsplit('/').next().unwrap_or("");
922                    if !rel.ends_with('/') && !last.contains('.') {
923                        fixed = Some(format!("{rel}/"));
924                    }
925                }
926                TrailingSlash::Ignore => {}
927            }
928        }
929        if target_host.is_none() && fixed.is_none() {
930            if let Cow::Borrowed(c) = canon {
931                let mut path = &c[rel_start..];
932                if site.slash == TrailingSlash::Ignore && path.len() > 1 {
933                    path = path.strip_suffix('/').unwrap_or(path);
934                }
935                return Placed::Here(Cow::Borrowed(path));
936            }
937        }
938        if quiet {
939            return Placed::Refuse(0, "", None);
940        }
941        let rel_out = fixed.as_deref().unwrap_or(rel);
942        let mut loc = String::with_capacity(raw.len() + 32);
943        if let Some(h) = target_host {
944            loc.push_str(if https { "https://" } else { "http://" });
945            loc.push_str(h);
946        }
947        loc.push_str(base);
948        loc.push_str(rel_out);
949        Placed::Redirect(loc)
950    }
951
952    fn run(
953        &self,
954        http: &HttpRequest<'_>,
955        env: &Envelope,
956        who: Option<&G::Identity>,
957        reply: &mut HttpReply,
958    ) -> Outcome {
959        let method = http.method();
960        let raw_path = http.path();
961        let authority = http.head().authority();
962        let at = At {
963            env,
964            tls: self.tls(env, authority),
965        };
966        if raw_path == "*" {
967            if method == Method::Options {
968                reply.status(204).header("allow", self.allow_all.as_bytes());
969                self.secure(reply, at.tls, |_| false);
970            } else {
971                self.refuse(
972                    400,
973                    "url.asterisk",
974                    EventKind::Refused,
975                    None,
976                    None,
977                    at,
978                    reply,
979                );
980            }
981            return Outcome::Done;
982        }
983        let host_raw = authority.unwrap_or("");
984        let placed = self.place(authority, raw_path, env.client().scheme(), false);
985        let path = match placed {
986            Placed::Here(path) => path,
987            Placed::Redirect(mut loc) => {
988                if let Some(q) = http.query() {
989                    loc.push('?');
990                    loc.push_str(q);
991                }
992                let status = if matches!(method, Method::Get | Method::Head) {
993                    301
994                } else {
995                    308
996                };
997                reply.status(status).header("location", loc.as_bytes());
998                self.secure(reply, at.tls, |_| false);
999                return Outcome::Done;
1000            }
1001            Placed::Refuse(status, code, src) => {
1002                let src = src
1003                    .as_ref()
1004                    .map(|e| e as &(dyn std::error::Error + 'static));
1005                self.refuse(status, code, EventKind::Refused, None, src, at, reply);
1006                return Outcome::Done;
1007            }
1008        };
1009        // Early: health paths and files, before the gate and the router.
1010        if let Some(outcome) = self.early(http, at, &path, reply) {
1011            return outcome;
1012        }
1013
1014        let mut req = Request {
1015            http,
1016            env,
1017            path: &path,
1018            host: host_raw,
1019            route: None,
1020            tls: at.tls,
1021        };
1022
1023        // Authentication, unless the caller did it.
1024        let owned;
1025        let who: &G::Identity = match who {
1026            Some(w) => w,
1027            None => match self.gate.authenticate(&req) {
1028                Ok(w) => {
1029                    owned = w;
1030                    &owned
1031                }
1032                Err(d) => {
1033                    self.deny(d, &req, reply);
1034                    return Outcome::Done;
1035                }
1036            },
1037        };
1038
1039        // Route and method.
1040        let Some((ep, caps)) = self.trie.find(&path) else {
1041            self.refuse(404, "route.none", EventKind::Refused, None, None, at, reply);
1042            return Outcome::Done;
1043        };
1044        let endpoint = &self.endpoints[ep as usize];
1045        let Some(idx) = self.entry_for(ep as usize, method) else {
1046            if method == Method::Options {
1047                reply.status(204).header("allow", endpoint.allow.as_bytes());
1048                self.secure(reply, at.tls, |_| false);
1049                return Outcome::Done;
1050            }
1051            let status = if method == Method::Other { 501 } else { 405 };
1052            let code = if status == 501 {
1053                "method.unknown"
1054            } else {
1055                "method.not_allowed"
1056            };
1057            self.refuse(status, code, EventKind::Refused, None, None, at, reply);
1058            if status == 405 {
1059                reply.header("allow", endpoint.allow.as_bytes());
1060            }
1061            return Outcome::Done;
1062        };
1063        let entry = &self.entries[idx as usize];
1064        req.route = Some(&entry.pattern);
1065
1066        // Authorization.
1067        if let Access::Rule(rule) = &entry.access {
1068            if let Err(d) = self.gate.authorize(who, rule, &req) {
1069                self.deny(d, &req, reply);
1070                return Outcome::Done;
1071            }
1072        }
1073        if let Some(cp) = &self.shared.checkpoint {
1074            match cp.check(env, Stage::Authorized) {
1075                Verdict::Pass => {}
1076                Verdict::Deny { status } => {
1077                    self.refuse(
1078                        status,
1079                        "checkpoint.denied",
1080                        EventKind::Revoked,
1081                        req.route,
1082                        None,
1083                        at,
1084                        reply,
1085                    );
1086                    return Outcome::Done;
1087                }
1088                _ => {
1089                    self.refuse(
1090                        403,
1091                        "checkpoint.killed",
1092                        EventKind::Revoked,
1093                        req.route,
1094                        None,
1095                        at,
1096                        reply,
1097                    );
1098                    return Outcome::Close;
1099                }
1100            }
1101        }
1102
1103        // CSRF on unsafe methods.
1104        let binding = who.csrf_binding();
1105        let csrf_state = match (&self.shared.csrf, entry.csrf) {
1106            (Some(engine), true) => {
1107                let scheme = if env.client().scheme() == Scheme::Https {
1108                    "https://"
1109                } else {
1110                    "http://"
1111                };
1112                let own = format!("{scheme}{}", host_raw.to_ascii_lowercase());
1113                match csrf::check(engine, http, binding, &own, &entry.limits) {
1114                    Ok(s) => s,
1115                    Err(code) => {
1116                        self.refuse(403, code, EventKind::Denied, req.route, None, at, reply);
1117                        return Outcome::Done;
1118                    }
1119                }
1120            }
1121            // Built only with an engine when a route needs one.
1122            (None, true) => {
1123                self.refuse(
1124                    500,
1125                    "csrf.no_engine",
1126                    EventKind::Failed,
1127                    req.route,
1128                    None,
1129                    at,
1130                    reply,
1131                );
1132                return Outcome::Done;
1133            }
1134            (_, false) => csrf::State::Off,
1135        };
1136
1137        // The handler, with its hooks, on a buffer web-net holds.
1138        let mut out = SCRATCH.with(Cell::take).unwrap_or_default();
1139        out.reset();
1140        let outcome = self.handle(
1141            entry,
1142            req,
1143            who,
1144            caps,
1145            (csrf_state, binding),
1146            &mut out,
1147            reply,
1148        );
1149        if self.shared.wipe {
1150            out.wipe();
1151        }
1152        if out.body.capacity() <= KEEP_CAPACITY && out.values.capacity() <= KEEP_CAPACITY {
1153            SCRATCH.with(|s| s.set(Some(out)));
1154        }
1155        outcome
1156    }
1157
1158    /// Health paths and static files.
1159    fn early(
1160        &self,
1161        http: &HttpRequest<'_>,
1162        at: At<'_>,
1163        path: &str,
1164        reply: &mut HttpReply,
1165    ) -> Option<Outcome> {
1166        let method = http.method();
1167        let get_like = matches!(method, Method::Get | Method::Head);
1168        if let Some((_, h)) = self.health.iter().find(|(p, _)| p.as_ref() == path) {
1169            if !get_like {
1170                self.not_get(method, at, reply);
1171                return Some(Outcome::Done);
1172            }
1173            let (status, text) = match h {
1174                Health::Live => (200, "ok"),
1175                Health::Ready(f) if f() => (200, "ready"),
1176                Health::Ready(_) => (503, "not ready"),
1177            };
1178            reply.text(status, text).header("cache-control", "no-store");
1179            self.secure(reply, at.tls, |_| false);
1180            return Some(Outcome::Done);
1181        }
1182        let (assets, rel) = self
1183            .assets
1184            .iter()
1185            .find_map(|a| a.strip(path).map(|rel| (a, rel)))?;
1186        if !get_like {
1187            self.not_get(method, at, reply);
1188            return Some(Outcome::Done);
1189        }
1190        let Some(found) = assets::find(assets, rel, http.header("accept-encoding")) else {
1191            self.refuse(
1192                404,
1193                "assets.none",
1194                EventKind::Refused,
1195                None,
1196                None,
1197                at,
1198                reply,
1199            );
1200            return Some(Outcome::Done);
1201        };
1202        let outcome = assets::serve(assets, &found, http, reply);
1203        self.secure(reply, at.tls, |_| false);
1204        Some(outcome)
1205    }
1206
1207    /// 405 (or 204 for `OPTIONS`) on a path that takes `GET` and `HEAD` only.
1208    fn not_get(&self, method: Method, at: At<'_>, reply: &mut HttpReply) {
1209        if method == Method::Options {
1210            reply.status(204).header("allow", "GET, HEAD, OPTIONS");
1211            self.secure(reply, at.tls, |_| false);
1212        } else {
1213            let (status, code) = if method == Method::Other {
1214                (501, "method.unknown")
1215            } else {
1216                (405, "method.not_allowed")
1217            };
1218            self.refuse(status, code, EventKind::Refused, None, None, at, reply);
1219            if status == 405 {
1220                reply.header("allow", "GET, HEAD, OPTIONS");
1221            }
1222        }
1223    }
1224
1225    #[allow(clippy::too_many_arguments)]
1226    fn handle(
1227        &self,
1228        entry: &Entry<G>,
1229        req: Request<'_>,
1230        who: &G::Identity,
1231        caps: Captures,
1232        (csrf_state, binding): (csrf::State, Option<&[u8]>),
1233        out: &mut Out,
1234        reply: &mut HttpReply,
1235    ) -> Outcome {
1236        let at = At {
1237            env: req.env,
1238            tls: req.tls,
1239        };
1240        let route = req.route;
1241        let (result, unverified) = {
1242            let mut cx = Cx {
1243                req,
1244                who,
1245                caps,
1246                names: &entry.names,
1247                out: &mut *out,
1248                shared: &self.shared,
1249                template: entry.template,
1250                csrf: csrf_state,
1251                binding,
1252                limits: &entry.limits,
1253            };
1254            let result = catch_unwind(AssertUnwindSafe(|| -> Result {
1255                for h in entry.routed.iter() {
1256                    h(&mut cx)?;
1257                }
1258                entry.handler.call(&mut cx)?;
1259                for h in entry.finish.iter() {
1260                    h(&mut cx)?;
1261                }
1262                Ok(())
1263            }));
1264            (result, cx.csrf == csrf::State::Pending)
1265        };
1266        match result {
1267            Ok(Ok(())) if unverified => {
1268                // A multipart route whose handler never read its form: the
1269                // token was never checked, so nothing it wrote may leave.
1270                out.reset();
1271                self.refuse(
1272                    403,
1273                    "csrf.unverified",
1274                    EventKind::Failed,
1275                    route,
1276                    None,
1277                    at,
1278                    reply,
1279                );
1280                Outcome::Done
1281            }
1282            Ok(Ok(())) if !out.invalid => {
1283                reply.status(out.status);
1284                for &(ns, ne, vs, ve) in &out.spans {
1285                    let name = core::str::from_utf8(&out.names[ns as usize..ne as usize])
1286                        .unwrap_or("x-invalid");
1287                    reply.header(name, &out.values[vs as usize..ve as usize]);
1288                }
1289                if !out.has("x-content-type-options") {
1290                    reply.header("x-content-type-options", "nosniff");
1291                }
1292                self.secure(reply, at.tls, |n| out.has(n));
1293                reply.body(&out.body);
1294                Outcome::Done
1295            }
1296            Ok(Ok(())) => {
1297                out.reset();
1298                self.refuse(
1299                    500,
1300                    "reply.invalid_field",
1301                    EventKind::Failed,
1302                    route,
1303                    None,
1304                    at,
1305                    reply,
1306                );
1307                Outcome::Done
1308            }
1309            Ok(Err(e)) => {
1310                out.reset();
1311                let src = std::error::Error::source(&e);
1312                self.refuse(
1313                    e.status(),
1314                    e.code(),
1315                    EventKind::Failed,
1316                    route,
1317                    src,
1318                    at,
1319                    reply,
1320                );
1321                Outcome::Done
1322            }
1323            Err(_) => {
1324                out.reset();
1325                self.refuse(
1326                    500,
1327                    "handler.panic",
1328                    EventKind::Panicked,
1329                    route,
1330                    None,
1331                    at,
1332                    reply,
1333                );
1334                Outcome::Done
1335            }
1336        }
1337    }
1338
1339    /// A gate's denial as a response.
1340    fn deny(&self, d: Denial, req: &Request<'_>, reply: &mut HttpReply) {
1341        let env = req.env;
1342        let at = At { env, tls: req.tls };
1343        if d.kind() == DenialKind::SignIn {
1344            if let Some(page) = &self.shared.sign_in {
1345                if matches!(req.method(), Method::Get | Method::Head) {
1346                    let mut loc = String::with_capacity(64);
1347                    loc.push_str(&self.shared.base);
1348                    loc.push_str(page);
1349                    loc.push_str(if page.contains('?') {
1350                        "&next="
1351                    } else {
1352                        "?next="
1353                    });
1354                    let mut next = String::new();
1355                    next.push_str(&self.shared.base);
1356                    next.push_str(req.path);
1357                    if let Some(q) = req.query() {
1358                        next.push('?');
1359                        next.push_str(q);
1360                    }
1361                    encode_component(&next, &mut loc);
1362                    self.event(EventKind::Denied, env, req.route, 303, d.code(), None);
1363                    reply.status(303).header("location", loc.as_bytes());
1364                    self.secure(reply, at.tls, |_| false);
1365                    return;
1366                }
1367            }
1368        }
1369        let status = d.status_code();
1370        self.refuse(
1371            status,
1372            d.code(),
1373            EventKind::Denied,
1374            req.route,
1375            None,
1376            at,
1377            reply,
1378        );
1379        if status == 401 {
1380            reply.header("www-authenticate", self.shared.challenge.as_bytes());
1381        }
1382    }
1383
1384    fn event(
1385        &self,
1386        kind: EventKind,
1387        env: &Envelope,
1388        route: Option<&str>,
1389        status: u16,
1390        code: &'static str,
1391        source: Option<&(dyn std::error::Error + 'static)>,
1392    ) {
1393        self.shared.events.event(&Event {
1394            kind,
1395            id: RequestId::of(env),
1396            route,
1397            status,
1398            code,
1399            source,
1400        });
1401    }
1402
1403    /// The one function every refusal and error goes through: the status,
1404    /// the error template for it or the built-in page, the reference; the
1405    /// code and source to the events only.
1406    #[allow(clippy::too_many_arguments)]
1407    fn refuse(
1408        &self,
1409        status: u16,
1410        code: &'static str,
1411        kind: EventKind,
1412        route: Option<&str>,
1413        source: Option<&(dyn std::error::Error + 'static)>,
1414        at: At<'_>,
1415        reply: &mut HttpReply,
1416    ) {
1417        self.event(kind, at.env, route, status, code, source);
1418        let reference = RequestId::of(at.env).to_string();
1419        let mut body = Vec::with_capacity(512);
1420        let mut done = false;
1421        if let (Some(engine), Some(&(_, t))) = (
1422            self.shared.render.as_deref(),
1423            self.shared.errors.iter().find(|(s, _)| *s == status),
1424        ) {
1425            let fields = [
1426                ("status", Value::Int(i64::from(status))),
1427                ("title", Value::Text(error::title(status))),
1428                ("message", Value::Text(error::message(status))),
1429                ("reference", Value::Text(&reference)),
1430            ];
1431            let view = Fields(&fields);
1432            let mut out = Output::new(&mut body, self.shared.page_limit);
1433            if engine.render(t, &view, &mut out).is_ok() {
1434                done = true;
1435            } else {
1436                body.clear();
1437            }
1438        }
1439        if !done {
1440            error::builtin_page(status, &reference, &mut body);
1441        }
1442        reply
1443            .status(status)
1444            .header("content-type", "text/html; charset=utf-8")
1445            .header("x-content-type-options", "nosniff")
1446            .header("cache-control", "no-store")
1447            .body(&body);
1448        self.secure(reply, at.tls, |_| false);
1449    }
1450
1451    /// Adds the policy's security headers, except those `has` says the reply
1452    /// set already.
1453    fn secure(&self, reply: &mut HttpReply, tls: bool, has: impl Fn(&str) -> bool) {
1454        for (name, value) in self.shared.security.fields(tls) {
1455            if !has(name) {
1456                reply.header(name, value.as_bytes());
1457            }
1458        }
1459    }
1460
1461    /// Whether HTTPS-only headers apply: the request came over HTTPS and is
1462    /// not for a development host.
1463    fn tls(&self, env: &Envelope, authority: Option<&str>) -> bool {
1464        if env.client().scheme() != Scheme::Https {
1465            return false;
1466        }
1467        let a = authority.unwrap_or("").to_ascii_lowercase();
1468        !(self.site.development && is_dev_host(split_port(a.trim_end_matches('.')).0))
1469    }
1470}
1471
1472impl<G: Gate> HttpService for App<G> {
1473    fn call(&self, request: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
1474        self.respond(request, env, reply)
1475    }
1476}
1477
1478impl<G: Gate> fmt::Debug for App<G> {
1479    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1480        f.debug_struct("App")
1481            .field("site", &self.site)
1482            .field("base", &self.shared.base)
1483            .field("routes", &self.entries.len())
1484            .finish_non_exhaustive()
1485    }
1486}
1487
1488/// Percent-encodes a query component: everything but unreserved and `/`.
1489fn encode_component(s: &str, out: &mut String) {
1490    const HEX: &[u8; 16] = b"0123456789ABCDEF";
1491    for b in s.bytes() {
1492        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
1493            out.push(b as char);
1494        } else {
1495            out.push('%');
1496            out.push(HEX[(b >> 4) as usize] as char);
1497            out.push(HEX[(b & 15) as usize] as char);
1498        }
1499    }
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504    use super::*;
1505
1506    #[test]
1507    fn test_split_port() {
1508        assert_eq!(split_port("a.test:80"), ("a.test", Some("80")));
1509        assert_eq!(split_port("a.test"), ("a.test", None));
1510        assert_eq!(split_port("[::1]:8080"), ("[::1]", Some("8080")));
1511        assert_eq!(split_port("[::1]"), ("[::1]", None));
1512    }
1513
1514    #[test]
1515    fn test_valid_host() {
1516        for ok in ["example.com", "localhost:8080", "[::1]", "127.0.0.1:80"] {
1517            assert!(valid_host(ok), "{ok}");
1518        }
1519        for bad in ["", "a b", "a:", "a:x", "[zz]", "a/b"] {
1520            assert!(!valid_host(bad), "{bad}");
1521        }
1522    }
1523
1524    #[test]
1525    fn test_selector() {
1526        assert!(Routes::prefix("/blog").matches("/blog", None));
1527        assert!(Routes::prefix("/blog/").matches("/blog/:slug", None));
1528        assert!(!Routes::prefix("/blog").matches("/blogger", None));
1529        assert!(Routes::named(&["a"]).matches("/x", Some("a")));
1530        assert!(!Routes::named(&["a"]).matches("/x", None));
1531    }
1532}