1use actus_controller::routing;
23use http::{HeaderMap, Uri, header};
24use std::fmt;
25
26#[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#[derive(Debug, Clone, PartialEq, Eq)]
56enum Label {
57 Literal(String),
59 Capture(String),
61}
62
63pub(crate) struct HostAlias {
66 labels: Vec<Label>,
68 mount: Vec<String>,
70 fills: Vec<usize>,
73 shares: Vec<Vec<String>>,
75}
76
77impl HostAlias {
78 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 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 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 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
209pub(crate) struct AliasHit<'r> {
211 alias: &'r HostAlias,
212 captures: Vec<String>,
213}
214
215impl AliasHit<'_> {
216 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
243pub(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
263fn 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
293fn 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
302fn 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 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 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 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 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 assert_eq!(
392 route(&a, "s1.localhost", "assetsx/y").map(|(p, _)| p),
393 Some("shop/stores/s1/assetsx/y".into())
394 );
395 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 assert_eq!(
407 route(&a, "ACME.Example.TEST", "").map(|(p, _)| p),
408 Some("tenants/acme".into())
409 );
410 assert!(a.hit("a.b.example.test").is_none());
412 assert!(a.hit("example.test").is_none());
413 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 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 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 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 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 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 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}