lib.rsannotatedlib.rssource380 lines · 14.2 KB · raw
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}