Skip to main content

compose_lens/model/
expose.rs

1//! Raw-preserving service exposed-port declarations.
2
3use crate::source::SourceSpan;
4
5use super::Located;
6
7/// The YAML scalar category of one authored service `expose` item.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9#[non_exhaustive]
10pub enum ExposeScalarKind {
11    /// A YAML number scalar.
12    Number,
13    /// A YAML string scalar, including quoted decimal spelling.
14    String,
15}
16
17/// A documented transport protocol suffix on one service `expose` item.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19#[non_exhaustive]
20pub enum ExposeProtocol {
21    /// Transmission Control Protocol (`tcp`).
22    Tcp,
23    /// User Datagram Protocol (`udp`).
24    Udp,
25}
26
27/// A decimal exposed port or inclusive decimal range, retained without integer parsing.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct ExposePort {
30    start: String,
31    end: Option<String>,
32}
33
34impl ExposePort {
35    /// Returns the exact first decimal spelling.
36    #[must_use]
37    pub fn start(&self) -> &str {
38        &self.start
39    }
40
41    /// Returns the exact range end spelling when one was authored.
42    #[must_use]
43    pub fn end(&self) -> Option<&str> {
44        self.end.as_deref()
45    }
46}
47
48/// The conservative semantic family of one service `expose` item.
49#[derive(Debug, Clone, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum ExposeItemKind {
52    /// A documented decimal port or range with an omitted, `tcp`, or `udp` suffix.
53    Documented {
54        /// Exact decimal port or range components.
55        port: ExposePort,
56        /// Exact documented protocol when explicitly present.
57        protocol: Option<ExposeProtocol>,
58    },
59    /// A well-shaped decimal port or range using the schema-recognized `sctp` suffix.
60    Sctp {
61        /// Exact decimal port or range components.
62        port: ExposePort,
63    },
64    /// A well-shaped decimal port or range using another raw protocol token.
65    UnknownProtocol {
66        /// Exact decimal port or range components.
67        port: ExposePort,
68        /// Exact unrecognized protocol spelling.
69        protocol: String,
70    },
71    /// A string whose effective spelling depends on Compose interpolation.
72    Expression,
73    /// An empty or otherwise malformed scalar retained for diagnostics.
74    Malformed,
75}
76
77/// One exact source-aware service `expose` sequence item.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ExposeItem {
80    raw: Located<String>,
81    scalar_kind: ExposeScalarKind,
82    kind: ExposeItemKind,
83}
84
85impl ExposeItem {
86    pub(crate) fn parse(raw: Located<String>, scalar_kind: ExposeScalarKind) -> Self {
87        let kind = classify_expose_item(raw.value(), scalar_kind);
88        Self { raw, scalar_kind, kind }
89    }
90
91    /// Returns the exact scalar value without normalizing its port, range, or protocol.
92    #[must_use]
93    pub fn value(&self) -> &str {
94        self.raw.value()
95    }
96
97    /// Returns the exact source span of this scalar item.
98    #[must_use]
99    pub const fn span(&self) -> SourceSpan {
100        self.raw.span()
101    }
102
103    /// Returns whether the authored YAML scalar was a string or number.
104    #[must_use]
105    pub const fn scalar_kind(&self) -> ExposeScalarKind {
106        self.scalar_kind
107    }
108
109    /// Returns the conservative raw-preserving item classification.
110    #[must_use]
111    pub const fn kind(&self) -> &ExposeItemKind {
112        &self.kind
113    }
114}
115
116/// An explicitly authored ordered service `expose` sequence.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Expose {
119    span: SourceSpan,
120    items: Vec<ExposeItem>,
121}
122
123impl Expose {
124    pub(crate) const fn new(span: SourceSpan, items: Vec<ExposeItem>) -> Self {
125        Self { span, items }
126    }
127
128    /// Returns the exact span of the complete authored sequence.
129    #[must_use]
130    pub const fn span(&self) -> SourceSpan {
131        self.span
132    }
133
134    /// Returns items in authored order, including exact duplicates.
135    #[must_use]
136    pub fn items(&self) -> &[ExposeItem] {
137        &self.items
138    }
139}
140
141pub(crate) fn classify_expose_item(value: &str, scalar_kind: ExposeScalarKind) -> ExposeItemKind {
142    if scalar_kind == ExposeScalarKind::String && value.contains('$') {
143        return ExposeItemKind::Expression;
144    }
145    let (port_value, protocol) = match value.split_once('/') {
146        Some((port, protocol)) if !protocol.is_empty() && !protocol.contains('/') => (port, Some(protocol)),
147        Some(_) => return ExposeItemKind::Malformed,
148        None => (value, None),
149    };
150    let Some(port) = parse_port(port_value) else {
151        return ExposeItemKind::Malformed;
152    };
153    match protocol {
154        None => ExposeItemKind::Documented { port, protocol: None },
155        Some("tcp") => ExposeItemKind::Documented {
156            port,
157            protocol: Some(ExposeProtocol::Tcp),
158        },
159        Some("udp") => ExposeItemKind::Documented {
160            port,
161            protocol: Some(ExposeProtocol::Udp),
162        },
163        Some("sctp") => ExposeItemKind::Sctp { port },
164        Some(protocol) => ExposeItemKind::UnknownProtocol {
165            port,
166            protocol: protocol.to_owned(),
167        },
168    }
169}
170
171pub(crate) fn valid_generated_expose_item(value: &str) -> bool {
172    matches!(
173        classify_expose_item(value, ExposeScalarKind::String),
174        ExposeItemKind::Documented { .. }
175    ) && !value.contains(['$', '\r', '\n', '\0'])
176}
177
178fn parse_port(value: &str) -> Option<ExposePort> {
179    let (start, end) = value
180        .split_once('-')
181        .map_or((value, None), |(start, end)| (start, Some(end)));
182    if !decimal(start) || end.is_some_and(|end| !decimal(end)) {
183        return None;
184    }
185    Some(ExposePort {
186        start: start.to_owned(),
187        end: end.map(str::to_owned),
188    })
189}
190
191fn decimal(value: &str) -> bool {
192    !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
193}
194
195#[cfg(test)]
196mod tests {
197    use super::{ExposeItemKind, ExposeProtocol, ExposeScalarKind, classify_expose_item, valid_generated_expose_item};
198
199    #[test]
200    fn classifies_without_fixed_width_or_default_protocol_normalization() {
201        let huge = "18446744073709551616000000000000000000000000000000";
202        assert!(matches!(
203            classify_expose_item(huge, ExposeScalarKind::Number),
204            ExposeItemKind::Documented { protocol: None, .. }
205        ));
206        assert!(matches!(
207            classify_expose_item("080-090/tcp", ExposeScalarKind::String),
208            ExposeItemKind::Documented {
209                protocol: Some(ExposeProtocol::Tcp),
210                ..
211            }
212        ));
213        assert!(matches!(
214            classify_expose_item("53/udp", ExposeScalarKind::String),
215            ExposeItemKind::Documented {
216                protocol: Some(ExposeProtocol::Udp),
217                ..
218            }
219        ));
220        assert!(matches!(
221            classify_expose_item("80/sctp", ExposeScalarKind::String),
222            ExposeItemKind::Sctp { .. }
223        ));
224        assert!(matches!(
225            classify_expose_item("80/HTTP", ExposeScalarKind::String),
226            ExposeItemKind::UnknownProtocol { protocol, .. } if protocol == "HTTP"
227        ));
228        assert_eq!(
229            classify_expose_item("${PORT:-80}", ExposeScalarKind::String),
230            ExposeItemKind::Expression
231        );
232        for value in ["", "80-", "-90", "80-90-100", "80/", "80/tcp/extra", "port"] {
233            assert_eq!(
234                classify_expose_item(value, ExposeScalarKind::String),
235                ExposeItemKind::Malformed
236            );
237        }
238    }
239
240    #[test]
241    fn generated_items_accept_only_resolved_documented_grammar() {
242        for value in ["0", "80", "080-090", "53/udp", "80/tcp"] {
243            assert!(valid_generated_expose_item(value));
244        }
245        for value in ["", "$PORT", "80/sctp", "80/HTTP", "80-", "80\n90"] {
246            assert!(!valid_generated_expose_item(value));
247        }
248    }
249}