Where the service (whiskersd) is: a host and a port, typed by a grown-up and kept on the device.
A [ServiceAddress] can only be built by [ServiceAddress::parse], so a shell never builds a URL
by gluing strings: it asks the address for its url. The service is meant to
be reached over a private network (a VPN) that does the encrypting, so the scheme is always plain
http; an https:// address is refused with an explanation rather than quietly downgraded.
Errors say what is wrong and never quote what was typed.
The port whiskersd listens on unless told otherwise.
14pub const DEFAULT_PORT: u16 = 47900;
A reachable-in-principle place: a plausible host and a port. Equal addresses are the same place, however they were typed (case and a default port do not matter).
A lower-case DNS-style name (a MagicDNS name or a one-word machine name included).
Why an address was refused.
Spaces or other whitespace inside it.
37 Whitespace,
https://: the service speaks plain http, over a private network that encrypts.
39 Https,
Any other scheme than http://.
41 OtherScheme,
A path, query or fragment: only the machine is wanted.
43 NotJustAMachine,
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 {
Reads what a grown-up typed: a bare host, host:port, or either with a leading http://; surrounding
whitespace is ignored and one trailing / is allowed. The port defaults to [DEFAULT_PORT].
IPv6 hosts are written in brackets, as in [fd7a::1]:47900.
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 }
What to show and keep: the host, and the port only when it is not the default. It parses back to an equal address.
The base URL every request is made under, without a trailing slash.
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}