Skip to main content

smolvm_protocol/
host_pattern.rs

1//! Matching for persisted network host entries.
2//!
3//! Bare entries retain the original exact-or-subdomain behavior. New strict
4//! entries use `=host` for exact matching or `*.host` for subdomains only.
5//! The `=` prefix is internal; users write `--allow-host-pattern host`.
6//! Host syntax follows RFC 1035 §§2.3.1, 2.3.4, 3.1 and RFC 1123 §2.1.
7
8/// Encode an opt-in host pattern for storage alongside legacy entries.
9pub fn encode_strict(pattern: &str) -> Result<String, String> {
10    // A single trailing dot denotes the DNS root; a second dot is an empty label.
11    let pattern = pattern.strip_suffix('.').unwrap_or(pattern);
12    // Only a leading `*.` is syntax; everything after it must be a hostname.
13    let (host, wildcard) = match pattern.strip_prefix("*.") {
14        Some(host) => (host, true),
15        None => (pattern, false),
16    };
17    // A pattern must name a host; `*.` alone has no DNS suffix to constrain it.
18    let empty_host = host.is_empty();
19    // RFC 1035 §§2.3.4/3.1: 255 wire octets permit 253 dotted ASCII bytes without the root dot.
20    let name_too_long = host.len() > 253;
21    // Only the leading `*.` has wildcard meaning; embedded or repeated stars are unsupported.
22    let extra_wildcard = host.contains('*');
23    // IP literals belong in CIDR policy; a bare pattern must be a DNS hostname.
24    let ip_literal = !wildcard && host.parse::<std::net::IpAddr>().is_ok();
25    let invalid_label = host.split('.').any(|label| {
26        // Empty labels represent consecutive dots, which are not hostname separators.
27        let empty = label.is_empty();
28        // RFC 1035 §3.1 limits each DNS label to 63 octets.
29        let too_long = label.len() > 63;
30        // RFC 1035's preferred host syntax (with RFC 1123's leading-digit update) forbids edge hyphens.
31        let edge_hyphen = label.starts_with('-') || label.ends_with('-');
32        // Limit host policies to ASCII letters, digits, and hyphens rather than arbitrary DNS owner names.
33        let non_hostname_character = !label
34            .bytes()
35            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
36        empty || too_long || edge_hyphen || non_hostname_character
37    });
38    if empty_host || name_too_long || extra_wildcard || ip_literal || invalid_label {
39        return Err(format!(
40            "invalid host pattern {pattern:?}: use an exact DNS hostname or *.domain"
41        ));
42    }
43    Ok(if wildcard {
44        format!("*.{}", host.to_ascii_lowercase())
45    } else {
46        format!("={}", host.to_ascii_lowercase())
47    })
48}
49
50/// Match a hostname against a legacy or opt-in network entry.
51pub fn matches(host: &str, entry: &str) -> bool {
52    // DNS comparisons ignore ASCII case and a terminal root dot; keep legacy normalization.
53    let host = host.trim_end_matches('.').to_ascii_lowercase();
54    let entry = entry.trim_end_matches('.').to_ascii_lowercase();
55    // `=` marks opt-in exact entries; an empty stored entry must not match a name.
56    if let Some(exact) = entry.strip_prefix('=') {
57        return !exact.is_empty() && host == exact;
58    }
59    // `*.` requires a dot-bounded, nonempty prefix, so it excludes the apex.
60    if let Some(suffix) = entry.strip_prefix("*.") {
61        return !suffix.is_empty()
62            && host
63                .strip_suffix(suffix)
64                .is_some_and(|prefix| prefix.ends_with('.') && prefix.len() > 1);
65    }
66    // Unmarked persisted entries retain the old apex-plus-subdomains behavior.
67    !entry.is_empty()
68        && (host == entry
69            || host
70                .strip_suffix(&entry)
71                .is_some_and(|prefix| prefix.ends_with('.')))
72}
73
74/// Get the exact hostname to resolve into static CIDRs, if one exists.
75/// Wildcard entries learn their addresses as concrete DNS queries succeed.
76pub fn static_resolution_host(entry: &str) -> Option<&str> {
77    if entry.starts_with("*.") {
78        None
79    } else {
80        Some(entry.strip_prefix('=').unwrap_or(entry))
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn old_and_new_entries_have_distinct_semantics() {
90        assert!(matches("api.example.com", "example.com"));
91        assert!(matches("example.com", "example.com"));
92        let exact = encode_strict("Example.COM").unwrap();
93        assert_eq!(exact, "=example.com");
94        assert_eq!(encode_strict("Example.COM.").unwrap(), exact);
95        assert!(matches("EXAMPLE.COM.", &exact));
96        assert!(!matches("api.example.com", &exact));
97        let wildcard = encode_strict("*.example.com").unwrap();
98        assert!(matches("api.example.com", &wildcard));
99        assert!(matches("a.b.example.com", &wildcard));
100        assert!(!matches("example.com", &wildcard));
101        assert!(!matches("notexample.com", &wildcard));
102        assert!(!matches("example.com.evil.test", &wildcard));
103    }
104
105    #[test]
106    fn rejects_invalid_patterns() {
107        for pattern in [
108            "",
109            "*",
110            "*.",
111            "*.*.example.com",
112            "foo*.example.com",
113            "https://example.com",
114            "example.com:443",
115            "example.com..",
116            "1.2.3.4",
117        ] {
118            assert!(encode_strict(pattern).is_err(), "{pattern}");
119        }
120    }
121
122    #[test]
123    fn enforces_dns_name_and_label_size_limits() {
124        let longest_name = [
125            "a".repeat(63),
126            "b".repeat(63),
127            "c".repeat(63),
128            "d".repeat(61),
129        ]
130        .join(".");
131        assert_eq!(longest_name.len(), 253);
132        assert!(encode_strict(&longest_name).is_ok());
133        assert!(encode_strict(&format!("{longest_name}d")).is_err());
134        assert!(encode_strict(&"a".repeat(64)).is_err());
135    }
136}