1use regex::Regex;
61use serde::{Deserialize, Serialize};
62use std::collections::HashMap;
63use std::fmt;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum PlaceholderKind {
68 Any,
70 Int,
72 Slug,
74 Multi,
78}
79
80impl PlaceholderKind {
81 fn regex_fragment(self) -> &'static str {
82 match self {
83 PlaceholderKind::Any => "[^.]+",
84 PlaceholderKind::Int => r"\d+",
85 PlaceholderKind::Slug => "[a-z0-9-]+",
86 PlaceholderKind::Multi => r"[^.]+(?:\.[^.]+)*",
87 }
88 }
89}
90
91fn special_regex_fragment(name: &str) -> Option<&'static str> {
101 match name {
102 "domain" => Some(r"[a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)+"),
103 _ => None,
104 }
105}
106
107#[derive(Debug, Clone)]
109pub struct Placeholder {
110 pub name: String,
111 pub kind: PlaceholderKind,
112}
113
114#[derive(Debug, Clone, Default, Deserialize, Serialize)]
116pub struct RouteConfig {
117 pub name: String,
118 #[serde(rename = "match")]
121 pub r#match: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub path: Option<String>,
128 pub target: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub headers: Option<HashMap<String, String>>,
134}
135
136#[derive(Debug, Clone, Deserialize, Serialize)]
138pub struct RoutesFile {
139 #[serde(default = "default_version")]
140 pub version: u32,
141 pub routes: Vec<RouteConfig>,
142}
143
144fn default_version() -> u32 {
145 1
146}
147
148pub fn parse_yaml(src: &str) -> Result<RoutesFile, serde_yaml::Error> {
150 serde_yaml::from_str(src)
151}
152
153#[derive(Debug, Clone)]
155pub struct CompiledRoute {
156 pub name: String,
157 pub pattern: Regex,
158 pub placeholders: Vec<Placeholder>,
159 pub target_template: String,
160 pub header_templates: HashMap<String, String>,
161 pub match_pattern: String,
164 pub path_prefix: Option<String>,
167 pub namespace: String,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct RouteHit {
175 pub route_name: String,
176 pub target: String,
178 pub host_header: Option<String>,
180 pub other_headers: HashMap<String, String>,
182}
183
184#[derive(Debug, Clone)]
186pub enum CompileError {
187 InvalidPlaceholder { route: String, placeholder: String, reason: String },
189 UnknownKind { route: String, name: String, kind: String },
191 DuplicatePlaceholder { route: String, name: String },
193 InvalidRegex { route: String, source: String },
196 UndeclaredPlaceholder { route: String, name: String, location: String },
199 UnbalancedBraces { route: String, location: String },
201}
202
203impl fmt::Display for CompileError {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 match self {
206 CompileError::InvalidPlaceholder { route, placeholder, reason } => {
207 write!(f, "route '{}': invalid placeholder '{{{}}}': {}", route, placeholder, reason)
208 }
209 CompileError::UnknownKind { route, name, kind } => {
210 write!(f, "route '{}': unknown placeholder kind ':{}' for '{{{}}}' (expected int|slug|multi or none)", route, kind, name)
211 }
212 CompileError::DuplicatePlaceholder { route, name } => {
213 write!(f, "route '{}': placeholder '{{{}}}' declared twice in match pattern", route, name)
214 }
215 CompileError::InvalidRegex { route, source } => {
216 write!(f, "route '{}': internal regex compile error: {}", route, source)
217 }
218 CompileError::UndeclaredPlaceholder { route, name, location } => {
219 write!(f, "route '{}': placeholder '{{{}}}' used in {} but never declared in match pattern", route, name, location)
220 }
221 CompileError::UnbalancedBraces { route, location } => {
222 write!(f, "route '{}': unbalanced braces in {}", route, location)
223 }
224 }
225 }
226}
227
228impl std::error::Error for CompileError {}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
236enum Token {
237 Literal(String),
238 Placeholder { name: String, kind: Option<String> },
239}
240
241fn tokenize(s: &str, route: &str, location: &str) -> Result<Vec<Token>, CompileError> {
244 let mut out = Vec::new();
245 let mut buf = String::new();
246 let mut chars = s.chars().peekable();
247
248 while let Some(c) = chars.next() {
249 if c == '{' {
250 if !buf.is_empty() {
252 out.push(Token::Literal(std::mem::take(&mut buf)));
253 }
254 let mut spec = String::new();
256 let mut closed = false;
257 while let Some(&nc) = chars.peek() {
258 chars.next();
259 if nc == '}' {
260 closed = true;
261 break;
262 }
263 spec.push(nc);
264 }
265 if !closed {
266 return Err(CompileError::UnbalancedBraces {
267 route: route.to_string(),
268 location: location.to_string(),
269 });
270 }
271 let (name, kind) = match spec.split_once(':') {
273 Some((n, k)) => (n.to_string(), Some(k.to_string())),
274 None => (spec.clone(), None),
275 };
276 out.push(Token::Placeholder { name, kind });
277 } else if c == '}' {
278 return Err(CompileError::UnbalancedBraces {
279 route: route.to_string(),
280 location: location.to_string(),
281 });
282 } else {
283 buf.push(c);
284 }
285 }
286 if !buf.is_empty() {
287 out.push(Token::Literal(buf));
288 }
289 Ok(out)
290}
291
292fn parse_kind(route: &str, name: &str, kind: Option<&str>) -> Result<PlaceholderKind, CompileError> {
293 match kind {
294 None | Some("") => Ok(PlaceholderKind::Any),
295 Some("int") => Ok(PlaceholderKind::Int),
296 Some("slug") => Ok(PlaceholderKind::Slug),
297 Some("multi") => Ok(PlaceholderKind::Multi),
298 Some(other) => Err(CompileError::UnknownKind {
299 route: route.to_string(),
300 name: name.to_string(),
301 kind: other.to_string(),
302 }),
303 }
304}
305
306fn validate_name(route: &str, raw_spec: &str, name: &str) -> Result<(), CompileError> {
307 if name.is_empty() {
308 return Err(CompileError::InvalidPlaceholder {
309 route: route.to_string(),
310 placeholder: raw_spec.to_string(),
311 reason: "empty placeholder name".to_string(),
312 });
313 }
314 let first = name.chars().next().unwrap();
315 if !(first.is_ascii_alphabetic() || first == '_') {
316 return Err(CompileError::InvalidPlaceholder {
317 route: route.to_string(),
318 placeholder: raw_spec.to_string(),
319 reason: "name must start with a letter or '_'".to_string(),
320 });
321 }
322 for c in name.chars() {
323 if !(c.is_ascii_alphanumeric() || c == '_') {
324 return Err(CompileError::InvalidPlaceholder {
325 route: route.to_string(),
326 placeholder: raw_spec.to_string(),
327 reason: format!("name contains invalid character '{}'", c),
328 });
329 }
330 }
331 Ok(())
332}
333
334pub fn compile(routes: Vec<RouteConfig>) -> Result<Vec<CompiledRoute>, CompileError> {
343 compile_in_namespace(routes, "default")
344}
345
346pub fn compile_in_namespace(
349 routes: Vec<RouteConfig>,
350 namespace: &str,
351) -> Result<Vec<CompiledRoute>, CompileError> {
352 let mut out = Vec::with_capacity(routes.len());
353 for r in routes {
354 out.push(compile_one(r, namespace)?);
355 }
356 Ok(out)
357}
358
359fn normalize_path_prefix(p: &str) -> String {
362 if p.starts_with('/') {
363 p.to_string()
364 } else {
365 format!("/{}", p)
366 }
367}
368
369fn compile_one(cfg: RouteConfig, namespace: &str) -> Result<CompiledRoute, CompileError> {
370 let route_name = cfg.name.clone();
371 let match_pattern = cfg.r#match.clone();
372 let tokens = tokenize(&cfg.r#match, &route_name, "match pattern")?;
373
374 let mut declared: Vec<Placeholder> = Vec::new();
375 let mut regex_src = String::from("^");
376 for tok in &tokens {
377 match tok {
378 Token::Literal(lit) => {
379 regex_src.push_str(®ex::escape(lit));
380 }
381 Token::Placeholder { name, kind } => {
382 let raw_spec = match kind {
383 Some(k) => format!("{}:{}", name, k),
384 None => name.clone(),
385 };
386 validate_name(&route_name, &raw_spec, name)?;
387 let parsed_kind = parse_kind(&route_name, name, kind.as_deref())?;
388 if declared.iter().any(|p| p.name == *name) {
389 return Err(CompileError::DuplicatePlaceholder {
390 route: route_name,
391 name: name.clone(),
392 });
393 }
394 declared.push(Placeholder { name: name.clone(), kind: parsed_kind });
395 regex_src.push('(');
396 regex_src.push_str("?P<");
397 regex_src.push_str(name);
398 regex_src.push('>');
399 if kind.is_none() {
405 if let Some(frag) = special_regex_fragment(name) {
406 regex_src.push_str(frag);
407 } else {
408 regex_src.push_str(parsed_kind.regex_fragment());
409 }
410 } else {
411 regex_src.push_str(parsed_kind.regex_fragment());
412 }
413 regex_src.push(')');
414 }
415 }
416 }
417 regex_src.push('$');
418
419 let pattern = Regex::new(®ex_src).map_err(|e| CompileError::InvalidRegex {
420 route: route_name.clone(),
421 source: e.to_string(),
422 })?;
423
424 let target_tokens = tokenize(&cfg.target, &route_name, "target template")?;
426 for tok in &target_tokens {
427 if let Token::Placeholder { name, .. } = tok {
428 validate_name(&route_name, name, name)?;
429 if !declared.iter().any(|p| p.name == *name) {
430 return Err(CompileError::UndeclaredPlaceholder {
431 route: route_name,
432 name: name.clone(),
433 location: "target template".to_string(),
434 });
435 }
436 }
437 }
438
439 let mut header_templates: HashMap<String, String> = HashMap::new();
440 if let Some(headers) = cfg.headers {
441 for (k, v) in headers {
442 let header_tokens = tokenize(&v, &route_name, &format!("header '{}'", k))?;
443 for tok in &header_tokens {
444 if let Token::Placeholder { name, .. } = tok {
445 validate_name(&route_name, name, name)?;
446 if !declared.iter().any(|p| p.name == *name) {
447 return Err(CompileError::UndeclaredPlaceholder {
448 route: route_name.clone(),
449 name: name.clone(),
450 location: format!("header '{}'", k),
451 });
452 }
453 }
454 }
455 header_templates.insert(k, v);
456 }
457 }
458
459 let path_prefix = cfg.path.as_deref().map(normalize_path_prefix);
460
461 Ok(CompiledRoute {
462 name: route_name,
463 pattern,
464 placeholders: declared,
465 target_template: cfg.target,
466 header_templates,
467 match_pattern,
468 path_prefix,
469 namespace: namespace.to_string(),
470 })
471}
472
473fn strip_port(host: &str) -> &str {
480 match host.rfind(':') {
481 Some(i) => &host[..i],
482 None => host,
483 }
484}
485
486fn strip_trailing_slash(host: &str) -> &str {
488 host.strip_suffix('/').unwrap_or(host)
489}
490
491fn normalize(host: &str) -> String {
492 strip_trailing_slash(strip_port(host)).to_ascii_lowercase()
494}
495
496fn expand(template: &str, captures: &HashMap<String, String>) -> String {
498 let mut out = String::with_capacity(template.len());
502 let mut chars = template.chars().peekable();
503 while let Some(c) = chars.next() {
504 if c == '{' {
505 let mut spec = String::new();
506 while let Some(&nc) = chars.peek() {
507 chars.next();
508 if nc == '}' {
509 break;
510 }
511 spec.push(nc);
512 }
513 let name = match spec.split_once(':') {
515 Some((n, _)) => n.to_string(),
516 None => spec,
517 };
518 if let Some(v) = captures.get(&name) {
519 out.push_str(v);
520 }
521 } else {
524 out.push(c);
525 }
526 }
527 out
528}
529
530fn path_matches(prefix: &str, req_path: &str) -> bool {
534 if prefix == "/" {
535 return true;
536 }
537 if let Some(stripped) = prefix.strip_suffix('/') {
538 req_path == stripped || req_path.starts_with(prefix)
539 } else {
540 req_path == prefix || req_path.starts_with(&format!("{}/", prefix))
541 }
542}
543
544pub fn match_host(routes: &[CompiledRoute], host: &str) -> Option<RouteHit> {
547 match_host_with_domain(routes, host, None)
548}
549
550pub fn match_host_with_domain(
559 routes: &[CompiledRoute],
560 host: &str,
561 default_domain: Option<&str>,
562) -> Option<RouteHit> {
563 match_request(routes, host, "/", default_domain)
564}
565
566pub fn match_request(
575 routes: &[CompiledRoute],
576 host: &str,
577 req_path: &str,
578 default_domain: Option<&str>,
579) -> Option<RouteHit> {
580 let host = normalize(host);
581
582 if let Some(domain) = default_domain {
583 if !domain.is_empty() {
584 let domain_lc = domain.to_ascii_lowercase();
585 if host != domain_lc && !host.ends_with(&format!(".{}", domain_lc)) {
586 return None;
587 }
588 }
589 }
590
591 let mut best_idx: Option<usize> = None;
596 let mut best_priority: i64 = -1;
597 for (i, route) in routes.iter().enumerate() {
598 if !route.pattern.is_match(&host) {
599 continue;
600 }
601 let priority: i64 = match &route.path_prefix {
602 None => 0,
603 Some(prefix) => {
604 if !path_matches(prefix, req_path) {
605 continue;
606 }
607 prefix.len() as i64
608 }
609 };
610 if priority > best_priority {
611 best_priority = priority;
612 best_idx = Some(i);
613 }
614 }
615
616 let route = routes.get(best_idx?)?;
617 let caps = route.pattern.captures(&host)?;
618 let mut values: HashMap<String, String> = HashMap::new();
619 for p in &route.placeholders {
620 if let Some(m) = caps.name(&p.name) {
621 values.insert(p.name.clone(), m.as_str().to_string());
622 }
623 }
624
625 let target = expand(&route.target_template, &values);
626
627 let mut host_header: Option<String> = None;
628 let mut other_headers: HashMap<String, String> = HashMap::new();
629 for (k, tmpl) in &route.header_templates {
630 let v = expand(tmpl, &values);
631 if k.eq_ignore_ascii_case("host") {
632 host_header = Some(v);
633 } else {
634 other_headers.insert(k.clone(), v);
635 }
636 }
637
638 Some(RouteHit {
639 route_name: route.name.clone(),
640 target,
641 host_header,
642 other_headers,
643 })
644}
645
646#[cfg(test)]
651mod tests {
652 use super::*;
653
654 fn default_routes() -> Vec<CompiledRoute> {
655 let configs = vec![
656 RouteConfig {
657 name: "port-as-host".into(),
658 r#match: "{port:int}.{domain}".into(),
659 path: None,
660 target: "127.0.0.1:{port}".into(),
661 headers: None,
662 },
663 RouteConfig {
664 name: "host-double-dash-port".into(),
665 r#match: "{host}--{port:int}.{domain}".into(),
666 path: None,
667 target: "{host}:{port}".into(),
668 headers: Some({
669 let mut h = HashMap::new();
670 h.insert("Host".into(), "{host}".into());
671 h
672 }),
673 },
674 RouteConfig {
675 name: "subdomain-hoisting".into(),
676 r#match: "{prefix}.{host}.{domain}".into(),
677 path: None,
678 target: "{host}:80".into(),
679 headers: Some({
680 let mut h = HashMap::new();
681 h.insert("Host".into(), "{prefix}".into());
682 h
683 }),
684 },
685 RouteConfig {
686 name: "direct-forward".into(),
687 r#match: "{host}.{domain}".into(),
688 path: None,
689 target: "{host}:80".into(),
690 headers: Some({
691 let mut h = HashMap::new();
692 h.insert("Host".into(), "{host}".into());
693 h
694 }),
695 },
696 ];
697 compile(configs).expect("compile default routes")
698 }
699
700 fn m(routes: &[CompiledRoute], host: &str) -> Option<RouteHit> {
705 match_host_with_domain(routes, host, Some("fbi.com"))
706 }
707
708 #[test]
709 fn empty_routes_no_match() {
710 let hit = match_host(&[], "anything.fbi.com");
711 assert!(hit.is_none());
712 }
713
714 #[test]
715 fn port_as_host_matches() {
716 let routes = default_routes();
717 let hit = m(&routes, "3000.fbi.com").expect("should match");
718 assert_eq!(hit.route_name, "port-as-host");
719 assert_eq!(hit.target, "127.0.0.1:3000");
720 assert_eq!(hit.host_header, None);
721 }
722
723 #[test]
724 fn host_double_dash_port_matches() {
725 let routes = default_routes();
726 let hit = m(&routes, "api--3001.fbi.com").expect("should match");
727 assert_eq!(hit.route_name, "host-double-dash-port");
728 assert_eq!(hit.target, "api:3001");
729 assert_eq!(hit.host_header.as_deref(), Some("api"));
730 }
731
732 #[test]
733 fn subdomain_hoisting_matches() {
734 let routes = default_routes();
735 let hit = m(&routes, "admin.app.fbi.com").expect("should match");
736 assert_eq!(hit.route_name, "subdomain-hoisting");
737 assert_eq!(hit.target, "app:80");
738 assert_eq!(hit.host_header.as_deref(), Some("admin"));
739 }
740
741 #[test]
742 fn direct_forward_matches() {
743 let routes = default_routes();
744 let hit = m(&routes, "myserver.fbi.com").expect("should match");
745 assert_eq!(hit.route_name, "direct-forward");
746 assert_eq!(hit.target, "myserver:80");
747 assert_eq!(hit.host_header.as_deref(), Some("myserver"));
748 }
749
750 #[test]
751 fn port_in_host_is_stripped_before_match() {
752 let routes = default_routes();
753 let hit = m(&routes, "myserver.fbi.com:8080").expect("should match");
754 assert_eq!(hit.route_name, "direct-forward");
755 assert_eq!(hit.target, "myserver:80");
756 }
757
758 #[test]
759 fn trailing_slash_stripped() {
760 let routes = default_routes();
761 let hit = m(&routes, "3000.fbi.com/").expect("should match");
762 assert_eq!(hit.route_name, "port-as-host");
763 }
764
765 #[test]
766 fn host_header_is_case_insensitive() {
767 let routes = default_routes();
768 let hit = m(&routes, "API--3001.FBI.COM").expect("should match");
769 assert_eq!(hit.route_name, "host-double-dash-port");
770 assert_eq!(hit.target, "api:3001");
771 }
772
773 #[test]
774 fn multi_dot_subdomain_assigns_domain_greedily() {
775 let routes = default_routes();
787 let hit = match_host(&routes, "a.b.c.fbi.com").expect("should match");
788 assert_eq!(hit.route_name, "subdomain-hoisting");
789 assert_eq!(hit.target, "b:80");
791 assert_eq!(hit.host_header.as_deref(), Some("a"));
793 }
794
795 #[test]
796 fn multi_dot_subdomain_with_domain_filter_is_unambiguous() {
797 let routes = default_routes();
806 let hit = match_host_with_domain(&routes, "admin.app.fbi.com", Some("fbi.com"))
808 .expect("should match");
809 assert_eq!(hit.route_name, "subdomain-hoisting");
810 assert_eq!(hit.target, "app:80");
811 assert_eq!(hit.host_header.as_deref(), Some("admin"));
812 }
813
814 #[test]
815 fn first_match_wins() {
816 let routes = compile(vec![
817 RouteConfig {
818 name: "first".into(),
819 r#match: "{x}.{y}".into(),
820 path: None,
821 target: "first-target".into(),
822 headers: None,
823 },
824 RouteConfig {
825 name: "second".into(),
826 r#match: "{x}.{y}".into(),
827 path: None,
828 target: "second-target".into(),
829 headers: None,
830 },
831 ])
832 .unwrap();
833 let hit = match_host(&routes, "a.b").expect("should match");
834 assert_eq!(hit.route_name, "first");
835 assert_eq!(hit.target, "first-target");
836 }
837
838 #[test]
839 fn unknown_placeholder_kind_errors() {
840 let err = compile(vec![RouteConfig {
841 name: "bad".into(),
842 r#match: "{port:zzz}.com".into(),
843 path: None,
844 target: "x".into(),
845 headers: None,
846 }])
847 .unwrap_err();
848 match err {
849 CompileError::UnknownKind { kind, .. } => assert_eq!(kind, "zzz"),
850 e => panic!("expected UnknownKind, got {:?}", e),
851 }
852 }
853
854 #[test]
855 fn unbalanced_braces_in_pattern_errors() {
856 let err = compile(vec![RouteConfig {
857 name: "bad".into(),
858 r#match: "{port".into(),
859 path: None,
860 target: "x".into(),
861 headers: None,
862 }])
863 .unwrap_err();
864 match err {
865 CompileError::UnbalancedBraces { location, .. } => {
866 assert!(location.contains("match"))
867 }
868 e => panic!("expected UnbalancedBraces, got {:?}", e),
869 }
870 }
871
872 #[test]
873 fn duplicate_placeholder_errors() {
874 let err = compile(vec![RouteConfig {
875 name: "bad".into(),
876 r#match: "{x}.{x}".into(),
877 path: None,
878 target: "y".into(),
879 headers: None,
880 }])
881 .unwrap_err();
882 match err {
883 CompileError::DuplicatePlaceholder { name, .. } => assert_eq!(name, "x"),
884 e => panic!("expected DuplicatePlaceholder, got {:?}", e),
885 }
886 }
887
888 #[test]
889 fn undeclared_placeholder_in_target_errors() {
890 let err = compile(vec![RouteConfig {
891 name: "bad".into(),
892 r#match: "{x}.{y}".into(),
893 path: None,
894 target: "{z}".into(),
895 headers: None,
896 }])
897 .unwrap_err();
898 match err {
899 CompileError::UndeclaredPlaceholder { name, location, .. } => {
900 assert_eq!(name, "z");
901 assert!(location.contains("target"));
902 }
903 e => panic!("expected UndeclaredPlaceholder, got {:?}", e),
904 }
905 }
906
907 #[test]
908 fn invalid_placeholder_name_errors() {
909 let err = compile(vec![RouteConfig {
910 name: "bad".into(),
911 r#match: "{1foo}".into(),
912 path: None,
913 target: "x".into(),
914 headers: None,
915 }])
916 .unwrap_err();
917 match err {
918 CompileError::InvalidPlaceholder { .. } => {}
919 e => panic!("expected InvalidPlaceholder, got {:?}", e),
920 }
921 }
922
923 #[test]
924 fn int_kind_rejects_non_numeric() {
925 let routes = default_routes();
926 let hit = m(&routes, "abc.fbi.com").expect("should match");
929 assert_eq!(hit.route_name, "direct-forward");
930 assert_eq!(hit.target, "abc:80");
931 }
932
933 #[test]
934 fn match_host_with_domain_filter_accepts_matching() {
935 let routes = default_routes();
936 let hit = match_host_with_domain(&routes, "3000.fbi.com", Some("fbi.com"))
937 .expect("should match");
938 assert_eq!(hit.route_name, "port-as-host");
939 assert_eq!(hit.target, "127.0.0.1:3000");
940 }
941
942 #[test]
943 fn match_host_with_domain_filter_rejects_non_matching() {
944 let routes = default_routes();
945 let hit = match_host_with_domain(&routes, "evil.example.com", Some("fbi.com"));
946 assert!(hit.is_none());
947 }
948
949 #[test]
950 fn match_host_with_multi_dot_domain() {
951 let routes = compile(vec![RouteConfig {
955 name: "direct".into(),
956 r#match: "{host}.{domain}".into(),
957 path: None,
958 target: "{host}:80".into(),
959 headers: None,
960 }])
961 .unwrap();
962 let hit =
963 match_host_with_domain(&routes, "myserver.fbi.example.com", Some("fbi.example.com"))
964 .expect("should match");
965 assert_eq!(hit.target, "myserver:80");
966 }
967
968 #[test]
969 fn match_host_with_multi_dot_domain_rejects_wrong_suffix() {
970 let routes = compile(vec![RouteConfig {
971 name: "direct".into(),
972 r#match: "{host}.{domain}".into(),
973 path: None,
974 target: "{host}:80".into(),
975 headers: None,
976 }])
977 .unwrap();
978 let hit = match_host_with_domain(&routes, "myserver.other.com", Some("fbi.example.com"));
979 assert!(hit.is_none());
980 }
981
982 #[test]
983 fn multi_kind_captures_multi_dot_segments() {
984 let routes = compile(vec![RouteConfig {
985 name: "dns-passthrough".into(),
986 r#match: "{upstream:multi}.fbi.com".into(),
987 path: None,
988 target: "{upstream}:80".into(),
989 headers: None,
990 }])
991 .unwrap();
992
993 let hit = match_host(&routes, "github.com.fbi.com").unwrap();
994 assert_eq!(hit.target, "github.com:80");
995
996 let hit = match_host(&routes, "api.example.org.fbi.com").unwrap();
997 assert_eq!(hit.target, "api.example.org:80");
998
999 let hit = match_host(&routes, "single.fbi.com").unwrap();
1001 assert_eq!(hit.target, "single:80");
1002 }
1003
1004 #[test]
1005 fn multi_kind_with_host_header_rewrite() {
1006 let routes = compile(vec![RouteConfig {
1007 name: "dns-with-host".into(),
1008 r#match: "{upstream:multi}.fbi.com".into(),
1009 path: None,
1010 target: "{upstream}:443".into(),
1011 headers: Some(HashMap::from([("Host".into(), "{upstream}".into())])),
1012 }])
1013 .unwrap();
1014 let hit = match_host(&routes, "api.example.com.fbi.com").unwrap();
1015 assert_eq!(hit.target, "api.example.com:443");
1016 assert_eq!(hit.host_header.as_deref(), Some("api.example.com"));
1017 }
1018
1019 #[test]
1020 fn multi_kind_with_routes_yaml() {
1021 let yaml = r#"
1022routes:
1023 - name: dns-passthrough
1024 match: "{upstream:multi}.{domain}"
1025 target: "{upstream}:80"
1026"#;
1027 let parsed = parse_yaml(yaml).unwrap();
1028 let routes = compile(parsed.routes).unwrap();
1029 let hit = match_host(&routes, "github.com.fbi.com").unwrap();
1030 assert_eq!(hit.target, "github.com:80");
1031 }
1032
1033 #[test]
1034 fn slug_kind_accepts_lowercase_and_dashes() {
1035 let routes = compile(vec![RouteConfig {
1036 name: "slugged".into(),
1037 r#match: "{name:slug}.example".into(),
1038 path: None,
1039 target: "{name}".into(),
1040 headers: None,
1041 }])
1042 .unwrap();
1043 assert!(match_host(&routes, "my-service.example").is_some());
1044 assert!(match_host(&routes, "MY-SERVICE.example").is_some());
1046 assert!(match_host(&routes, "my_service.example").is_none());
1048 }
1049
1050 #[test]
1051 fn parse_yaml_default_routes() {
1052 let yaml = r#"
1053version: 1
1054routes:
1055 - name: port-as-host
1056 match: "{port:int}.{domain}"
1057 target: "127.0.0.1:{port}"
1058 - name: direct-forward
1059 match: "{host}.{domain}"
1060 target: "{host}:80"
1061 headers:
1062 Host: "{host}"
1063"#;
1064 let parsed = parse_yaml(yaml).expect("yaml should parse");
1065 assert_eq!(parsed.version, 1);
1066 assert_eq!(parsed.routes.len(), 2);
1067 assert_eq!(parsed.routes[0].name, "port-as-host");
1068 assert_eq!(parsed.routes[0].r#match, "{port:int}.{domain}");
1069 assert_eq!(parsed.routes[1].headers.as_ref().unwrap()["Host"], "{host}");
1070
1071 let compiled = compile(parsed.routes).unwrap();
1072 let hit = match_host(&compiled, "3000.fbi.com").unwrap();
1073 assert_eq!(hit.target, "127.0.0.1:3000");
1074 }
1075
1076 #[test]
1077 fn expand_passes_through_unknown_placeholders_silently() {
1078 let mut caps = HashMap::new();
1083 caps.insert("a".to_string(), "X".to_string());
1084 assert_eq!(expand("{a}-{b}", &caps), "X-");
1085 }
1086
1087 fn web_code_routes() -> Vec<CompiledRoute> {
1090 compile_in_namespace(
1091 vec![
1092 RouteConfig {
1093 name: "root".into(),
1094 r#match: "fbi.com".into(),
1095 path: Some("/".into()),
1096 target: "localhost:3001".into(),
1097 headers: None,
1098 },
1099 RouteConfig {
1100 name: "vscode".into(),
1101 r#match: "fbi.com".into(),
1102 path: Some("/_vscode/".into()),
1103 target: "localhost:9999".into(),
1104 headers: None,
1105 },
1106 ],
1107 "web-code",
1108 )
1109 .expect("compile web-code routes")
1110 }
1111
1112 #[test]
1113 fn path_prefix_longest_wins() {
1114 let routes = web_code_routes();
1115 let hit = match_request(&routes, "fbi.com", "/_vscode/", Some("fbi.com")).unwrap();
1116 assert_eq!(hit.target, "localhost:9999");
1117 let hit = match_request(&routes, "fbi.com", "/_vscode/stable/x.js", Some("fbi.com")).unwrap();
1118 assert_eq!(hit.target, "localhost:9999");
1119 let hit = match_request(&routes, "fbi.com", "/", Some("fbi.com")).unwrap();
1120 assert_eq!(hit.target, "localhost:3001");
1121 let hit = match_request(&routes, "fbi.com", "/owner/repo/tree/main", Some("fbi.com")).unwrap();
1122 assert_eq!(hit.target, "localhost:3001");
1123 }
1124
1125 #[test]
1126 fn path_prefix_boundary_not_substring() {
1127 let routes = web_code_routes();
1128 let hit = match_request(&routes, "fbi.com", "/_vscodex", Some("fbi.com")).unwrap();
1129 assert_eq!(hit.target, "localhost:3001");
1130 let hit = match_request(&routes, "fbi.com", "/_vscode", Some("fbi.com")).unwrap();
1131 assert_eq!(hit.target, "localhost:9999");
1132 }
1133
1134 #[test]
1135 fn explicit_root_path_beats_pathless() {
1136 let routes = compile(vec![
1137 RouteConfig {
1138 name: "pathless".into(),
1139 r#match: "fbi.com".into(),
1140 path: None,
1141 target: "localhost:1".into(),
1142 headers: None,
1143 },
1144 RouteConfig {
1145 name: "rooted".into(),
1146 r#match: "fbi.com".into(),
1147 path: Some("/".into()),
1148 target: "localhost:2".into(),
1149 headers: None,
1150 },
1151 ])
1152 .unwrap();
1153 let hit = match_request(&routes, "fbi.com", "/anything", None).unwrap();
1154 assert_eq!(hit.target, "localhost:2");
1155 }
1156
1157 #[test]
1158 fn namespace_is_tagged_on_compiled_route() {
1159 let routes = web_code_routes();
1160 assert!(routes.iter().all(|r| r.namespace == "web-code"));
1161 let bundled = compile(vec![RouteConfig {
1162 name: "x".into(),
1163 r#match: "{host}".into(),
1164 path: None,
1165 target: "{host}:80".into(),
1166 headers: None,
1167 }])
1168 .unwrap();
1169 assert_eq!(bundled[0].namespace, "default");
1170 }
1171}