Skip to main content

actus_server/
host.rs

1//! Host aliases — a host as a name for a mounted controller.
2//!
3//! [`RouterBuilder::host_alias`](crate::RouterBuilder::host_alias) registers
4//! them (the `hosts` block of `app_routes!` is the declarative form), and the
5//! server applies them before routing. A request whose host an alias names is
6//! routed as the request for the alias's target — the aliased mount, then the
7//! host's captured labels — followed by the request's own path, unless that
8//! path lies under one of the alias's shared mounts, which keep their own
9//! paths.
10//!
11//! From then on it *is* that request: [`Request::path_parts`] holds the
12//! aliased path, so the router, every middleware and the handler see one
13//! address, and a middleware that re-derives the matched controller with
14//! [`Router::match_controller`] gets the one the server dispatched to. An
15//! aliased request is identical, apart from `Host`, to a request any client
16//! could send to the aliased path directly: an alias adds addresses, never
17//! capabilities.
18//!
19//! [`Request::path_parts`]: crate::Request::path_parts
20//! [`Router::match_controller`]: crate::Router::match_controller
21
22use actus_controller::routing;
23use http::{HeaderMap, Uri, header};
24use std::fmt;
25
26/// Why [`RouterBuilder::host_alias`](crate::RouterBuilder::host_alias)
27/// refused an alias. The message names the entry and what is wrong with it;
28/// `app_routes!` returns it from the generated `init()`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct HostAliasError {
31    message: String,
32}
33
34impl HostAliasError {
35    fn new(host: Option<&str>, target: &str, problem: impl fmt::Display) -> Self {
36        let host = match host {
37            Some(h) => format!("{h:?}"),
38            None => "(not configured)".to_string(),
39        };
40        Self {
41            message: format!("host alias {host} => {target:?}: {problem}"),
42        }
43    }
44}
45
46impl fmt::Display for HostAliasError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(&self.message)
49    }
50}
51
52impl std::error::Error for HostAliasError {}
53
54/// One label of a host pattern.
55#[derive(Debug, Clone, PartialEq, Eq)]
56enum Label {
57    /// Matches this label, ignoring ASCII case. Stored lowercase.
58    Literal(String),
59    /// `{name}` — matches any one hostname label.
60    Capture(String),
61}
62
63/// A registered host alias. Built, and checked against the mounts, by
64/// [`HostAlias::new`].
65pub(crate) struct HostAlias {
66    /// The host pattern's labels, leftmost first.
67    labels: Vec<Label>,
68    /// The aliased mount's path segments.
69    mount: Vec<String>,
70    /// For each `{name}` segment of the target, in order: the position of
71    /// the host label that fills it.
72    fills: Vec<usize>,
73    /// The shared mounts' path segments.
74    shares: Vec<Vec<String>>,
75}
76
77impl HostAlias {
78    /// Parse `host`, `target` and `shares`, and check them against the mounts
79    /// `is_mount` recognises. `Ok(None)` when `host` is `None`: the entry is
80    /// still checked, so a wrong path fails every deployment, but no alias
81    /// exists in this one.
82    pub(crate) fn new(
83        host: Option<&str>,
84        target: &str,
85        shares: &[&str],
86        is_mount: impl Fn(&[String]) -> bool,
87    ) -> Result<Option<Self>, HostAliasError> {
88        let err = |problem: String| HostAliasError::new(host, target, problem);
89
90        // The target: a mount path, then only `{name}` segments.
91        let segments: Vec<&str> = target.split('/').filter(|s| !s.is_empty()).collect();
92        let first_capture = segments
93            .iter()
94            .position(|s| s.starts_with('{'))
95            .unwrap_or(segments.len());
96        let (mount, captures) = segments.split_at(first_capture);
97        let mount: Vec<String> = mount.iter().map(|s| s.to_string()).collect();
98        let mut names: Vec<&str> = Vec::new();
99        for segment in captures {
100            match capture_name(segment) {
101                Some(name) if names.contains(&name) => {
102                    return Err(err(format!("`{{{name}}}` appears twice in the target")));
103                }
104                Some(name) => names.push(name),
105                None => {
106                    return Err(err(format!(
107                        "`{segment}` follows a capture — a target is a mount path, then only \
108                         `{{name}}` segments"
109                    )));
110                }
111            }
112        }
113        // An exact mount is what keeps every aliased path inside it: a path
114        // that named no mount would fall through to a shallower one — a root
115        // catch-all, typically — and reach what the alias exists to confine.
116        if !is_mount(&mount) {
117            return Err(err(format!(
118                "`{}` is not a mounted controller — the target must name one",
119                mount.join("/")
120            )));
121        }
122
123        let mut shared = Vec::with_capacity(shares.len());
124        for share in shares {
125            let segs: Vec<String> = routing::family_segments(share)
126                .into_iter()
127                .map(String::from)
128                .collect();
129            if segs.is_empty() {
130                return Err(err(
131                    "a shared mount cannot be the root — that would share every mount".to_string(),
132                ));
133            }
134            if mount.starts_with(&segs) {
135                return Err(err(format!(
136                    "shared `{share}` contains the aliased mount — sharing it would reach every \
137                     path the alias confines"
138                )));
139            }
140            if !is_mount(&segs) {
141                return Err(err(format!("shared `{share}` is not a mounted controller")));
142            }
143            shared.push(segs);
144        }
145
146        let Some(host) = host else {
147            return Ok(None);
148        };
149        let labels = parse_pattern(host).map_err(err)?;
150        let mut fills = Vec::with_capacity(names.len());
151        for name in &names {
152            match labels
153                .iter()
154                .position(|l| matches!(l, Label::Capture(c) if c == name))
155            {
156                Some(position) => fills.push(position),
157                None => {
158                    return Err(err(format!(
159                        "the target's `{{{name}}}` is not captured by the host pattern"
160                    )));
161                }
162            }
163        }
164        if let Some(Label::Capture(unused)) = labels
165            .iter()
166            .find(|l| matches!(l, Label::Capture(c) if !names.contains(&c.as_str())))
167        {
168            return Err(err(format!(
169                "the host pattern captures `{{{unused}}}`, but the target does not use it"
170            )));
171        }
172
173        Ok(Some(Self {
174            labels,
175            mount,
176            fills,
177            shares: shared,
178        }))
179    }
180
181    /// This alias applied to `host` (as [`request_host`] returns it), or
182    /// `None` if the alias does not name that host.
183    pub(crate) fn hit(&self, host: &str) -> Option<AliasHit<'_>> {
184        if host.split('.').count() != self.labels.len() {
185            return None;
186        }
187        let names_host = host
188            .split('.')
189            .zip(&self.labels)
190            .all(|(label, pattern)| match pattern {
191                Label::Literal(literal) => label.eq_ignore_ascii_case(literal),
192                Label::Capture(_) => is_hostname_label(label),
193            });
194        if !names_host {
195            return None;
196        }
197        let labels: Vec<&str> = host.split('.').collect();
198        Some(AliasHit {
199            alias: self,
200            captures: self
201                .fills
202                .iter()
203                .map(|&i| labels[i].to_ascii_lowercase())
204                .collect(),
205        })
206    }
207}
208
209/// An alias that names a request's host, with the labels the host filled in.
210pub(crate) struct AliasHit<'r> {
211    alias: &'r HostAlias,
212    captures: Vec<String>,
213}
214
215impl AliasHit<'_> {
216    /// Route `path_parts` — the request's own path — under the alias. A path
217    /// under a shared mount is left as it is, and `None` returned. Any other
218    /// path becomes the target, with the captures filled in, followed by the
219    /// path; the target is returned joined with `/`, which is what
220    /// `Params::alias_prefix` reports.
221    pub(crate) fn apply(self, path_parts: &mut Vec<String>) -> Option<String> {
222        if self
223            .alias
224            .shares
225            .iter()
226            .any(|share| path_parts.starts_with(share))
227        {
228            return None;
229        }
230        let prefix: Vec<String> = self
231            .alias
232            .mount
233            .iter()
234            .cloned()
235            .chain(self.captures)
236            .collect();
237        let joined = prefix.join("/");
238        path_parts.splice(0..0, prefix);
239        Some(joined)
240    }
241}
242
243/// The host a request is addressed to, as aliases match it: the authority of
244/// an absolute-form request-target — which RFC 9112 §3.2.2 has a server use
245/// instead of `Host` — else the `Host` header; without its port, and without
246/// a trailing dot. `None` when there is none, or it is an IP literal
247/// (`[::1]`), which no pattern names.
248pub(crate) fn request_host<'a>(uri: &'a Uri, headers: &'a HeaderMap) -> Option<&'a str> {
249    let host = match uri.authority() {
250        Some(authority) => authority.host(),
251        None => {
252            let value = headers.get(header::HOST)?.to_str().ok()?;
253            value.rsplit_once(':').map_or(value, |(host, _port)| host)
254        }
255    };
256    if host.starts_with('[') {
257        return None;
258    }
259    let host = host.strip_suffix('.').unwrap_or(host);
260    (!host.is_empty()).then_some(host)
261}
262
263/// Parse a host pattern: dot-separated labels, each a hostname label or a
264/// `{name}` capture. One trailing dot is allowed.
265fn parse_pattern(pattern: &str) -> Result<Vec<Label>, String> {
266    let body = pattern.strip_suffix('.').unwrap_or(pattern);
267    if body.is_empty() {
268        return Err("the host pattern is empty".to_string());
269    }
270    let mut labels: Vec<Label> = Vec::new();
271    for raw in body.split('.') {
272        let label = if let Some(name) = capture_name(raw) {
273            if labels
274                .iter()
275                .any(|l| matches!(l, Label::Capture(c) if c == name))
276            {
277                return Err(format!("`{{{name}}}` appears twice in the host pattern"));
278            }
279            Label::Capture(name.to_string())
280        } else if is_hostname_label(raw) {
281            Label::Literal(raw.to_ascii_lowercase())
282        } else {
283            return Err(format!(
284                "`{raw}` in the host pattern is neither a hostname label (letters, digits and \
285                 hyphens) nor a `{{name}}` capture — a pattern names hosts only, without a port"
286            ));
287        };
288        labels.push(label);
289    }
290    Ok(labels)
291}
292
293/// `name` when `segment` is exactly `{name}` with a capture name: letters,
294/// digits and `_`.
295fn capture_name(segment: &str) -> Option<&str> {
296    segment
297        .strip_prefix('{')
298        .and_then(|s| s.strip_suffix('}'))
299        .filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'))
300}
301
302/// One hostname label: 1–63 letters, digits and hyphens (RFC 1123). A
303/// capture matches only these, so what it puts in the path is always a
304/// single, plain segment.
305fn is_hostname_label(label: &str) -> bool {
306    !label.is_empty()
307        && label.len() <= 63
308        && label
309            .bytes()
310            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    /// The mounts every test below checks against.
318    fn mounted(segments: &[String]) -> bool {
319        const MOUNTS: &[&[&str]] = &[
320            &["tenants"],
321            &["assets"],
322            &["shop", "stores"],
323            &["shop", "assets"],
324            &["api"],
325        ];
326        MOUNTS
327            .iter()
328            .any(|m| m.len() == segments.len() && m.iter().zip(segments).all(|(a, b)| a == b))
329    }
330
331    fn alias(host: &str, target: &str, shares: &[&str]) -> HostAlias {
332        HostAlias::new(Some(host), target, shares, mounted)
333            .expect("valid alias")
334            .expect("configured")
335    }
336
337    fn refused(host: Option<&str>, target: &str, shares: &[&str]) -> String {
338        match HostAlias::new(host, target, shares, mounted) {
339            Err(e) => e.to_string(),
340            Ok(_) => panic!("{host:?} => {target:?} {shares:?} must be refused"),
341        }
342    }
343
344    fn path(p: &str) -> Vec<String> {
345        p.split('/')
346            .filter(|s| !s.is_empty())
347            .map(String::from)
348            .collect()
349    }
350
351    /// Route `path` on `host` through `alias`: `Some((aliased path, prefix))`
352    /// when the alias names the host, `None` when it does not.
353    fn route(alias: &HostAlias, host: &str, p: &str) -> Option<(String, Option<String>)> {
354        let hit = alias.hit(host)?;
355        let mut parts = path(p);
356        let prefix = hit.apply(&mut parts);
357        Some((parts.join("/"), prefix))
358    }
359
360    #[test]
361    fn a_capture_becomes_the_first_action_segment_under_the_mount() {
362        let a = alias("{tenant}.example.test", "tenants/{tenant}", &[]);
363        assert_eq!(
364            route(&a, "acme.example.test", "orders/7"),
365            Some(("tenants/acme/orders/7".into(), Some("tenants/acme".into())))
366        );
367        // The host's root is the target itself.
368        assert_eq!(
369            route(&a, "acme.example.test", ""),
370            Some(("tenants/acme".into(), Some("tenants/acme".into())))
371        );
372    }
373
374    #[test]
375    fn shared_mounts_keep_their_paths_and_everything_else_goes_under_the_target() {
376        let a = alias(
377            "{store}.localhost",
378            "shop/stores/{store}",
379            &["shop/assets", "assets"],
380        );
381        // Shared: untouched, and not reported as aliased.
382        assert_eq!(
383            route(&a, "s1.localhost", "shop/assets/logo.svg"),
384            Some(("shop/assets/logo.svg".into(), None))
385        );
386        assert_eq!(
387            route(&a, "s1.localhost", "assets"),
388            Some(("assets".into(), None))
389        );
390        // Segment-aligned: `assetsx` is not under `assets`.
391        assert_eq!(
392            route(&a, "s1.localhost", "assetsx/y").map(|(p, _)| p),
393            Some("shop/stores/s1/assetsx/y".into())
394        );
395        // Every other mount's path lands under the target instead.
396        assert_eq!(
397            route(&a, "s1.localhost", "api/things").map(|(p, _)| p),
398            Some("shop/stores/s1/api/things".into())
399        );
400    }
401
402    #[test]
403    fn hosts_match_by_label_ignoring_case_and_capture_only_hostname_labels() {
404        let a = alias("{tenant}.example.test", "tenants/{tenant}", &[]);
405        // Case-insensitive, and the capture arrives lowercased.
406        assert_eq!(
407            route(&a, "ACME.Example.TEST", "").map(|(p, _)| p),
408            Some("tenants/acme".into())
409        );
410        // One label per capture: more or fewer labels is another host.
411        assert!(a.hit("a.b.example.test").is_none());
412        assert!(a.hit("example.test").is_none());
413        // A capture matches letters, digits and hyphens only — never a
414        // character that could split or escape a path segment.
415        assert!(a.hit("0c7e-11ef.example.test").is_some());
416        for bad in [
417            "a_b.example.test",
418            "a%2fb.example.test",
419            "a b.example.test",
420            "a/b.example.test",
421        ] {
422            assert!(a.hit(bad).is_none(), "{bad:?} must not be captured");
423        }
424        assert!(a.hit(&format!("{}.example.test", "x".repeat(64))).is_none());
425        // Literal labels must match.
426        assert!(a.hit("acme.example.org").is_none());
427    }
428
429    #[test]
430    fn a_pattern_without_captures_names_one_host() {
431        let a = alias("admin.example.test", "api", &[]);
432        assert_eq!(
433            route(&a, "admin.example.test", "x"),
434            Some(("api/x".into(), Some("api".into())))
435        );
436        assert!(a.hit("other.example.test").is_none());
437    }
438
439    #[test]
440    fn the_request_host_is_the_authority_else_host_without_port_or_trailing_dot() {
441        let origin: Uri = "/orders/7".parse().unwrap();
442        let mut headers = HeaderMap::new();
443        headers.insert(header::HOST, "acme.example.test:8080".parse().unwrap());
444        assert_eq!(request_host(&origin, &headers), Some("acme.example.test"));
445
446        headers.insert(header::HOST, "acme.example.test.".parse().unwrap());
447        assert_eq!(request_host(&origin, &headers), Some("acme.example.test"));
448
449        // Absolute form: the request-target's authority wins over `Host`.
450        let absolute: Uri = "http://acme.example.test:8080/orders/7".parse().unwrap();
451        headers.insert(header::HOST, "elsewhere.test".parse().unwrap());
452        assert_eq!(request_host(&absolute, &headers), Some("acme.example.test"));
453
454        // IP literals and a missing host name no host.
455        headers.insert(header::HOST, "[::1]:8080".parse().unwrap());
456        assert_eq!(request_host(&origin, &headers), None);
457        assert_eq!(request_host(&origin, &HeaderMap::new()), None);
458    }
459
460    #[test]
461    fn an_unconfigured_alias_is_checked_but_not_registered() {
462        assert!(
463            HostAlias::new(None, "tenants/{tenant}", &["assets"], mounted)
464                .expect("the paths are valid")
465                .is_none()
466        );
467        // …and its paths are still checked, in every deployment.
468        assert!(refused(None, "tenantz/{tenant}", &[]).contains("not a mounted controller"));
469    }
470
471    #[test]
472    fn a_target_must_name_a_mount_and_use_every_capture() {
473        let e = refused(Some("{t}.example.test"), "tenantz/{t}", &[]);
474        assert!(e.contains("`tenantz` is not a mounted controller"), "{e}");
475        assert!(e.contains(r#""{t}.example.test""#), "names the entry: {e}");
476
477        // A mount *below* a mounted prefix is not itself a mount.
478        let e = refused(Some("{t}.example.test"), "tenants/x/{t}", &[]);
479        assert!(e.contains("not a mounted controller"), "{e}");
480
481        let e = refused(Some("{t}.example.test"), "tenants/{t}/extra", &[]);
482        assert!(e.contains("follows a capture"), "{e}");
483
484        let e = refused(Some("{t}.example.test"), "tenants/{u}", &[]);
485        assert!(e.contains("not captured by the host pattern"), "{e}");
486
487        let e = refused(Some("{t}.{u}.example.test"), "tenants/{t}", &[]);
488        assert!(e.contains("does not use it"), "{e}");
489
490        let e = refused(Some("{t}.example.test"), "tenants/{t}/{t}", &[]);
491        assert!(e.contains("appears twice in the target"), "{e}");
492    }
493
494    #[test]
495    fn a_share_must_be_a_mount_outside_the_aliased_one() {
496        let e = refused(Some("{t}.example.test"), "tenants/{t}", &["asets"]);
497        assert!(
498            e.contains("shared `asets` is not a mounted controller"),
499            "{e}"
500        );
501
502        let e = refused(Some("{t}.example.test"), "tenants/{t}", &["tenants"]);
503        assert!(e.contains("contains the aliased mount"), "{e}");
504
505        // An ancestor of the aliased mount would reach it too.
506        let e = refused(Some("{s}.localhost"), "shop/stores/{s}", &["shop"]);
507        assert!(e.contains("contains the aliased mount"), "{e}");
508
509        let e = refused(Some("{t}.example.test"), "tenants/{t}", &["*"]);
510        assert!(e.contains("cannot be the root"), "{e}");
511    }
512
513    #[test]
514    fn malformed_patterns_are_refused() {
515        for (pattern, why) in [
516            ("", "is empty"),
517            ("{t}.localhost:8080", "without a port"),
518            ("a..localhost", "neither a hostname label"),
519            ("x{t}.localhost", "neither a hostname label"),
520            ("{t}.{t}.localhost", "appears twice in the host pattern"),
521            ("{bad name}.localhost", "neither a hostname label"),
522        ] {
523            let e = refused(Some(pattern), "tenants/{t}", &[]);
524            assert!(e.contains(why), "{pattern:?}: {e}");
525        }
526    }
527}