Skip to main content

compose_lens/model/
hostname.rs

1//! Raw-preserving service hostnames.
2
3use super::Located;
4
5/// A service-level Compose `hostname` value with its authored string scalar retained.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct Hostname {
8    raw: Located<String>,
9    kind: HostnameKind,
10}
11
12impl Hostname {
13    pub(crate) fn parse(raw: Located<String>) -> Self {
14        let kind = if raw.value().contains('$') {
15            HostnameKind::Expression
16        } else if valid_hostname(raw.value()) {
17            HostnameKind::Resolved
18        } else {
19            HostnameKind::Invalid
20        };
21        Self { raw, kind }
22    }
23
24    /// Returns the complete authored scalar value and its source span.
25    #[must_use]
26    pub const fn raw(&self) -> &Located<String> {
27        &self.raw
28    }
29
30    /// Returns the conservative, non-destructive hostname classification.
31    #[must_use]
32    pub const fn kind(&self) -> &HostnameKind {
33        &self.kind
34    }
35
36    /// Reports whether the hostname is a resolved RFC-1123 literal.
37    #[must_use]
38    pub const fn is_resolved(&self) -> bool {
39        matches!(self.kind, HostnameKind::Resolved)
40    }
41}
42
43/// The semantic family of a service-level Compose hostname.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum HostnameKind {
47    /// A resolved ASCII hostname satisfying the conservative RFC-1123 grammar.
48    Resolved,
49    /// A scalar containing a dollar marker and therefore deferred to interpolation.
50    Expression,
51    /// A resolved literal that does not satisfy the conservative hostname grammar.
52    Invalid,
53}
54
55pub(crate) fn valid_hostname(value: &str) -> bool {
56    if !(1..=253).contains(&value.len()) || !value.is_ascii() {
57        return false;
58    }
59    value.split('.').all(|label| {
60        (1..=63).contains(&label.len())
61            && label.bytes().next().is_some_and(|byte| byte.is_ascii_alphanumeric())
62            && label.bytes().last().is_some_and(|byte| byte.is_ascii_alphanumeric())
63            && label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
64    })
65}
66
67#[cfg(test)]
68mod tests {
69    use super::{Hostname, HostnameKind, valid_hostname};
70    use crate::model::Located;
71    use crate::source::{SourceId, SourceSpan};
72
73    #[test]
74    fn validates_conservative_ascii_rfc_1123_hostnames() {
75        let label_63 = "a".repeat(63);
76        let maximum = format!("{label_63}.{label_63}.{label_63}.{}", "a".repeat(61));
77        for value in ["a", "3api", "API.Example-Corp.COM", maximum.as_str()] {
78            assert!(valid_hostname(value), "expected valid hostname {value}");
79        }
80        let label_64 = "a".repeat(64);
81        let too_long = format!("{maximum}.a");
82        for value in [
83            "",
84            ".",
85            "example.",
86            ".example",
87            "example..com",
88            "-example",
89            "example-",
90            "example_com",
91            "café.example",
92            label_64.as_str(),
93            too_long.as_str(),
94        ] {
95            assert!(!valid_hostname(value), "expected invalid hostname {value}");
96        }
97    }
98
99    #[test]
100    fn classifies_every_dollar_bearing_value_as_deferred() -> Result<(), &'static str> {
101        let value = "invalid_$_hostname";
102        let span = SourceSpan::new(SourceId::new(1), 0, value.len()).ok_or("valid test span expected")?;
103        let hostname = Hostname::parse(Located::new(value.to_owned(), span));
104        assert_eq!(hostname.raw().value(), value);
105        assert_eq!(hostname.kind(), &HostnameKind::Expression);
106        assert!(!hostname.is_resolved());
107        Ok(())
108    }
109}