Skip to main content

isoloom_spec/
probe.rs

1//! The parts of a check's probe a spec is validated against: its URL and host, and which
2//! machines can run checks.
3
4use std::fmt;
5
6use crate::images;
7use crate::model::Machine;
8
9/// What a probe targets: a machine of the environment on one of its networks (each target
10/// writes the address its own way), or a host as written (an address, an outside name).
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Host {
13    /// A machine of the environment, on one of its networks.
14    Machine {
15        /// The machine's name.
16        name: String,
17        /// The network whose address the probe uses.
18        network: String,
19    },
20    /// A host as written: an address or an outside name.
21    Literal(String),
22}
23
24impl fmt::Display for Host {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Host::Machine { name, .. } => f.write_str(name),
28            Host::Literal(h) => f.write_str(h),
29        }
30    }
31}
32
33/// An HTTP request's parts (the URL as parsed from the spec).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Url {
36    /// The scheme is `https` (else `http`).
37    pub https: bool,
38    /// The host the request goes to.
39    pub host: Host,
40    /// None: the scheme's default port.
41    pub port: Option<u16>,
42    /// From the first `/` on (at least `/`).
43    pub path: String,
44}
45
46impl Url {
47    /// Parses `http://host[:port][/path]`; None when it isn't one.
48    #[must_use]
49    pub fn parse(s: &str) -> Option<Url> {
50        let (https, rest) = match s.strip_prefix("https://") {
51            Some(r) => (true, r),
52            None => (false, s.strip_prefix("http://")?),
53        };
54        let (hostport, path) = match rest.split_once('/') {
55            Some((hostport, path)) => (hostport, format!("/{path}")),
56            None => (rest, "/".to_owned()),
57        };
58        let (host, port) = host_and_port(hostport)?;
59        if host.is_empty() || host.chars().any(|c| c.is_whitespace() || c == '\'' || c == '"' || c == '@') {
60            return None;
61        }
62        Some(Url {
63            https,
64            host: Host::Literal(host.to_owned()),
65            port,
66            path,
67        })
68    }
69
70    /// The URL with its host written by `host`, an IPv6 address in brackets
71    /// (`http://[fd00:20::10]:80/`).
72    #[must_use]
73    pub fn render(&self, host: &str) -> String {
74        let scheme = if self.https { "https" } else { "http" };
75        let bracketed;
76        let host = if host.contains(':') {
77            bracketed = format!("[{host}]");
78            bracketed.as_str()
79        } else {
80            host
81        };
82        match self.port {
83            Some(p) => format!("{scheme}://{host}:{p}{}", self.path),
84            None => format!("{scheme}://{host}{}", self.path),
85        }
86    }
87}
88
89/// `host[:port]`, or `[ipv6]` with an optional `:port` after the bracket.
90fn host_and_port(hostport: &str) -> Option<(&str, Option<u16>)> {
91    let port = |p: &str| p.parse::<u16>().ok().filter(|p| *p > 0);
92    if let Some(rest) = hostport.strip_prefix('[') {
93        let (host, after) = rest.split_once(']')?;
94        return match after.strip_prefix(':') {
95            Some(p) => Some((host, Some(port(p)?))),
96            None if after.is_empty() => Some((host, None)),
97            None => None,
98        };
99    }
100    match hostport.rsplit_once(':') {
101        Some((h, p)) => Some((h, Some(port(p)?))),
102        None => Some((hostport, None)),
103    }
104}
105
106/// Whether a machine can run checks: Linux (or supplied by the runner), not Windows.
107#[must_use]
108pub fn can_run_checks(m: &Machine) -> bool {
109    // An appliance's container isn't where its traffic is (that's the OS inside it).
110    !m.vm.as_ref().is_some_and(|v| images::is_windows(&v.os)) && m.docker.as_ref().is_none_or(|d| d.appliance.is_none())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn an_ipv6_literal_goes_in_brackets() {
119        let u = Url::parse("http://[fd00:20::10]:8080/x").unwrap();
120        assert_eq!((u.port, u.host.to_string()), (Some(8080), "fd00:20::10".to_owned()));
121        assert_eq!(u.render("fd00:20::10"), "http://[fd00:20::10]:8080/x");
122        assert!(Url::parse("http://[fd00::1]/").is_some_and(|bare| bare.port.is_none()));
123        assert!(Url::parse("http://[fd00::1/").is_none());
124        assert!(Url::parse("http://[fd00::1]x/").is_none());
125    }
126
127    #[test]
128    fn a_url_reads_into_its_scheme_host_port_and_path() {
129        assert_eq!(
130            Url::parse("https://web:8443/health?x=1"),
131            Some(Url {
132                https: true,
133                host: Host::Literal("web".into()),
134                port: Some(8443),
135                path: "/health?x=1".into(),
136            })
137        );
138        assert_eq!(Url::parse("http://web").map(|u| (u.https, u.port, u.path)), Some((false, None, "/".to_owned())));
139    }
140
141    #[test]
142    fn a_host_is_one_plain_name_and_a_port_is_1_or_more() {
143        for bad in [
144            "http:///x",
145            "http://a b/",
146            "http://a'b/",
147            "http://a\"b/",
148            "http://user@web/",
149            "http://web:0/",
150            "ftp://web/",
151        ] {
152            assert_eq!(Url::parse(bad), None, "{bad}");
153        }
154        assert_eq!(Url::parse("http://web:1/").and_then(|u| u.port), Some(1));
155    }
156
157    #[test]
158    fn windows_vms_and_appliances_run_no_checks() -> Result<(), String> {
159        let spec = crate::testing::model(&crate::testing::lab(
160            "machines:\n  web: { docker: { image: nginx } }\n  box: { vm: { os: debian@12 } }\n  dc: { vm: { os: windows-server@2022 } }\n  fw: { docker: { appliance: cisco-iol } }\n",
161        ))?;
162        let can: Vec<(&str, bool)> = spec.machines.iter().map(|(n, m)| (n.as_str(), can_run_checks(m))).collect();
163        assert_eq!(can, [("web", true), ("box", true), ("dc", false), ("fw", false)]);
164        Ok(())
165    }
166}