1use 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::cors::Cors;
18use crate::csrf;
19use crate::cx::{Out, is_local_target};
20use crate::error::{self, BuildError, Problem};
21use crate::headers::SecurityHeaders;
22use crate::policy::{CachePolicy, Cacheable, Compression, Served, compressible};
23use crate::route::{Access, BoxHandler, Group, Route};
24use crate::router::{self, Captures, Trie};
25use crate::url::{self, TrailingSlash};
26use crate::{
27 Cx, Denial, DenialKind, Event, EventKind, Events, Fields, Gate, Identity, NoEvents, Open,
28 Output, Render, Request, RequestId, Result, Stage, TemplateId, Value,
29};
30
31pub const DEFAULT_PAGE_LIMIT: usize = 8 << 20;
33
34const ERROR_STATUSES: [u16; 14] = [
36 400, 401, 403, 404, 405, 409, 410, 413, 421, 422, 429, 500, 501, 503,
37];
38
39#[non_exhaustive]
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Hook {
44 Routed,
47 Finish,
50 BeforeRender,
53}
54
55#[derive(Clone, Debug)]
57pub struct Routes(Selector);
58
59#[derive(Clone, Debug)]
60enum Selector {
61 All,
62 Prefix(String),
63 Named(Vec<&'static str>),
64}
65
66impl Routes {
67 #[must_use]
69 pub fn all() -> Self {
70 Self(Selector::All)
71 }
72
73 #[must_use]
75 pub fn prefix(prefix: impl Into<String>) -> Self {
76 Self(Selector::Prefix(prefix.into()))
77 }
78
79 #[must_use]
81 pub fn named(names: &[&'static str]) -> Self {
82 Self(Selector::Named(names.to_vec()))
83 }
84
85 fn matches(&self, pattern: &str, name: Option<&str>) -> bool {
86 match &self.0 {
87 Selector::All => true,
88 Selector::Prefix(p) => {
89 let p = p.trim_end_matches('/');
90 pattern == p
91 || pattern
92 .strip_prefix(p)
93 .is_some_and(|rest| rest.starts_with('/'))
94 }
95 Selector::Named(names) => name.is_some_and(|n| names.contains(&n)),
96 }
97 }
98}
99
100pub(crate) type HookFn<I> = Arc<dyn Fn(&mut Cx<'_, I>) -> Result + Send + Sync>;
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub struct Classified {
105 pub route: Option<RouteId>,
107 pub work: WorkClass,
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
114pub struct RouteId(pub u32);
115
116pub(crate) struct Shared {
118 pub(crate) render: Option<Box<dyn Render>>,
119 pub(crate) checkpoint: Option<Box<dyn crate::Checkpoint>>,
120 pub(crate) events: Box<dyn Events>,
121 pub(crate) page_limit: usize,
122 pub(crate) base: Box<str>,
123 errors: Vec<(u16, TemplateId)>,
124 sign_in: Option<Box<str>>,
125 challenge: Box<str>,
126 wipe: bool,
127 security: SecurityHeaders,
128 pub(crate) csrf: Option<csrf_sec::Csrf>,
129 pub(crate) redirect_hosts: Vec<Box<str>>,
130 compression: Option<Compression>,
131}
132
133#[derive(Clone, Copy)]
136pub(crate) struct At<'a> {
137 pub(crate) env: &'a Envelope,
138 pub(crate) tls: bool,
139 pub(crate) json: bool,
141}
142
143type ReadyFn = Arc<dyn Fn() -> bool + Send + Sync>;
144
145#[derive(Clone)]
146enum Health {
147 Live,
148 Ready(ReadyFn),
149}
150
151#[derive(Clone, Debug, Default)]
152struct Site {
153 hosts: Vec<Box<str>>,
155 aliases: Vec<Box<str>>,
156 development: bool,
157 slash: TrailingSlash,
158 lowercase: bool,
159 https_only: bool,
160}
161
162struct Entry<G: Gate> {
163 handler: BoxHandler<G::Identity>,
164 access: Access<G::Rule>,
165 names: Box<[Box<str>]>,
166 pattern: Box<str>,
167 work: WorkClass,
168 template: Option<TemplateId>,
169 csrf: bool,
170 limits: form_net::Limits,
171 cors: Option<Arc<Cors>>,
172 cache: CachePolicy,
173 cache_values: (Option<Box<str>>, Option<Box<str>>),
175 routed: Box<[HookFn<G::Identity>]>,
176 finish: Box<[HookFn<G::Identity>]>,
177 before_render: Box<[HookFn<G::Identity>]>,
178}
179
180struct Endpoint {
181 methods: [Option<u32>; 10],
183 allow: Box<str>,
184 set: MethodSet,
185}
186
187pub struct App<G: Gate = Open> {
216 gate: G,
217 shared: Shared,
218 site: Site,
219 trie: Trie,
220 endpoints: Vec<Endpoint>,
221 entries: Vec<Entry<G>>,
222 allow_all: Box<str>,
223 assets: Vec<Assets>,
224 health: Vec<(Box<str>, Health)>,
225}
226
227impl App<Open> {
228 #[must_use]
231 pub fn builder() -> Builder<Open> {
232 Builder::new(Open)
233 }
234}
235
236impl<G: Gate> App<G> {
237 #[must_use]
239 pub fn with_gate(gate: G) -> Builder<G> {
240 Builder::new(gate)
241 }
242}
243
244pub struct Builder<G: Gate> {
247 gate: G,
248 routes: Vec<Route<G::Identity, G::Rule>>,
249 hooks: Vec<(Hook, Routes, HookFn<G::Identity>)>,
250 site: Site,
251 base: String,
252 render: Option<Box<dyn Render>>,
253 checkpoint: Option<Box<dyn crate::Checkpoint>>,
254 events: Box<dyn Events>,
255 page_limit: usize,
256 sign_in: Option<String>,
257 challenge: String,
258 wipe: bool,
259 security: SecurityHeaders,
260 assets: Vec<Assets>,
261 health: Vec<(String, Health)>,
262 csrf: Option<csrf_sec::Csrf>,
263 redirect_hosts: Vec<Box<str>>,
264 compression: Option<Compression>,
265}
266
267impl<G: Gate> Builder<G> {
268 fn new(gate: G) -> Self {
269 Self {
270 gate,
271 routes: Vec::new(),
272 hooks: Vec::new(),
273 site: Site::default(),
274 base: String::new(),
275 render: None,
276 checkpoint: None,
277 events: Box::new(NoEvents),
278 page_limit: DEFAULT_PAGE_LIMIT,
279 sign_in: None,
280 challenge: "Session".into(),
281 wipe: false,
282 security: SecurityHeaders::default(),
283 assets: Vec::new(),
284 health: Vec::new(),
285 csrf: None,
286 redirect_hosts: Vec::new(),
287 compression: None,
288 }
289 }
290
291 #[must_use]
295 pub fn compress(mut self, compression: Compression) -> Self {
296 self.compression = Some(compression);
297 self
298 }
299
300 #[must_use]
305 pub fn csrf(mut self, csrf: csrf_sec::Csrf) -> Self {
306 self.csrf = Some(csrf);
307 self
308 }
309
310 #[must_use]
313 pub fn redirect_hosts(mut self, hosts: &[&str]) -> Self {
314 self.redirect_hosts.extend(
315 hosts
316 .iter()
317 .map(|h| h.to_ascii_lowercase().into_boxed_str()),
318 );
319 self
320 }
321
322 #[must_use]
325 pub fn assets(mut self, assets: Assets) -> Self {
326 self.assets.push(assets);
327 self
328 }
329
330 #[must_use]
333 pub fn health(mut self, path: &str) -> Self {
334 self.health.push((path.to_owned(), Health::Live));
335 self
336 }
337
338 #[must_use]
342 pub fn ready(mut self, path: &str, check: impl Fn() -> bool + Send + Sync + 'static) -> Self {
343 self.health
344 .push((path.to_owned(), Health::Ready(Arc::new(check))));
345 self
346 }
347
348 #[must_use]
351 pub fn security_headers(mut self, policy: SecurityHeaders) -> Self {
352 self.security = policy;
353 self
354 }
355
356 #[must_use]
358 pub fn route(mut self, route: Route<G::Identity, G::Rule>) -> Self {
359 self.routes.push(route);
360 self
361 }
362
363 #[must_use]
365 pub fn group(mut self, group: Group<G::Identity, G::Rule>) -> Self {
366 self.routes.extend(group.into_routes());
367 self
368 }
369
370 #[must_use]
372 pub fn hook<F>(mut self, hook: Hook, routes: Routes, f: F) -> Self
373 where
374 F: Fn(&mut Cx<'_, G::Identity>) -> Result + Send + Sync + 'static,
375 {
376 self.hooks.push((hook, routes, Arc::new(f)));
377 self
378 }
379
380 #[must_use]
384 pub fn host(mut self, host: &str) -> Self {
385 self.site.hosts.push(host.to_ascii_lowercase().into());
386 self
387 }
388
389 #[must_use]
392 pub fn alias(mut self, host: &str) -> Self {
393 self.site.aliases.push(host.to_ascii_lowercase().into());
394 self
395 }
396
397 #[must_use]
402 pub fn development(mut self) -> Self {
403 self.site.development = true;
404 self
405 }
406
407 #[must_use]
411 pub fn base(mut self, base: &str) -> Self {
412 self.base = base.to_owned();
413 self
414 }
415
416 #[must_use]
418 pub fn trailing_slash(mut self, policy: TrailingSlash) -> Self {
419 self.site.slash = policy;
420 self
421 }
422
423 #[must_use]
425 pub fn lowercase_paths(mut self) -> Self {
426 self.site.lowercase = true;
427 self
428 }
429
430 #[must_use]
433 pub fn https_only(mut self) -> Self {
434 self.site.https_only = true;
435 self
436 }
437
438 #[must_use]
442 pub fn sign_in(mut self, path: &str) -> Self {
443 self.sign_in = Some(path.to_owned());
444 self
445 }
446
447 #[must_use]
450 pub fn challenge(mut self, challenge: &str) -> Self {
451 challenge.clone_into(&mut self.challenge);
452 self
453 }
454
455 #[must_use]
457 pub fn render(mut self, engine: impl Render) -> Self {
458 self.render = Some(Box::new(engine));
459 self
460 }
461
462 #[must_use]
464 pub fn events(mut self, events: impl Events) -> Self {
465 self.events = Box::new(events);
466 self
467 }
468
469 #[must_use]
471 pub fn checkpoint(mut self, checkpoint: impl crate::Checkpoint) -> Self {
472 self.checkpoint = Some(Box::new(checkpoint));
473 self
474 }
475
476 #[must_use]
478 pub fn page_limit(mut self, bytes: usize) -> Self {
479 self.page_limit = bytes;
480 self
481 }
482
483 #[must_use]
487 pub fn wipe_buffers(mut self) -> Self {
488 self.wipe = true;
489 self
490 }
491
492 pub fn build(self) -> std::result::Result<App<G>, BuildError> {
500 let mut problems = Vec::new();
501 let mut problem = |subject: &str, message: String| {
502 problems.push(Problem {
503 subject: subject.to_owned(),
504 message,
505 });
506 };
507
508 let base = self.base.trim_end_matches('/');
510 if !base.is_empty() {
511 match url::normalize(base, self.site.lowercase) {
512 Ok(n) if n == base && !base.contains(['?', '#']) => {}
513 _ => problem(
514 "base",
515 format!("'{}' is not a canonical path such as '/blog'", self.base),
516 ),
517 }
518 }
519 for h in self.site.hosts.iter().chain(&self.site.aliases) {
520 if !valid_host(h) {
521 problem("host", format!("'{h}' is not a host name or address"));
522 }
523 }
524 if self.site.https_only && self.site.hosts.is_empty() {
525 problem(
526 "https_only",
527 "redirecting to HTTPS needs a declared host; add .host(\"example.com\")".into(),
528 );
529 }
530 if let Some(s) = &self.sign_in {
531 if !is_local_target(s) {
532 problem("sign_in", format!("'{s}' is not a site-relative path"));
533 }
534 }
535 if self.challenge.is_empty() || !self.challenge.bytes().all(|b| (0x20..0x7f).contains(&b)) {
536 problem("challenge", "the challenge must be visible ASCII".into());
537 }
538
539 if !self.security.valid() {
540 problem(
541 "security_headers",
542 "every header value must be visible ASCII and not empty".into(),
543 );
544 }
545 let mut assets = self.assets;
546 for a in &mut assets {
547 let subject = format!("assets {}", a.mount);
548 if a.mount.is_empty()
549 || !is_local_target(&a.mount)
550 || url::normalize(&a.mount, self.site.lowercase).map_or(true, |n| n != a.mount)
551 {
552 problem(
553 &subject,
554 "the mount must be a canonical path such as '/static'".into(),
555 );
556 }
557 match std::fs::canonicalize(&a.dir) {
558 Ok(root) if root.is_dir() => a.root = root,
559 _ => problem(
560 &subject,
561 format!("'{}' is not a folder that exists", a.dir.display()),
562 ),
563 }
564 for r in &self.routes {
565 if a.strip(&r.pattern).is_some() {
566 problem(
567 &r.pattern,
568 format!(
569 "the route is under the assets mount '{}' and can never run",
570 a.mount
571 ),
572 );
573 }
574 }
575 }
576 for h in &self.redirect_hosts {
577 if !valid_host(h) {
578 problem(
579 "redirect_hosts",
580 format!("'{h}' is not a host name or address"),
581 );
582 }
583 }
584 let mut health = Vec::new();
585 for (p, h) in self.health {
586 if !is_local_target(&p) || url::normalize(&p, false).map_or(true, |n| n != p) {
587 problem(
588 "health",
589 format!("'{p}' is not a canonical site-relative path"),
590 );
591 }
592 health.push((p.into_boxed_str(), h));
593 }
594
595 let mut errors = Vec::new();
597 if let Some(r) = &self.render {
598 for s in ERROR_STATUSES {
599 if let Some(t) = r.template(&format!("error/{s}")) {
600 errors.push((s, t));
601 }
602 }
603 }
604
605 let mut trie = Trie::default();
607 let mut endpoints: Vec<Endpoint> = Vec::new();
608 let mut entries: Vec<Entry<G>> = Vec::new();
609 let mut all = MethodSet::EMPTY;
610 for r in self.routes {
611 let parts = match router::parse(&r.pattern) {
612 Ok(p) => p,
613 Err(e) => {
614 problem(&r.pattern, e.message());
615 continue;
616 }
617 };
618 if r.handlers.is_empty() {
619 problem(&r.pattern, "the route has no handler".into());
620 continue;
621 }
622 if matches!(r.access, Access::Undeclared) {
623 problem(
624 &r.pattern,
625 "the route declares no access; add .public() or .allow(rule)".into(),
626 );
627 continue;
628 }
629 let template = match (r.template, &self.render) {
630 (None, _) => None,
631 (Some(t), None) => {
632 problem(
633 &r.pattern,
634 format!("template '{t}' is declared but no render engine is set"),
635 );
636 None
637 }
638 (Some(t), Some(engine)) => {
639 let id = engine.template(t);
640 if id.is_none() {
641 problem(&r.pattern, format!("the engine has no template '{t}'"));
642 }
643 id
644 }
645 };
646 let ep = trie.insert(&parts, endpoints.len() as u32) as usize;
647 if ep == endpoints.len() {
648 endpoints.push(Endpoint {
649 methods: [None; 10],
650 allow: Box::from(""),
651 set: MethodSet::EMPTY,
652 });
653 }
654 let names: Box<[Box<str>]> = router::names(&parts).into();
655 if r.csrf_exempt.is_some_and(|reason| reason.trim().is_empty()) {
656 problem(&r.pattern, "a CSRF exemption needs its reason".into());
657 }
658 if let Some(why) = r.cors.as_deref().and_then(Cors::problem) {
659 problem(&r.pattern, why.into());
660 }
661 if let Err(e) = r.form_limits.validate() {
662 problem(&r.pattern, format!("the form limits are inconsistent: {e}"));
663 }
664 for (m, h) in r.handlers {
665 let subject = format!("{} {}", m.as_str().unwrap_or("?"), r.pattern);
666 if m == Method::Other || m == Method::Connect || m == Method::Trace {
667 problem(&subject, "this method cannot be routed".into());
668 continue;
669 }
670 let slot = &mut endpoints[ep].methods[m.index() as usize];
671 if slot.is_some() {
672 problem(
673 &subject,
674 "a handler for this method and path exists already".into(),
675 );
676 continue;
677 }
678 *slot = Some(entries.len() as u32);
679 let csrf = r.csrf_exempt.is_none() && !m.is_safe();
680 if csrf && self.csrf.is_none() {
681 problem(
682 &subject,
683 "an unsafe method needs CSRF protection; set Builder::csrf(..) or mark the route csrf_exempt(\"reason\")".into(),
684 );
685 }
686 let pick = |at: Hook| -> Box<[HookFn<G::Identity>]> {
687 self.hooks
688 .iter()
689 .filter(|(h, sel, _)| *h == at && sel.matches(&r.pattern, r.name))
690 .map(|(_, _, f)| Arc::clone(f))
691 .collect()
692 };
693 entries.push(Entry {
694 handler: h,
695 access: r.access.clone(),
696 names: names.clone(),
697 pattern: r.pattern.as_str().into(),
698 work: r.work,
699 template,
700 csrf,
701 limits: r.form_limits,
702 cors: r.cors.clone(),
703 cache: r.cache,
704 cache_values: (
705 r.cache.value(true).map(String::into_boxed_str),
706 r.cache.value(false).map(String::into_boxed_str),
707 ),
708 routed: pick(Hook::Routed),
709 finish: pick(Hook::Finish),
710 before_render: pick(Hook::BeforeRender),
711 });
712 }
713 }
714 for e in &mut endpoints {
715 let mut set = MethodSet::EMPTY;
716 for m in [
717 Method::Get,
718 Method::Head,
719 Method::Post,
720 Method::Put,
721 Method::Delete,
722 Method::Options,
723 Method::Patch,
724 ] {
725 if e.methods[m.index() as usize].is_some() {
726 set = set.with(m);
727 }
728 }
729 if set.contains(Method::Get) {
730 set = set.with(Method::Head);
731 }
732 set = set.with(Method::Options);
733 e.set = set;
734 all = all.union(set);
735 let mut v = Vec::new();
736 set.write_allow(&mut v);
737 e.allow = String::from_utf8(v).unwrap_or_default().into();
738 }
739 if !problems.is_empty() {
740 return Err(BuildError::new(problems));
741 }
742 let mut v = Vec::new();
743 all.with(Method::Options).write_allow(&mut v);
744 Ok(App {
745 gate: self.gate,
746 shared: Shared {
747 render: self.render,
748 checkpoint: self.checkpoint,
749 events: self.events,
750 page_limit: self.page_limit,
751 base: base.into(),
752 errors,
753 sign_in: self.sign_in.map(Into::into),
754 challenge: self.challenge.into(),
755 wipe: self.wipe,
756 security: self.security,
757 csrf: self.csrf,
758 redirect_hosts: self.redirect_hosts,
759 compression: self.compression,
760 },
761 site: self.site,
762 trie,
763 endpoints,
764 entries,
765 allow_all: String::from_utf8(v).unwrap_or_default().into(),
766 assets,
767 health,
768 })
769 }
770}
771
772impl<G: Gate> fmt::Debug for Builder<G> {
773 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
774 f.debug_struct("Builder")
775 .field("routes", &self.routes)
776 .field("site", &self.site)
777 .field("base", &self.base)
778 .finish_non_exhaustive()
779 }
780}
781
782fn valid_host(h: &str) -> bool {
783 let (host, port) = split_port(h);
784 let host_ok = if let Some(inner) = host.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
785 inner.parse::<std::net::Ipv6Addr>().is_ok()
786 } else {
787 !host.is_empty()
788 && host.len() <= 253
789 && host
790 .bytes()
791 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
792 };
793 host_ok && port.is_none_or(|p| !p.is_empty() && p.parse::<u16>().is_ok())
794}
795
796fn split_port(a: &str) -> (&str, Option<&str>) {
799 if a.starts_with('[') {
800 return match a.find(']') {
801 Some(i) => match a[i + 1..].strip_prefix(':') {
802 Some(p) => (&a[..=i], Some(p)),
803 None => (a, None),
804 },
805 None => (a, None),
806 };
807 }
808 match a.rsplit_once(':') {
809 Some((h, p)) => (h, Some(p)),
810 None => (a, None),
811 }
812}
813
814fn is_dev_host(host: &str) -> bool {
816 const SUFFIX: &[u8] = b".localhost";
817 let b = host.as_bytes();
818 ["localhost", "127.0.0.1", "[::1]"]
819 .iter()
820 .any(|d| d.eq_ignore_ascii_case(host))
821 || (b.len() > SUFFIX.len() && b[b.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX))
822}
823
824fn host_parts(authority: &str) -> (&str, &str) {
826 let full = authority.trim_end_matches('.');
827 (full, split_port(full).0.trim_end_matches('.'))
828}
829
830fn host_listed(list: &[Box<str>], full: &str, host: &str) -> bool {
833 list.iter().any(|h| {
834 h.eq_ignore_ascii_case(full) || (split_port(h).1.is_none() && h.eq_ignore_ascii_case(host))
835 })
836}
837
838thread_local! {
839 static SCRATCH: Cell<Option<Box<Out>>> = const { Cell::new(None) };
843}
844
845const KEEP_CAPACITY: usize = 1 << 20;
847
848enum Placed<'a> {
850 Here(Cow<'a, str>),
851 Redirect(String),
852 Refuse(u16, &'static str, Option<url::UrlError>),
853}
854
855impl<G: Gate> App<G> {
856 pub fn respond(&self, req: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
858 self.serve(req, env, None, reply).outcome
859 }
860
861 pub fn serve(
866 &self,
867 req: &HttpRequest<'_>,
868 env: &Envelope,
869 who: Option<&G::Identity>,
870 reply: &mut HttpReply,
871 ) -> Served {
872 let mut report = Served {
873 outcome: Outcome::Done,
874 route: None,
875 cache: Cacheable::No,
876 };
877 report.outcome = self.run(req, env, who, reply, &mut report);
878 report
879 }
880
881 pub fn respond_identified(
886 &self,
887 req: &HttpRequest<'_>,
888 env: &Envelope,
889 who: &G::Identity,
890 reply: &mut HttpReply,
891 ) -> Outcome {
892 self.serve(req, env, Some(who), reply).outcome
893 }
894
895 #[must_use]
898 pub fn classify(&self, head: &HttpHead<'_>) -> Classified {
899 let none = Classified {
900 route: None,
901 work: WorkClass::Quick,
902 };
903 let Placed::Here(path) = self.place(head.authority(), head.path(), Scheme::Https, true)
904 else {
905 return none;
906 };
907 let Some((ep, _)) = self.trie.find(&path) else {
908 return none;
909 };
910 match self.entry_for(ep as usize, head.method()) {
911 Some(i) => Classified {
912 route: Some(RouteId(i)),
913 work: self.entries[i as usize].work,
914 },
915 None => none,
916 }
917 }
918
919 #[must_use]
921 pub fn gate(&self) -> &G {
922 &self.gate
923 }
924
925 fn entry_for(&self, ep: usize, m: Method) -> Option<u32> {
926 let methods = &self.endpoints[ep].methods;
927 match m {
928 Method::Other => None,
929 Method::Head => {
930 methods[Method::Head.index() as usize].or(methods[Method::Get.index() as usize])
931 }
932 m => methods[m.index() as usize],
933 }
934 }
935
936 fn place<'a>(
939 &self,
940 authority: Option<&'a str>,
941 raw: &'a str,
942 scheme: Scheme,
943 quiet: bool,
944 ) -> Placed<'a> {
945 let site = &self.site;
946 let authority = authority.unwrap_or("");
947 let (full, host_only) = host_parts(authority);
948 let listed = |list: &[Box<str>]| host_listed(list, full, host_only);
949 let dev = site.development && is_dev_host(host_only);
950 let mut target_host: Option<&str> = None;
951 if !site.hosts.is_empty() && !dev && !listed(&site.hosts) {
954 if listed(&site.aliases) {
955 target_host = site.hosts.first().map(AsRef::as_ref);
956 } else {
957 return Placed::Refuse(421, "host.unknown", None);
958 }
959 }
960 let mut https = scheme == Scheme::Https;
961 if site.https_only && !https && !dev {
962 https = true;
963 target_host = target_host.or(site.hosts.first().map(AsRef::as_ref));
964 }
965
966 let canon = match url::normalize(raw, site.lowercase) {
968 Ok(c) => c,
969 Err(e) => return Placed::Refuse(400, e.code(), Some(e)),
970 };
971 let base = self.shared.base.as_ref();
972 let c: &str = &canon;
973 let rel_start = if base.is_empty() {
974 0
975 } else if c == base {
976 c.len()
977 } else if c.strip_prefix(base).is_some_and(|r| r.starts_with('/')) {
978 base.len()
979 } else {
980 return Placed::Refuse(404, "site.outside_base", None);
981 };
982 let rel = &c[rel_start..];
983 let mut fixed: Option<String> = None;
984 let health = self.health.iter().any(|(p, _)| p.as_ref() == rel);
985 if rel.is_empty() {
986 fixed = Some("/".into());
987 } else if rel.len() > 1 && !health {
988 match site.slash {
989 TrailingSlash::Strip => {
990 if let Some(s) = rel.strip_suffix('/') {
991 fixed = Some(s.to_owned());
992 }
993 }
994 TrailingSlash::Add => {
995 let trimmed = rel.strip_suffix('/').unwrap_or(rel);
996 let file_like = trimmed.rsplit('/').next().unwrap_or("").contains('.');
997 if file_like && rel.ends_with('/') {
998 fixed = Some(trimmed.to_owned());
1000 } else if !file_like && !rel.ends_with('/') {
1001 fixed = Some(format!("{rel}/"));
1002 }
1003 }
1004 TrailingSlash::Ignore => {}
1005 }
1006 }
1007 if target_host.is_none() && fixed.is_none() {
1008 if let Cow::Borrowed(c) = canon {
1009 let mut path = &c[rel_start..];
1010 if site.slash == TrailingSlash::Ignore && path.len() > 1 {
1011 path = path.strip_suffix('/').unwrap_or(path);
1012 }
1013 return Placed::Here(Cow::Borrowed(path));
1014 }
1015 }
1016 if quiet {
1017 return Placed::Refuse(0, "", None);
1018 }
1019 let rel_out = fixed.as_deref().unwrap_or(rel);
1020 let mut loc = String::with_capacity(raw.len() + 32);
1021 if let Some(h) = target_host {
1022 loc.push_str(if https { "https://" } else { "http://" });
1023 loc.push_str(h);
1024 }
1025 loc.push_str(base);
1026 loc.push_str(rel_out);
1027 Placed::Redirect(loc)
1028 }
1029
1030 fn run(
1031 &self,
1032 http: &HttpRequest<'_>,
1033 env: &Envelope,
1034 who: Option<&G::Identity>,
1035 reply: &mut HttpReply,
1036 report: &mut Served,
1037 ) -> Outcome {
1038 let method = http.method();
1039 let raw_path = http.path();
1040 let authority = http.head().authority();
1041 let at = At {
1042 env,
1043 tls: self.tls(env, authority),
1044 json: wants_json(http.header("accept")),
1045 };
1046 if raw_path == "*" {
1047 if self.claim(authority, &self.shared.base).is_none() {
1050 self.refuse(
1051 421,
1052 "host.unknown",
1053 EventKind::Refused,
1054 None,
1055 None,
1056 at,
1057 reply,
1058 );
1059 return Outcome::Done;
1060 }
1061 if method == Method::Options {
1062 reply.status(204).header("allow", self.allow_all.as_bytes());
1063 self.secure(reply, at.tls, |_| false);
1064 } else {
1065 self.refuse(
1066 400,
1067 "url.asterisk",
1068 EventKind::Refused,
1069 None,
1070 None,
1071 at,
1072 reply,
1073 );
1074 }
1075 return Outcome::Done;
1076 }
1077 let host_raw = authority.unwrap_or("");
1078 let placed = self.place(authority, raw_path, env.client().scheme(), false);
1079 let path = match placed {
1080 Placed::Here(path) => path,
1081 Placed::Redirect(mut loc) => {
1082 if let Some(q) = http.query() {
1083 loc.push('?');
1084 loc.push_str(q);
1085 }
1086 let status = if matches!(method, Method::Get | Method::Head) {
1087 301
1088 } else {
1089 308
1090 };
1091 reply.status(status).header("location", loc.as_bytes());
1092 self.secure(reply, at.tls, |_| false);
1093 return Outcome::Done;
1094 }
1095 Placed::Refuse(status, code, src) => {
1096 let src = src
1097 .as_ref()
1098 .map(|e| e as &(dyn std::error::Error + 'static));
1099 self.refuse(status, code, EventKind::Refused, None, src, at, reply);
1100 return Outcome::Done;
1101 }
1102 };
1103 if let Some(outcome) = self.early(http, at, &path, reply) {
1105 return outcome;
1106 }
1107 if let Some(outcome) = self.preflight(http, at, &path, reply) {
1109 return outcome;
1110 }
1111
1112 let req = Request {
1113 http,
1114 env,
1115 path: &path,
1116 host: host_raw,
1117 route: None,
1118 tls: at.tls,
1119 json: at.json,
1120 };
1121
1122 let owned;
1124 let owned_here = who.is_none();
1125 let who: &G::Identity = match who {
1126 Some(w) => w,
1127 None => match self.gate.authenticate(&req) {
1128 Ok(w) => {
1129 owned = w;
1130 &owned
1131 }
1132 Err(d) => {
1133 self.deny(d, &req, reply);
1134 return Outcome::Done;
1135 }
1136 },
1137 };
1138
1139 let mut gate_cookie = if owned_here { who.set_cookie() } else { None };
1140 let outcome = self.routed(http, env, at, req, who, &mut gate_cookie, reply, report);
1141 if let Some(c) = gate_cookie {
1147 reply.header("set-cookie", c.as_bytes());
1148 report.cache = Cacheable::No;
1149 }
1150 outcome
1151 }
1152
1153 #[allow(clippy::too_many_arguments)]
1155 fn routed<'r>(
1156 &'r self,
1157 http: &HttpRequest<'_>,
1158 env: &Envelope,
1159 at: At<'_>,
1160 mut req: Request<'r>,
1161 who: &G::Identity,
1162 gate_cookie: &mut Option<&str>,
1163 reply: &mut HttpReply,
1164 report: &mut Served,
1165 ) -> Outcome {
1166 let method = http.method();
1167 let path = req.path;
1168 let host_raw = req.host;
1169 let Some((ep, caps)) = self.trie.find(path) else {
1171 self.refuse(404, "route.none", EventKind::Refused, None, None, at, reply);
1172 return Outcome::Done;
1173 };
1174 let endpoint = &self.endpoints[ep as usize];
1175 let Some(idx) = self.entry_for(ep as usize, method) else {
1176 if method == Method::Options {
1177 reply.status(204).header("allow", endpoint.allow.as_bytes());
1178 self.secure(reply, at.tls, |_| false);
1179 return Outcome::Done;
1180 }
1181 let status = if method == Method::Other { 501 } else { 405 };
1182 let code = if status == 501 {
1183 "method.unknown"
1184 } else {
1185 "method.not_allowed"
1186 };
1187 self.refuse(status, code, EventKind::Refused, None, None, at, reply);
1188 if status == 405 {
1189 reply.header("allow", endpoint.allow.as_bytes());
1190 }
1191 return Outcome::Done;
1192 };
1193 let entry = &self.entries[idx as usize];
1194 req.route = Some(&entry.pattern);
1195 report.route = Some(RouteId(idx));
1196
1197 if let Access::Rule(rule) = &entry.access {
1199 if let Err(d) = self.gate.authorize(who, rule, &req) {
1200 self.deny(d, &req, reply);
1201 return Outcome::Done;
1202 }
1203 }
1204 if let Some(cp) = &self.shared.checkpoint {
1205 match cp.check(env, Stage::Authorized) {
1206 Verdict::Pass => {}
1207 Verdict::Deny { status } => {
1208 self.refuse(
1209 status,
1210 "checkpoint.denied",
1211 EventKind::Revoked,
1212 req.route,
1213 None,
1214 at,
1215 reply,
1216 );
1217 return Outcome::Done;
1218 }
1219 _ => {
1220 self.refuse(
1221 403,
1222 "checkpoint.killed",
1223 EventKind::Revoked,
1224 req.route,
1225 None,
1226 at,
1227 reply,
1228 );
1229 return Outcome::Close;
1230 }
1231 }
1232 }
1233
1234 let binding = who.csrf_binding();
1236 match (&self.shared.csrf, entry.csrf) {
1237 (Some(engine), true) => {
1238 let scheme = if env.client().scheme() == Scheme::Https {
1239 "https://"
1240 } else {
1241 "http://"
1242 };
1243 let own = format!("{scheme}{}", host_raw.to_ascii_lowercase());
1244 match csrf::check(engine, http, binding, &own, &entry.limits) {
1245 Ok(()) => {}
1246 Err(code) => {
1247 self.refuse(403, code, EventKind::Denied, req.route, None, at, reply);
1248 return Outcome::Done;
1249 }
1250 }
1251 }
1252 (None, true) => {
1254 self.refuse(
1255 500,
1256 "csrf.no_engine",
1257 EventKind::Failed,
1258 req.route,
1259 None,
1260 at,
1261 reply,
1262 );
1263 return Outcome::Done;
1264 }
1265 (_, false) => {}
1266 }
1267
1268 let mut out = SCRATCH.with(Cell::take).unwrap_or_default();
1270 out.reset();
1271 let outcome = self.handle(
1272 entry,
1273 req,
1274 who,
1275 caps,
1276 (gate_cookie, binding),
1277 &mut out,
1278 reply,
1279 &mut report.cache,
1280 );
1281 if self.shared.wipe {
1282 out.wipe();
1283 }
1284 if out.body.capacity() <= KEEP_CAPACITY && out.values.capacity() <= KEEP_CAPACITY {
1285 SCRATCH.with(|s| s.set(Some(out)));
1286 }
1287 outcome
1288 }
1289
1290 fn preflight(
1292 &self,
1293 http: &HttpRequest<'_>,
1294 at: At<'_>,
1295 path: &str,
1296 reply: &mut HttpReply,
1297 ) -> Option<Outcome> {
1298 if http.method() != Method::Options {
1299 return None;
1300 }
1301 let origin = http.header("origin")?;
1302 let wanted = Method::from_bytes(http.header("access-control-request-method")?);
1303 let (ep, _) = self.trie.find(path)?;
1304 let ep = ep as usize;
1305 if self.entry_for(ep, Method::Options).is_some() {
1306 return None;
1307 }
1308 let cors = self
1309 .entry_for(ep, wanted)
1310 .and_then(|i| self.entries[i as usize].cors.as_ref())?;
1311 let mut set = MethodSet::EMPTY;
1313 for m in self.endpoints[ep].set.iter() {
1314 let shares = self.entry_for(ep, m).is_some_and(|i| {
1315 self.entries[i as usize]
1316 .cors
1317 .as_ref()
1318 .is_some_and(|c| Arc::ptr_eq(c, cors))
1319 });
1320 if shares {
1321 set = set.with(m);
1322 }
1323 }
1324 let ok = cors.preflight(
1325 origin,
1326 wanted,
1327 http.header("access-control-request-headers"),
1328 set,
1329 reply,
1330 );
1331 if !ok {
1332 self.event(EventKind::Refused, at.env, None, 204, "cors.denied", None);
1333 }
1334 self.secure(reply, at.tls, |_| false);
1335 Some(Outcome::Done)
1336 }
1337
1338 fn early(
1340 &self,
1341 http: &HttpRequest<'_>,
1342 at: At<'_>,
1343 path: &str,
1344 reply: &mut HttpReply,
1345 ) -> Option<Outcome> {
1346 let method = http.method();
1347 let get_like = matches!(method, Method::Get | Method::Head);
1348 if let Some((_, h)) = self.health.iter().find(|(p, _)| p.as_ref() == path) {
1349 if !get_like {
1350 self.not_get(method, at, reply);
1351 return Some(Outcome::Done);
1352 }
1353 let (status, text) = match h {
1354 Health::Live => (200, "ok"),
1355 Health::Ready(f) if f() => (200, "ready"),
1356 Health::Ready(_) => (503, "not ready"),
1357 };
1358 reply.text(status, text).header("cache-control", "no-store");
1359 self.secure(reply, at.tls, |_| false);
1360 return Some(Outcome::Done);
1361 }
1362 let (assets, rel) = self
1363 .assets
1364 .iter()
1365 .find_map(|a| a.strip(path).map(|rel| (a, rel)))?;
1366 if !get_like {
1367 self.not_get(method, at, reply);
1368 return Some(Outcome::Done);
1369 }
1370 let found = assets::find(assets, rel, http.header("accept-encoding"));
1371 let key = (at.env.connection().get(), at.env.seq().get());
1372 if let Some(tag) = assets::continuing(key) {
1373 return Some(assets::next_part(key, &tag, found.as_ref(), http, reply));
1376 }
1377 let Some(found) = found else {
1378 self.refuse(
1379 404,
1380 "assets.none",
1381 EventKind::Refused,
1382 None,
1383 None,
1384 at,
1385 reply,
1386 );
1387 return Some(Outcome::Done);
1388 };
1389 let Some(outcome) = assets::serve(assets, &found, key, http, reply) else {
1390 self.refuse(
1391 500,
1392 "assets.unreadable",
1393 EventKind::Failed,
1394 None,
1395 None,
1396 at,
1397 reply,
1398 );
1399 return Some(Outcome::Done);
1400 };
1401 self.secure(reply, at.tls, |_| false);
1402 Some(outcome)
1403 }
1404
1405 fn not_get(&self, method: Method, at: At<'_>, reply: &mut HttpReply) {
1407 if method == Method::Options {
1408 reply.status(204).header("allow", "GET, HEAD, OPTIONS");
1409 self.secure(reply, at.tls, |_| false);
1410 } else {
1411 let (status, code) = if method == Method::Other {
1412 (501, "method.unknown")
1413 } else {
1414 (405, "method.not_allowed")
1415 };
1416 self.refuse(status, code, EventKind::Refused, None, None, at, reply);
1417 if status == 405 {
1418 reply.header("allow", "GET, HEAD, OPTIONS");
1419 }
1420 }
1421 }
1422
1423 #[allow(clippy::too_many_arguments)]
1424 fn handle(
1425 &self,
1426 entry: &Entry<G>,
1427 req: Request<'_>,
1428 who: &G::Identity,
1429 caps: Captures,
1430 (gate_cookie, binding): (&mut Option<&str>, Option<&[u8]>),
1431 out: &mut Out,
1432 reply: &mut HttpReply,
1433 cacheable: &mut Cacheable,
1434 ) -> Outcome {
1435 let at = At {
1436 env: req.env,
1437 tls: req.tls,
1438 json: req.json,
1439 };
1440 let route = req.route;
1441 let http = req.http;
1442 let guest = who.is_guest();
1443 let result = {
1444 let mut cx = Cx {
1445 req,
1446 who,
1447 caps,
1448 names: &entry.names,
1449 out: &mut *out,
1450 shared: &self.shared,
1451 template: entry.template,
1452 binding,
1453 limits: &entry.limits,
1454 before_render: &entry.before_render,
1455 rendering: false,
1456 };
1457 catch_unwind(AssertUnwindSafe(|| -> Result {
1458 for h in entry.routed.iter() {
1459 h(&mut cx)?;
1460 }
1461 entry.handler.call(&mut cx)?;
1462 for h in entry.finish.iter() {
1463 h(&mut cx)?;
1464 }
1465 Ok(())
1466 }))
1467 };
1468 match result {
1469 Ok(Ok(())) if !out.invalid => {
1470 if gate_cookie.is_some_and(|c| out.sets_cookie(c)) {
1473 *gate_cookie = None;
1474 }
1475 let cookies = gate_cookie.is_some() || out.has("set-cookie");
1480 let ours = !out.has("cache-control");
1481 if cookies {
1482 let public = out
1483 .get("cache-control")
1484 .is_some_and(|v| v.windows(6).any(|w| w.eq_ignore_ascii_case(b"public")));
1485 if ours || public {
1486 out.remove("cache-control");
1487 out.header("cache-control", b"private, no-store");
1488 }
1489 } else if ours {
1490 let (for_guest, for_member) = &entry.cache_values;
1491 let value = if guest { for_guest } else { for_member };
1492 if let Some(v) = value {
1493 out.header("cache-control", v.as_bytes());
1494 }
1495 }
1496 if let Some(c) = self.shared.compression {
1497 compress(c, http.header("accept-encoding"), out);
1498 }
1499 *cacheable = match entry.cache {
1500 CachePolicy::Guests { max_age } | CachePolicy::Public { max_age }
1501 if guest
1502 && ours
1503 && !cookies
1504 && entry.cors.is_none()
1505 && matches!(http.method(), Method::Get | Method::Head)
1506 && out.status == 200
1507 && out
1508 .get("vary")
1509 .is_none_or(|v| v.eq_ignore_ascii_case(b"accept-encoding")) =>
1510 {
1511 Cacheable::Guests { max_age }
1512 }
1513 _ => Cacheable::No,
1514 };
1515 reply.status(out.status);
1516 if let Some(c) = gate_cookie.take() {
1517 reply.header("set-cookie", c.as_bytes());
1518 }
1519 for &(ns, ne, vs, ve) in &out.spans {
1520 let name = core::str::from_utf8(&out.names[ns as usize..ne as usize])
1521 .unwrap_or("x-invalid");
1522 reply.header(name, &out.values[vs as usize..ve as usize]);
1523 }
1524 if !out.has("x-content-type-options") {
1525 reply.header("x-content-type-options", "nosniff");
1526 }
1527 if let Some(cors) = &entry.cors {
1528 cors.actual(http.header("origin"), reply);
1529 }
1530 self.secure(reply, at.tls, |n| out.has(n));
1531 reply.body(&out.body);
1532 Outcome::Done
1533 }
1534 Ok(Ok(())) => {
1535 out.reset();
1536 self.refuse(
1537 500,
1538 "reply.invalid_field",
1539 EventKind::Failed,
1540 route,
1541 None,
1542 at,
1543 reply,
1544 );
1545 Outcome::Done
1546 }
1547 Ok(Err(e)) => {
1548 out.reset();
1549 let src = std::error::Error::source(&e);
1550 self.refuse(
1551 e.status(),
1552 e.code(),
1553 EventKind::Failed,
1554 route,
1555 src,
1556 at,
1557 reply,
1558 );
1559 Outcome::Done
1560 }
1561 Err(_) => {
1562 out.reset();
1563 self.refuse(
1564 500,
1565 "handler.panic",
1566 EventKind::Panicked,
1567 route,
1568 None,
1569 at,
1570 reply,
1571 );
1572 Outcome::Done
1573 }
1574 }
1575 }
1576
1577 fn deny(&self, d: Denial, req: &Request<'_>, reply: &mut HttpReply) {
1579 let env = req.env;
1580 let at = At {
1581 env,
1582 tls: req.tls,
1583 json: req.json,
1584 };
1585 if d.kind() == DenialKind::SignIn {
1586 if let Some(page) = &self.shared.sign_in {
1587 if matches!(req.method(), Method::Get | Method::Head) {
1588 let mut loc = String::with_capacity(64);
1589 loc.push_str(&self.shared.base);
1590 loc.push_str(page);
1591 loc.push_str(if page.contains('?') {
1592 "&next="
1593 } else {
1594 "?next="
1595 });
1596 let mut next = String::new();
1597 next.push_str(&self.shared.base);
1598 next.push_str(req.path);
1599 if let Some(q) = req.query() {
1600 next.push('?');
1601 next.push_str(q);
1602 }
1603 encode_component(&next, &mut loc);
1604 self.event(EventKind::Denied, env, req.route, 303, d.code(), None);
1605 reply.status(303).header("location", loc.as_bytes());
1606 self.secure(reply, at.tls, |_| false);
1607 if let Some(c) = d.cookie() {
1608 reply.header("set-cookie", c.as_bytes());
1609 }
1610 return;
1611 }
1612 }
1613 }
1614 let status = d.status_code();
1615 self.refuse(
1616 status,
1617 d.code(),
1618 EventKind::Denied,
1619 req.route,
1620 None,
1621 at,
1622 reply,
1623 );
1624 if status == 401 {
1625 reply.header("www-authenticate", self.shared.challenge.as_bytes());
1626 }
1627 if let Some(c) = d.cookie() {
1628 reply.header("set-cookie", c.as_bytes());
1629 }
1630 }
1631
1632 fn event(
1633 &self,
1634 kind: EventKind,
1635 env: &Envelope,
1636 route: Option<&str>,
1637 status: u16,
1638 code: &'static str,
1639 source: Option<&(dyn std::error::Error + 'static)>,
1640 ) {
1641 self.shared.events.event(&Event {
1642 kind,
1643 id: RequestId::of(env),
1644 route,
1645 status,
1646 code,
1647 source,
1648 });
1649 }
1650
1651 #[allow(clippy::too_many_arguments)]
1655 fn refuse(
1656 &self,
1657 status: u16,
1658 code: &'static str,
1659 kind: EventKind,
1660 route: Option<&str>,
1661 source: Option<&(dyn std::error::Error + 'static)>,
1662 at: At<'_>,
1663 reply: &mut HttpReply,
1664 ) {
1665 self.event(kind, at.env, route, status, code, source);
1666 let reference = RequestId::of(at.env).to_string();
1667 let mut body = Vec::with_capacity(512);
1668 if at.json {
1669 error::json_body(status, &reference, &mut body);
1670 reply
1671 .status(status)
1672 .header("content-type", "application/json")
1673 .header("x-content-type-options", "nosniff")
1674 .header("cache-control", "no-store")
1675 .body(&body);
1676 self.secure(reply, at.tls, |_| false);
1677 return;
1678 }
1679 let mut done = false;
1680 if let (Some(engine), Some(&(_, t))) = (
1681 self.shared.render.as_deref(),
1682 self.shared.errors.iter().find(|(s, _)| *s == status),
1683 ) {
1684 let fields = [
1685 ("status", Value::Int(i64::from(status))),
1686 ("title", Value::Text(error::title(status))),
1687 ("message", Value::Text(error::message(status))),
1688 ("reference", Value::Text(&reference)),
1689 ];
1690 let view = Fields(&fields);
1691 let mut out = Output::new(&mut body, self.shared.page_limit);
1692 if engine.render(t, &view, &mut out).is_ok() {
1693 done = true;
1694 } else {
1695 body.clear();
1696 }
1697 }
1698 if !done {
1699 error::builtin_page(status, &reference, &mut body);
1700 }
1701 reply
1702 .status(status)
1703 .header("content-type", "text/html; charset=utf-8")
1704 .header("x-content-type-options", "nosniff")
1705 .header("cache-control", "no-store")
1706 .body(&body);
1707 self.secure(reply, at.tls, |_| false);
1708 }
1709
1710 fn secure(&self, reply: &mut HttpReply, tls: bool, has: impl Fn(&str) -> bool) {
1713 for (name, value) in self.shared.security.fields(tls) {
1714 if !has(name) {
1715 reply.header(name, value.as_bytes());
1716 }
1717 }
1718 }
1719
1720 pub(crate) fn claim(&self, authority: Option<&str>, path: &str) -> Option<(u8, usize)> {
1724 let (full, host_only) = host_parts(authority.unwrap_or(""));
1725 let listed = |list: &[Box<str>]| host_listed(list, full, host_only);
1726 let rank = if self.site.hosts.is_empty() {
1727 0
1728 } else if listed(&self.site.hosts) || listed(&self.site.aliases) {
1729 2
1730 } else if self.site.development && is_dev_host(host_only) {
1731 1
1732 } else {
1733 return None;
1734 };
1735 let base = self.shared.base.as_ref();
1736 let inside = base.is_empty()
1737 || path == base
1738 || path.strip_prefix(base).is_some_and(|r| r.starts_with('/'));
1739 inside.then_some((rank, base.len()))
1740 }
1741
1742 fn tls(&self, env: &Envelope, authority: Option<&str>) -> bool {
1745 if env.client().scheme() != Scheme::Https {
1746 return false;
1747 }
1748 let (_, host) = host_parts(authority.unwrap_or(""));
1749 !(self.site.development && is_dev_host(host))
1750 }
1751}
1752
1753impl<G: Gate> HttpService for App<G> {
1754 fn call(&self, request: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
1755 self.respond(request, env, reply)
1756 }
1757}
1758
1759impl<G: Gate> fmt::Debug for App<G> {
1760 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1761 f.debug_struct("App")
1762 .field("site", &self.site)
1763 .field("base", &self.shared.base)
1764 .field("routes", &self.entries.len())
1765 .finish_non_exhaustive()
1766 }
1767}
1768
1769fn wants_json(accept: Option<&[u8]>) -> bool {
1771 accept.is_some_and(|a| a.windows(4).any(|w| w.eq_ignore_ascii_case(b"json")))
1773 && parse_net::best_media(accept, &["text/html", "application/json"]) == Some(1)
1774}
1775
1776fn compress(c: Compression, accept_encoding: Option<&[u8]>, out: &mut Out) {
1780 if out.status != 200 || out.has("content-encoding") {
1781 return;
1782 }
1783 if !out.get("content-type").is_some_and(compressible) {
1784 return;
1785 }
1786 if !out.has("vary") {
1787 out.header("vary", b"accept-encoding");
1788 }
1789 let accepts = accept_encoding
1790 .and_then(|v| parse_net::best_encoding(Some(v), &["gzip", "identity"]))
1791 == Some(0);
1792 if !accepts || out.body.len() < c.min_bytes {
1793 return;
1794 }
1795 let level = compress_lib::Level::new(c.level).unwrap_or(compress_lib::Level::DEFAULT);
1796 let packed = compress_lib::gzip::compress_with(&out.body, level);
1797 if packed.len() < out.body.len() {
1798 out.body.clear();
1799 out.body.extend_from_slice(&packed);
1800 out.header("content-encoding", b"gzip");
1801 }
1802}
1803
1804fn encode_component(s: &str, out: &mut String) {
1806 const HEX: &[u8; 16] = b"0123456789ABCDEF";
1807 for b in s.bytes() {
1808 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
1809 out.push(b as char);
1810 } else {
1811 out.push('%');
1812 out.push(HEX[(b >> 4) as usize] as char);
1813 out.push(HEX[(b & 15) as usize] as char);
1814 }
1815 }
1816}
1817
1818#[cfg(test)]
1819mod tests {
1820 use super::*;
1821
1822 #[test]
1823 fn test_split_port() {
1824 assert_eq!(split_port("a.test:80"), ("a.test", Some("80")));
1825 assert_eq!(split_port("a.test"), ("a.test", None));
1826 assert_eq!(split_port("[::1]:8080"), ("[::1]", Some("8080")));
1827 assert_eq!(split_port("[::1]"), ("[::1]", None));
1828 }
1829
1830 #[test]
1831 fn test_valid_host() {
1832 for ok in ["example.com", "localhost:8080", "[::1]", "127.0.0.1:80"] {
1833 assert!(valid_host(ok), "{ok}");
1834 }
1835 for bad in ["", "a b", "a:", "a:x", "[zz]", "a/b"] {
1836 assert!(!valid_host(bad), "{bad}");
1837 }
1838 }
1839
1840 #[test]
1841 fn test_selector() {
1842 assert!(Routes::prefix("/blog").matches("/blog", None));
1843 assert!(Routes::prefix("/blog/").matches("/blog/:slug", None));
1844 assert!(!Routes::prefix("/blog").matches("/blogger", None));
1845 assert!(Routes::named(&["a"]).matches("/x", Some("a")));
1846 assert!(!Routes::named(&["a"]).matches("/x", None));
1847 }
1848}