1//! Which address a request came to, and what to do with it before any route sees it. 2//! 3//! A site has one **canonical** address and may have old ones that lead to it. A person or a link 4//! that arrives at an old address is sent on for good, with the path and the query kept. A page that 5//! is still open there is left to finish: its socket and its own requests (`Sec-Fetch-Mode` other 6//! than `navigate`) would only break if sent on, and it moves the next time it is loaded. Any other 7//! address (staging, a dev server) is served and redirects nothing, so a change can be tried where it 8//! is. 9//! 10//! Pure: strings in, a decision out. A site with more homes than one (lmjtfy's code host) puts its own 11//! decisions in front of [`Canonical::gate`] and keeps this for the old addresses. 12 13/// A permanent redirect. `308` keeps the method; `301` is what a page's `GET` has always been given. 14#[derive(Clone, Copy, Debug, PartialEq, Eq)] 15pub enum Status { 16 MovedPermanently, 17 PermanentRedirect, 18} 19 20impl Status { 21 pub fn code(self) -> u16 { 22 match self { 23 Status::MovedPermanently => 301, 24 Status::PermanentRedirect => 308, 25 } 26 } 27} 28 29/// The `cache-control` of a redirect: long enough to spare the old address its traffic, short enough 30/// that a mistake is not stuck in caches. 31pub const MOVED_CACHE_CONTROL: &str = "public, max-age=86400"; 32 33/// Which address a request's `Host` is. 34#[derive(Clone, Copy, Debug, PartialEq, Eq)] 35pub enum Where { 36 /// The canonical address. 37 Canonical, 38 /// An address that leads to the canonical one. 39 Old, 40 /// This machine: a dev server, which redirects nothing. 41 Local, 42 /// Anything else: staging, a dev server of another name. Served, and redirects nothing. 43 Other, 44} 45 46/// A request, as far as the gate reads it. 47pub struct Asked<'a> { 48 pub host: &'a str, 49 /// `GET` or `HEAD`: a request that can be repeated at another address. 50 pub is_read: bool, 51 /// The path and the query, as sent. 52 pub target: &'a str, 53 /// A WebSocket upgrade. 54 pub socket: bool, 55 /// A request a page made for itself, rather than a person or a program following a link. 56 pub own: bool, 57} 58 59/// What to do with a request before routing it. 60#[derive(Debug, PartialEq, Eq)] 61pub enum Gate { 62 /// Route it. 63 Pass, 64 /// Send it on for good. 65 Moved { to: String, status: Status }, 66 /// Answer `405` with this: a method that was never served at the address the thing left. 67 Refused(String), 68} 69 70/// What happens at an old address to a request that cannot be repeated somewhere else (a `POST`), 71/// when it is not a page finishing where it started. 72#[derive(Clone, Copy, Debug, PartialEq, Eq)] 73pub enum Unrepeatable { 74 /// Route it here. 75 Pass, 76 /// Say where the site is (`405`): a redirect would repeat it somewhere it was never meant to go. 77 Refuse, 78} 79 80/// Which addresses lead to the canonical one. 81#[derive(Clone, Copy, Debug)] 82enum Old { 83 /// These, and no others. 84 Listed(&'static [&'static str]), 85 /// Every address that is neither the canonical one nor this machine's, so a name nobody thought 86 /// of cannot serve a second copy of the page (and no account's name need be written down). 87 EveryOther, 88} 89 90/// A site's canonical address and the old ones. 91#[derive(Clone, Debug)] 92pub struct Canonical { 93 secure: bool, 94 host: std::borrow::Cow<'static, str>, 95 old: Old, 96} 97 98/// An origin that is not `scheme://host[:port]`. 99#[derive(Debug, PartialEq, Eq)] 100pub struct NotAnOrigin(pub String); 101 102impl std::fmt::Display for NotAnOrigin { 103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 104 write!(f, "{:?} is not an origin (http(s)://host[:port], nothing after it)", self.0) 105 } 106} 107 108impl std::error::Error for NotAnOrigin {} 109 110impl Canonical { 111 /// A canonical `https://host` and a listed set of old addresses, all known at compile time. Hosts 112 /// are bare names, lower case, no scheme, no port. A name that is also its own old address, or is 113 /// not lower case, stops the site being built. 114 pub const fn new(host: &'static str, old: &'static [&'static str]) -> Canonical { 115 assert!(is_bare(host), "a host is a bare lower-case name"); 116 let mut i = 0; 117 while i < old.len() { 118 assert!(is_bare(old[i]), "a host is a bare lower-case name"); 119 assert!(!same(old[i], host), "an address is not its own old address"); 120 i += 1; 121 } 122 Canonical { secure: true, host: std::borrow::Cow::Borrowed(host), old: Old::Listed(old) } 123 } 124 125 /// The canonical address is a runtime value (a deploy's `SITE_ORIGIN`), and every address that is 126 /// neither it nor this machine's leads to it. 127 pub fn every_other_leads_to(origin: &str) -> Result<Canonical, NotAnOrigin> { 128 let bad = || NotAnOrigin(origin.to_owned()); 129 let (scheme, host) = origin.split_once("://").ok_or_else(bad)?; 130 let secure = match scheme { 131 "https" => true, 132 "http" => false, 133 _ => return Err(bad()), 134 }; 135 if host.is_empty() || host.contains(['/', '?', '#', ' ']) { 136 return Err(bad()); 137 } 138 Ok(Canonical { secure, host: std::borrow::Cow::Owned(host.to_ascii_lowercase()), old: Old::EveryOther }) 139 } 140 141 pub fn host(&self) -> &str { 142 &self.host 143 } 144 145 /// `https://host`, as a link to the canonical address is written. 146 pub fn origin(&self) -> String { 147 format!("{}://{}", if self.secure { "https" } else { "http" }, self.host) 148 } 149 150 /// Exact match, case-insensitive, never a suffix: a name that merely ends in ours is no one's. 151 pub fn of(&self, host: &str) -> Where { 152 let host = host.trim().to_ascii_lowercase(); 153 if host == self.host { 154 return Where::Canonical; 155 } 156 if is_local(&host) { 157 return Where::Local; 158 } 159 match self.old { 160 Old::EveryOther => Where::Old, 161 Old::Listed(list) if list.contains(&host.as_str()) => Where::Old, 162 Old::Listed(_) => Where::Other, 163 } 164 } 165 166 /// An old address leads here, for a request that is not a socket and not a page finishing where it 167 /// started. A read is moved; anything else is `unrepeatable`. 168 pub fn gate(&self, asked: &Asked<'_>, status: Status, unrepeatable: Unrepeatable) -> Gate { 169 if self.of(asked.host) != Where::Old || asked.socket || asked.own { 170 return Gate::Pass; 171 } 172 if asked.is_read { 173 return Gate::Moved { to: format!("{}{}", self.origin(), asked.target), status }; 174 } 175 match unrepeatable { 176 Unrepeatable::Pass => Gate::Pass, 177 Unrepeatable::Refuse => { 178 let path = asked.target.split('?').next().unwrap_or("/"); 179 Gate::Refused(format!("The site is at {}, not here: use {}{path}", self.origin(), self.origin())) 180 } 181 } 182 } 183} 184 185/// A host with its port taken off (an IPv6 literal keeps its brackets). 186fn without_port(host: &str) -> &str { 187 if host.starts_with('[') { 188 return host.split_once(']').map_or(host, |(inside, _)| &host[..inside.len() + 1]); 189 } 190 host.split_once(':').map_or(host, |(name, _)| name) 191} 192 193/// This machine's own address. 194pub fn is_local(host: &str) -> bool { 195 let name = without_port(host); 196 name == "localhost" || name.ends_with(".localhost") || name == "127.0.0.1" || name == "[::1]" 197} 198 199const fn same(a: &str, b: &str) -> bool { 200 let (a, b) = (a.as_bytes(), b.as_bytes()); 201 if a.len() != b.len() { 202 return false; 203 } 204 let mut i = 0; 205 while i < a.len() { 206 if a[i] != b[i] { 207 return false; 208 } 209 i += 1; 210 } 211 true 212} 213 214const fn is_bare(host: &str) -> bool { 215 let host = host.as_bytes(); 216 if host.is_empty() { 217 return false; 218 } 219 let mut i = 0; 220 while i < host.len() { 221 let b = host[i]; 222 if !(b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-') { 223 return false; 224 } 225 i += 1; 226 } 227 true 228} 229 230/// A switch a deploy sets in the environment. Exactly `on` or `off`; unset is `off`. Anything else is 231/// refused, not guessed at (`true`, `ON`, ` on` and the empty string included), so a mistyped value is 232/// loud and the site runs as `off`. 233#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] 234pub enum Switch { 235 #[default] 236 Off, 237 On, 238} 239 240/// A switch whose value is neither `on` nor `off`. 241#[derive(Debug, PartialEq, Eq)] 242pub struct BadSwitch { 243 pub var: String, 244 pub value: String, 245} 246 247impl std::fmt::Display for BadSwitch { 248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 249 write!(f, "{} is {:?}; it must be exactly \"on\" or \"off\". Treating it as \"off\".", self.var, self.value) 250 } 251} 252 253impl std::error::Error for BadSwitch {} 254 255impl Switch { 256 pub fn parse(var: &str, value: Option<&str>) -> Result<Switch, BadSwitch> { 257 match value { 258 None | Some("off") => Ok(Switch::Off), 259 Some("on") => Ok(Switch::On), 260 Some(other) => Err(BadSwitch { var: var.to_owned(), value: other.to_owned() }), 261 } 262 } 263 264 /// The switch to run with, and the complaint to log if the value was refused. `Off` is the 265 /// fallback because it is the behaviour that cannot break a working address. 266 pub fn resolve(var: &str, value: Option<&str>) -> (Switch, Option<String>) { 267 match Switch::parse(var, value) { 268 Ok(switch) => (switch, None), 269 Err(bad) => (Switch::Off, Some(bad.to_string())), 270 } 271 } 272 273 pub fn is_on(self) -> bool { 274 self == Switch::On 275 } 276} 277 278#[cfg(test)] 279mod tests { 280 use super::*; 281 282 const SITE: Canonical = Canonical::new("example.fun", &["www.example.fun", "example.workers.dev"]); 283 const MOVE: Status = Status::PermanentRedirect; 284 285 fn ask<'a>(host: &'a str, target: &'a str) -> Asked<'a> { 286 Asked { host, is_read: true, target, socket: false, own: false } 287 } 288 289 fn moved(to: &str) -> Gate { 290 Gate::Moved { to: to.into(), status: MOVE } 291 } 292 293 #[test] 294 fn an_address_is_told_from_the_others() { 295 assert_eq!(SITE.of("example.fun"), Where::Canonical); 296 assert_eq!(SITE.of("EXAMPLE.fun"), Where::Canonical); 297 assert_eq!(SITE.of("www.example.fun"), Where::Old); 298 assert_eq!(SITE.of("staging.example.fun"), Where::Other); 299 assert_eq!(SITE.of("localhost:8787"), Where::Local); 300 assert_eq!(SITE.of("evil-www.example.fun"), Where::Other); 301 assert_eq!(SITE.of("example.fun.evil.example"), Where::Other); 302 assert_eq!(SITE.of("localhost.evil.example"), Where::Other); 303 assert_eq!(SITE.of(""), Where::Other); 304 } 305 306 #[test] 307 fn an_old_address_sends_a_read_on_with_the_path_and_query() { 308 assert_eq!(SITE.gate(&ask("www.example.fun", "/q/a?x=1"), MOVE, Unrepeatable::Pass), moved("https://example.fun/q/a?x=1")); 309 assert_eq!(Status::MovedPermanently.code(), 301); 310 assert_eq!(Status::PermanentRedirect.code(), 308); 311 } 312 313 #[test] 314 fn a_page_still_open_there_and_every_other_address_is_left_alone() { 315 let pass = |a: Asked| SITE.gate(&a, MOVE, Unrepeatable::Refuse); 316 assert_eq!(pass(Asked { socket: true, ..ask("www.example.fun", "/live") }), Gate::Pass); 317 assert_eq!(pass(Asked { own: true, is_read: false, ..ask("www.example.fun", "/mood") }), Gate::Pass); 318 assert_eq!(pass(ask("example.fun", "/")), Gate::Pass); 319 assert_eq!(pass(ask("staging.example.fun", "/")), Gate::Pass); 320 assert_eq!(pass(ask("localhost:8789", "/")), Gate::Pass); 321 } 322 323 #[test] 324 fn what_cannot_be_repeated_is_passed_or_told_where_the_site_is() { 325 let post = Asked { is_read: false, ..ask("www.example.fun", "/mood?x=1") }; 326 assert_eq!(SITE.gate(&post, MOVE, Unrepeatable::Pass), Gate::Pass); 327 let Gate::Refused(why) = SITE.gate(&post, MOVE, Unrepeatable::Refuse) else { panic!() }; 328 assert!(why.contains("https://example.fun/mood") && !why.contains("x=1"), "a query is not echoed: {why}"); 329 } 330 331 #[test] 332 fn a_runtime_origin_sends_every_other_address_on() { 333 let site = Canonical::every_other_leads_to("https://Site.Example").unwrap(); 334 assert_eq!(site.host(), "site.example"); 335 assert_eq!(site.of("Site.example"), Where::Canonical); 336 assert_eq!(site.of("anything.workers.dev"), Where::Old); 337 assert_eq!(site.of("[::1]:8789"), Where::Local); 338 assert_eq!(site.of("127.0.0.1:8789"), Where::Local); 339 assert_eq!(site.of("app.localhost:1"), Where::Local); 340 assert_eq!(site.of("site.example.evil.example"), Where::Old); 341 assert_eq!(site.gate(&ask("anything.workers.dev", "/?a=b"), MOVE, Unrepeatable::Refuse), moved("https://site.example/?a=b")); 342 // A dev server's own origin is canonical at its own port, and another port of it is local. 343 let dev = Canonical::every_other_leads_to("http://localhost:8788").unwrap(); 344 assert_eq!(dev.of("localhost:8788"), Where::Canonical); 345 assert_eq!(dev.of("localhost:9"), Where::Local); 346 assert_eq!(dev.origin(), "http://localhost:8788"); 347 } 348 349 #[test] 350 fn an_origin_must_be_scheme_and_host_and_nothing_after() { 351 for bad in ["site.example", "ftp://site.example", "https://", "https://a.example/path", "https://a.example?x", "https://a b"] { 352 assert_eq!(Canonical::every_other_leads_to(bad).unwrap_err(), NotAnOrigin(bad.into()), "{bad}"); 353 } 354 } 355 356 #[test] 357 fn a_switch_is_exactly_on_or_off() { 358 assert_eq!(Switch::parse("V", None), Ok(Switch::Off)); 359 assert_eq!(Switch::parse("V", Some("on")), Ok(Switch::On)); 360 for bad in ["true", "ON", " on", "", "1"] { 361 assert!(Switch::parse("V", Some(bad)).is_err(), "{bad:?}"); 362 } 363 let (switch, complaint) = Switch::resolve("GIT_REDIRECT", Some("yes")); 364 assert_eq!(switch, Switch::Off); 365 assert!(complaint.unwrap().contains("GIT_REDIRECT")); 366 assert_eq!(Switch::resolve("V", Some("on")), (Switch::On, None)); 367 } 368 369 #[test] 370 #[should_panic(expected = "bare lower-case")] 371 fn a_host_with_a_scheme_is_refused() { 372 Canonical::new("https://example.fun", &[]); 373 } 374 375 #[test] 376 #[should_panic(expected = "its own old address")] 377 fn an_address_that_is_its_own_old_address_is_refused() { 378 Canonical::new("example.fun", &["example.fun"]); 379 } 380}