1//! Where the service (`whiskersd`) is: a host and a port, typed by a grown-up and kept on the device.
2//!
3//! A [`ServiceAddress`] can only be built by [`ServiceAddress::parse`], so a shell never builds a URL
4//! by gluing strings: it asks the address for its [`url`](ServiceAddress::url). The service is meant to
5//! be reached over a private network (a VPN) that does the encrypting, so the scheme is always plain
6//! `http`; an `https://` address is refused with an explanation rather than quietly downgraded.
7//!
8//! Errors say what is wrong and never quote what was typed.
9
10use std::fmt;
11use std::net::{Ipv4Addr, Ipv6Addr};
12
13/// The port `whiskersd` listens on unless told otherwise.
14pub const DEFAULT_PORT: u16 = 47900;
15
16/// A reachable-in-principle place: a plausible host and a port. Equal addresses are the same place, however
17/// they were typed (case and a default port do not matter).
18#[derive(Clone, Debug, PartialEq, Eq, Hash)]
19pub struct ServiceAddress {
20    host: Host,
21    port: u16,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq, Hash)]
25enum Host {
26    /// A lower-case DNS-style name (a MagicDNS name or a one-word machine name included).
27    Name(String),
28    V4(Ipv4Addr),
29    V6(Ipv6Addr),
30}
31
32/// Why an address was refused.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum AddressError {
35    Empty,
36    /// Spaces or other whitespace inside it.
37    Whitespace,
38    /// `https://`: the service speaks plain http, over a private network that encrypts.
39    Https,
40    /// Any other scheme than `http://`.
41    OtherScheme,
42    /// A path, query or fragment: only the machine is wanted.
43    NotJustAMachine,
44    /// `name@host`.
45    Credentials,
46    NotAHost,
47    BadPort,
48}
49
50impl fmt::Display for AddressError {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.write_str(match self {
53            AddressError::Empty => "Type the address of the machine that runs the Whiskers service.",
54            AddressError::Whitespace => "An address has no spaces in it.",
55            AddressError::Https => {
56                "Whiskers talks to its service over plain http, on a private network such as a VPN that does the encrypting. Leave off the https:// (or type http://)."
57            }
58            AddressError::OtherScheme => "Only http:// addresses are used. Leave the beginning off, or type http://.",
59            AddressError::NotJustAMachine => "Only the machine is needed: no path, no question mark and no extras after it. For example 192.0.2.10 or 192.0.2.10:47900.",
60            AddressError::Credentials => "An address has no name or password in it.",
61            AddressError::NotAHost => "That does not look like a machine name or number.",
62            AddressError::BadPort => "The port must be a number from 1 to 65535.",
63        })
64    }
65}
66
67impl std::error::Error for AddressError {}
68
69impl ServiceAddress {
70    /// Reads what a grown-up typed: a bare host, `host:port`, or either with a leading `http://`; surrounding
71    /// whitespace is ignored and one trailing `/` is allowed. The port defaults to [`DEFAULT_PORT`].
72    /// IPv6 hosts are written in brackets, as in `[fd7a::1]:47900`.
73    pub fn parse(typed: &str) -> Result<ServiceAddress, AddressError> {
74        let result = Self::parse_inner(typed);
75        match &result {
76            Ok(a) => ::log::debug!("service address accepted (port {})", a.port),
77            Err(e) => ::log::debug!("service address refused: {e:?}"),
78        }
79        result
80    }
81
82    fn parse_inner(typed: &str) -> Result<ServiceAddress, AddressError> {
83        let t = typed.trim();
84        if t.is_empty() {
85            return Err(AddressError::Empty);
86        }
87        if t.chars().any(char::is_whitespace) {
88            return Err(AddressError::Whitespace);
89        }
90        let rest = match t.split_once("://") {
91            Some((scheme, rest)) if scheme.eq_ignore_ascii_case("http") => rest,
92            Some((scheme, _)) if scheme.eq_ignore_ascii_case("https") => return Err(AddressError::Https),
93            Some(_) => return Err(AddressError::OtherScheme),
94            None => t,
95        };
96        let rest = rest.strip_suffix('/').unwrap_or(rest);
97        if rest.contains('@') {
98            return Err(AddressError::Credentials);
99        }
100        if rest.contains(['/', '?', '#', '\\']) {
101            return Err(AddressError::NotJustAMachine);
102        }
103        let (host_text, port_text) = if let Some(inner) = rest.strip_prefix('[') {
104            let (v6, after) = inner.split_once(']').ok_or(AddressError::NotAHost)?;
105            match after {
106                "" => (format!("[{v6}]"), None),
107                _ => (format!("[{v6}]"), Some(after.strip_prefix(':').ok_or(AddressError::NotAHost)?)),
108            }
109        } else {
110            match rest.split_once(':') {
111                Some((h, p)) => (h.to_owned(), Some(p)),
112                None => (rest.to_owned(), None),
113            }
114        };
115        let port = match port_text {
116            None => DEFAULT_PORT,
117            Some(p) => {
118                if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
119                    return Err(AddressError::BadPort);
120                }
121                match p.parse::<u16>() {
122                    Ok(n) if n != 0 => n,
123                    _ => return Err(AddressError::BadPort),
124                }
125            }
126        };
127        Ok(ServiceAddress { host: parse_host(&host_text)?, port })
128    }
129
130    pub fn port(&self) -> u16 {
131        self.port
132    }
133
134    /// What to show and keep: the host, and the port only when it is not the default. It parses back to an
135    /// equal address.
136    pub fn text(&self) -> String {
137        if self.port == DEFAULT_PORT { self.host.to_string() } else { format!("{}:{}", self.host, self.port) }
138    }
139
140    /// The base URL every request is made under, without a trailing slash.
141    pub fn url(&self) -> String {
142        format!("http://{}:{}", self.host, self.port)
143    }
144}
145
146impl fmt::Display for Host {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            Host::Name(n) => f.write_str(n),
150            Host::V4(a) => write!(f, "{a}"),
151            Host::V6(a) => write!(f, "[{a}]"),
152        }
153    }
154}
155
156fn parse_host(h: &str) -> Result<Host, AddressError> {
157    if let Some(inner) = h.strip_prefix('[') {
158        return inner.strip_suffix(']').and_then(|v| v.parse::<Ipv6Addr>().ok()).map(Host::V6).ok_or(AddressError::NotAHost);
159    }
160    // A trailing dot makes a name fully qualified; it names the same machine.
161    let h = h.strip_suffix('.').unwrap_or(h);
162    if h.is_empty() || h.len() > 253 {
163        return Err(AddressError::NotAHost);
164    }
165    if let Ok(v4) = h.parse::<Ipv4Addr>() {
166        return Ok(Host::V4(v4));
167    }
168    // Digits and dots only, yet not an address (`999.1.1.1`, `1.2.3`): a typo, not a name.
169    if h.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
170        return Err(AddressError::NotAHost);
171    }
172    let name = h.to_ascii_lowercase();
173    let label_ok = |l: &str| {
174        !l.is_empty()
175            && l.len() <= 63
176            && !l.starts_with('-')
177            && !l.ends_with('-')
178            && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
179    };
180    if name.split('.').all(label_ok) { Ok(Host::Name(name)) } else { Err(AddressError::NotAHost) }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn ok(s: &str) -> ServiceAddress {
188        ServiceAddress::parse(s).unwrap_or_else(|e| panic!("{s:?} should parse: {e:?}"))
189    }
190
191    #[test]
192    fn a_bare_host_gets_the_default_port() {
193        let a = ok("192.0.2.10");
194        assert_eq!(a.url(), "http://192.0.2.10:47900");
195        assert_eq!(a.text(), "192.0.2.10");
196        assert_eq!(a.port(), DEFAULT_PORT);
197    }
198
199    #[test]
200    fn every_way_of_typing_the_same_place_is_one_address() {
201        let a = ok("192.0.2.10");
202        for s in ["192.0.2.10:47900", "http://192.0.2.10", "http://192.0.2.10:47900/", "  192.0.2.10\n", "HTTP://192.0.2.10"] {
203            assert_eq!(ok(s), a, "{s:?}");
204        }
205        assert_eq!(ok("Box.Example.TS.net"), ok("box.example.ts.net."));
206    }
207
208    #[test]
209    fn a_port_other_than_the_default_is_kept() {
210        let a = ok("http://box.example:8080");
211        assert_eq!(a.url(), "http://box.example:8080");
212        assert_eq!(a.text(), "box.example:8080");
213    }
214
215    #[test]
216    fn text_parses_back_to_the_same_address() {
217        for s in ["192.0.2.10", "host:1", "one-word", "a.b.c:65535", "[fd7a::1]", "[fd7a::1]:8080"] {
218            let a = ok(s);
219            assert_eq!(ok(&a.text()), a, "{s:?}");
220        }
221    }
222
223    #[test]
224    fn a_one_word_machine_name_is_a_host() {
225        assert_eq!(ok("Henhouse").url(), "http://henhouse:47900");
226    }
227
228    #[test]
229    fn ipv6_is_written_in_brackets() {
230        assert_eq!(ok("[fd7a::1]:8080").url(), "http://[fd7a::1]:8080");
231        assert_eq!(ok("[fd7a::1]").url(), "http://[fd7a::1]:47900");
232        assert!(ServiceAddress::parse("fd7a::1").is_err());
233        assert_eq!(ServiceAddress::parse("[fd7a::1"), Err(AddressError::NotAHost));
234        assert_eq!(ServiceAddress::parse("[nothex]"), Err(AddressError::NotAHost));
235        assert_eq!(ServiceAddress::parse("[::1]x"), Err(AddressError::NotAHost));
236    }
237
238    #[test]
239    fn empty_and_blank_are_refused() {
240        assert_eq!(ServiceAddress::parse(""), Err(AddressError::Empty));
241        assert_eq!(ServiceAddress::parse("   \t"), Err(AddressError::Empty));
242        assert_eq!(ServiceAddress::parse("http://"), Err(AddressError::NotAHost));
243    }
244
245    #[test]
246    fn whitespace_inside_is_refused() {
247        assert_eq!(ServiceAddress::parse("192.0.2. 10"), Err(AddressError::Whitespace));
248        assert_eq!(ServiceAddress::parse("my host"), Err(AddressError::Whitespace));
249    }
250
251    #[test]
252    fn https_is_refused_with_its_own_explanation() {
253        let e = ServiceAddress::parse("https://box.example").unwrap_err();
254        assert_eq!(e, AddressError::Https);
255        assert!(e.to_string().contains("plain http"));
256        assert_eq!(ServiceAddress::parse("HTTPS://box.example"), Err(AddressError::Https));
257        assert_eq!(ServiceAddress::parse("ftp://box.example"), Err(AddressError::OtherScheme));
258    }
259
260    #[test]
261    fn paths_queries_and_fragments_are_refused() {
262        for s in ["box.example/usage", "http://box.example/a/b", "box.example?x=1", "box.example:47900#top", "box.example\\x", "http://box.example//"] {
263            assert_eq!(ServiceAddress::parse(s), Err(AddressError::NotJustAMachine), "{s:?}");
264        }
265    }
266
267    #[test]
268    fn credentials_are_refused() {
269        assert_eq!(ServiceAddress::parse("user@box.example"), Err(AddressError::Credentials));
270        assert_eq!(ServiceAddress::parse("http://user:pw@box.example:47900"), Err(AddressError::Credentials));
271    }
272
273    #[test]
274    fn ports_are_checked() {
275        for s in ["box:0", "box:65536", "box:", "box:abc", "box:-1", "box:+80", "box:80:80"] {
276            assert!(ServiceAddress::parse(s).is_err(), "{s:?}");
277        }
278        assert_eq!(ServiceAddress::parse("box:0"), Err(AddressError::BadPort));
279        assert_eq!(ServiceAddress::parse("box:65536"), Err(AddressError::BadPort));
280        assert_eq!(ok("box:65535").port(), 65535);
281    }
282
283    #[test]
284    fn implausible_hosts_are_refused() {
285        for s in ["999.1.1.1", "1.2.3", "-box", "box-", "a..b", ".box", "bo_x", "bo$x", "ex ample", "é.example", ":47900", "a".repeat(64).as_str()] {
286            assert!(ServiceAddress::parse(s).is_err(), "{s:?}");
287        }
288        assert!(ServiceAddress::parse(&format!("{}.example", "a".repeat(63))).is_ok());
289        assert!(ServiceAddress::parse(&format!("{}.example", "a".repeat(64))).is_err());
290    }
291
292    #[test]
293    fn an_error_never_quotes_what_was_typed() {
294        let secret = "hunter2-should-not-appear";
295        for typed in [format!("{secret}@box"), format!("https://{secret}"), format!("{secret}/x"), format!("{secret}:99999"), format!("{secret} x"), format!("{secret}_")] {
296            let e = ServiceAddress::parse(&typed).unwrap_err();
297            assert!(!e.to_string().contains(secret));
298            assert!(!format!("{e:?}").contains(secret));
299        }
300    }
301}