Skip to main content

rama_http_headers/common/content_security_policy/
host_source.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::str::FromStr;
4
5use rama_net::Protocol;
6use rama_net::address::Domain;
7use rama_utils::macros::generate_set_and_with;
8
9use crate::Error;
10
11/// Port component of a CSP [`HostSource`].
12///
13/// Either a concrete port number or the `*` token which the spec allows
14/// to mean "any port".
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum HostSourcePort {
17    /// A concrete port number, rendered as `:N`.
18    Number(u16),
19    /// The `*` wildcard, rendered as `:*`.
20    Any,
21}
22
23impl fmt::Display for HostSourcePort {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Number(n) => write!(f, "{n}"),
27            Self::Any => f.write_str("*"),
28        }
29    }
30}
31
32/// A CSP `host-source` value: optional scheme, a [`Domain`] (which may
33/// itself be a wildcard subdomain), optional port (concrete or `*`),
34/// optional path.
35///
36/// Construct directly from a [`Domain`] for the common case, or via
37/// [`HostSource::try_parse`] from a wire-format string.
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct HostSource {
40    scheme: Option<Protocol>,
41    host: Domain,
42    port: Option<HostSourcePort>,
43    path: Option<Cow<'static, str>>,
44}
45
46// TODO: replace the above with Uri? or something like it ,as this is a bit silly..
47
48impl HostSource {
49    /// Wrap a bare [`Domain`] — no scheme, port, or path.
50    #[must_use]
51    pub fn new(host: Domain) -> Self {
52        Self {
53            scheme: None,
54            host,
55            port: None,
56            path: None,
57        }
58    }
59
60    /// Parse a CSP host-source from its wire form
61    /// (`[scheme://]host[:port][/path]`). Hosts with a leading `*.`
62    /// wildcard, port `*`, and arbitrary path tails are all accepted.
63    pub fn try_parse(s: &str) -> Result<Self, Error> {
64        let (scheme, rest) = match s.find("://") {
65            Some(idx) => {
66                let proto = Protocol::try_from(&s[..idx]).map_err(|_err| Error::invalid())?;
67                (Some(proto), &s[idx + 3..])
68            }
69            None => (None, s),
70        };
71        let (host_port, path) = match rest.find('/') {
72            Some(idx) => (&rest[..idx], Some(Cow::Owned(rest[idx..].to_owned()))),
73            None => (rest, None),
74        };
75        let (host_str, port) = match host_port.rfind(':') {
76            Some(idx) => {
77                let port_str = &host_port[idx + 1..];
78                let parsed = if port_str == "*" {
79                    Some(HostSourcePort::Any)
80                } else {
81                    let n: u16 = port_str.parse().map_err(|_err| Error::invalid())?;
82                    Some(HostSourcePort::Number(n))
83                };
84                (&host_port[..idx], parsed)
85            }
86            None => (host_port, None),
87        };
88        let host = Domain::try_from(host_str).map_err(|_err| Error::invalid())?;
89        Ok(Self {
90            scheme,
91            host,
92            port,
93            path,
94        })
95    }
96
97    /// The optional scheme component.
98    pub fn scheme(&self) -> Option<&Protocol> {
99        self.scheme.as_ref()
100    }
101
102    /// The host (domain) component.
103    pub fn host(&self) -> &Domain {
104        &self.host
105    }
106
107    /// The optional port component.
108    pub fn port(&self) -> Option<HostSourcePort> {
109        self.port
110    }
111
112    /// The optional path component (kept verbatim, including the
113    /// leading `/`).
114    pub fn path(&self) -> Option<&str> {
115        self.path.as_deref()
116    }
117
118    generate_set_and_with! {
119        /// Attach a scheme component (rendered as `<scheme>://…`).
120        pub fn scheme(mut self, scheme: Protocol) -> Self {
121            self.scheme = Some(scheme);
122            self
123        }
124    }
125
126    generate_set_and_with! {
127        /// Attach a concrete port component.
128        pub fn port(mut self, port: u16) -> Self {
129            self.port = Some(HostSourcePort::Number(port));
130            self
131        }
132    }
133
134    generate_set_and_with! {
135        /// Attach the `*` (any-port) component.
136        pub fn any_port(mut self) -> Self {
137            self.port = Some(HostSourcePort::Any);
138            self
139        }
140    }
141
142    generate_set_and_with! {
143        /// Attach a path component (will be rendered verbatim — caller
144        /// keeps the leading `/`).
145        pub fn path(mut self, path: impl Into<Cow<'static, str>>) -> Self {
146            self.path = Some(path.into());
147            self
148        }
149    }
150}
151
152impl From<Domain> for HostSource {
153    fn from(host: Domain) -> Self {
154        Self::new(host)
155    }
156}
157
158impl<'a> TryFrom<&'a str> for HostSource {
159    type Error = Error;
160    fn try_from(s: &'a str) -> Result<Self, Self::Error> {
161        Self::try_parse(s)
162    }
163}
164
165impl TryFrom<String> for HostSource {
166    type Error = Error;
167    fn try_from(s: String) -> Result<Self, Self::Error> {
168        Self::try_parse(&s)
169    }
170}
171
172impl FromStr for HostSource {
173    type Err = Error;
174    fn from_str(s: &str) -> Result<Self, Self::Err> {
175        Self::try_parse(s)
176    }
177}
178
179impl fmt::Display for HostSource {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        if let Some(scheme) = &self.scheme {
182            write!(f, "{}://", scheme.as_str())?;
183        }
184        write!(f, "{}", self.host)?;
185        if let Some(port) = &self.port {
186            write!(f, ":{port}")?;
187        }
188        if let Some(path) = &self.path {
189            f.write_str(path)?;
190        }
191        Ok(())
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn bare_domain_round_trips() {
201        let h = HostSource::try_parse("example.com").unwrap();
202        assert_eq!(h.to_string(), "example.com");
203        assert_eq!(h.scheme(), None);
204        assert_eq!(h.port(), None);
205        assert_eq!(h.path(), None);
206    }
207
208    #[test]
209    fn scheme_plus_host_round_trips() {
210        let h = HostSource::try_parse("https://raw.githubusercontent.com").unwrap();
211        assert_eq!(h.to_string(), "https://raw.githubusercontent.com");
212        assert_eq!(h.scheme().unwrap().as_str(), "https");
213    }
214
215    #[test]
216    fn full_form_round_trips() {
217        let h = HostSource::try_parse("https://*.example.com:8443/api/*").unwrap();
218        assert_eq!(h.to_string(), "https://*.example.com:8443/api/*");
219        assert!(h.host().is_wildcard());
220        assert_eq!(h.port(), Some(HostSourcePort::Number(8443)));
221        assert_eq!(h.path(), Some("/api/*"));
222    }
223
224    #[test]
225    fn any_port_round_trips() {
226        let h = HostSource::try_parse("https://example.com:*").unwrap();
227        assert_eq!(h.to_string(), "https://example.com:*");
228        assert_eq!(h.port(), Some(HostSourcePort::Any));
229    }
230
231    #[test]
232    fn builder_assembles_components() {
233        let h = HostSource::new(Domain::from_static("example.com"))
234            .with_scheme(Protocol::HTTPS)
235            .with_port(8443)
236            .with_path("/api/");
237        assert_eq!(h.to_string(), "https://example.com:8443/api/");
238    }
239
240    #[test]
241    fn rejects_malformed_scheme() {
242        HostSource::try_parse("ht tp://example.com").unwrap_err();
243    }
244
245    #[test]
246    fn rejects_malformed_port() {
247        HostSource::try_parse("example.com:abc").unwrap_err();
248        HostSource::try_parse("example.com:99999").unwrap_err();
249    }
250}