Skip to main content

ojak/
origin.rs

1//! Where an identifier's authority comes from.
2//!
3//! Every rule of the form "the key must belong to the actor's server" or
4//! "an object fetched from here must say it is from here" compares origins,
5//! and an origin is one of two things:
6//!
7//!  -  for an `http` or `https` IRI, its scheme, host and port, as the web
8//!     defines an origin;
9//!  -  for an `ap://` IRI (FEP-ef61) or a DID URL, the DID: a portable object
10//!     is vouched for by the key that controls it, wherever it is served from.
11//!     So is a portable object's compatible `https` identifier at a gateway,
12//!     `https://gateway.example/.well-known/apgateway/did:key:…/path`: the
13//!     gateway is where it is, not who vouches for it.
14//!
15//! The rules are written once against [`Origin`], never against hostnames, so
16//! that portable objects are the same rules over a second kind of origin.
17
18use crate::portable::{ApUri, did_of};
19use alloc::string::{String, ToString};
20
21/// An identifier's origin.
22#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
23pub enum Origin {
24    /// An `http` or `https` origin. The host is lower-cased and the port is
25    /// the effective one, so `https://A.example` and `https://a.example:443`
26    /// are one origin.
27    Web {
28        scheme: String,
29        host: String,
30        port: u16,
31    },
32    /// A DID, the authority of a portable object.
33    Did(String),
34}
35
36impl Origin {
37    /// The origin of `iri`, or `None` when it has none Ojak recognises: not
38    /// `http`, `https`, `ap` or `did`, or malformed.
39    #[must_use]
40    pub fn of(iri: &str) -> Option<Self> {
41        let (scheme, _) = iri.split_once(':')?;
42        match scheme.to_ascii_lowercase().as_str() {
43            "http" | "https" => match ApUri::parse(iri) {
44                Some(portable) => Some(Self::Did(portable.did().to_string())),
45                None => web(iri),
46            },
47            "ap" | "ap+ef61" => ApUri::parse(iri).map(|uri| Self::Did(uri.did().to_string())),
48            "did" => did_of(iri).map(|did| Self::Did(did.to_string())),
49            _ => None,
50        }
51    }
52
53    /// Whether this is a portable object's origin.
54    #[must_use]
55    pub fn is_portable(&self) -> bool {
56        matches!(self, Self::Did(_))
57    }
58}
59
60/// Whether `a` and `b` have the same origin. An identifier with no origin
61/// Ojak recognises is never the same as anything.
62#[must_use]
63pub fn same_origin(a: &str, b: &str) -> bool {
64    match (Origin::of(a), Origin::of(b)) {
65        (Some(a), Some(b)) => a == b,
66        _ => false,
67    }
68}
69
70/// Whether `comparison` is an `http` or `https` URL on the same host as
71/// `base`, whatever their schemes and ports: Mastodon's
72/// `non_matching_uri_hosts?`, negated, which is how it decides that a
73/// collection page or a pinned post is on an account's server. Hosts are
74/// compared without regard to case.
75#[must_use]
76pub fn same_host(base: &str, comparison: &str) -> bool {
77    let host = |iri: &str| {
78        url::Url::parse(iri)
79            .ok()
80            .and_then(|url| url.host_str().map(str::to_ascii_lowercase))
81    };
82    let web = comparison.starts_with("http://") || comparison.starts_with("https://");
83    match (web, host(base), host(comparison)) {
84        (true, Some(base), Some(comparison)) => base == comparison,
85        _ => false,
86    }
87}
88
89/// Whether `a` and `b` have the same authority, as a rule that only cares
90/// which server vouches for an identifier compares them: the same DID for
91/// portable identifiers, and for `http` and `https` ones the same host,
92/// whatever their schemes and ports, compared without regard to case. A
93/// portable identifier never has the authority of a web one, and an
94/// identifier that is neither has none.
95#[must_use]
96pub fn same_authority(a: &str, b: &str) -> bool {
97    match (Origin::of(a), Origin::of(b)) {
98        (Some(Origin::Did(a)), Some(Origin::Did(b))) => a == b,
99        (Some(Origin::Did(_)), _) | (_, Some(Origin::Did(_))) => false,
100        _ => is_web(a) && same_host(a, b),
101    }
102}
103
104/// Whether `iri` is an `http` or `https` URL, by its scheme, without regard
105/// to case.
106fn is_web(iri: &str) -> bool {
107    iri.split_once(':').is_some_and(|(scheme, _)| {
108        scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
109    })
110}
111
112/// The host of the URL `iri`, without its port, in the form hosts are
113/// compared in: lower case, an internationalised name in its ASCII form, an
114/// IPv6 address in brackets. `None` when `iri` is not a URL or has no host.
115/// This is the host Mastodon's `Addressable::URI.parse(url).normalized_host`
116/// reads, by which it tracks deliveries and matches domain blocks.
117#[must_use]
118pub fn host_of(iri: &str) -> Option<String> {
119    let url = url::Url::parse(iri).ok()?;
120    url.host_str()
121        .filter(|host| !host.is_empty())
122        .map(str::to_ascii_lowercase)
123}
124
125/// What [`normalize_host`] does with a port.
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub enum Port {
128    /// Keep it as it was written, `:443` included: Mastodon's
129    /// `TagManager#normalize_domain`, by which an account on a server at a
130    /// port is told from one on the same host without.
131    Keep,
132    /// Leave it out.
133    Drop,
134}
135
136/// A host as written by hand — a domain typed into a form, or taken from a
137/// handle — in the form hosts are compared in: lower case, percent-encoding
138/// decoded, an internationalised name in its ASCII form (IDNA), and a single
139/// trailing dot left off, as `Addressable::URI#normalized_host` has it. The
140/// port is kept or left out as `port` says. `None` when it is empty or is
141/// not a host, such as one with a space, `/` or `@` in it.
142#[must_use]
143pub fn normalize_host(authority: &str, port: Port) -> Option<String> {
144    let (host, written_port) = split_port(authority);
145    if host.is_empty() {
146        return None;
147    }
148    let mut host = url::Host::parse(host).ok()?.to_string();
149    if host.ends_with('.') && !host.ends_with("..") {
150        host.pop();
151    }
152    Some(match (port, written_port) {
153        (Port::Keep, Some(written)) => alloc::format!("{host}:{written}"),
154        _ => host,
155    })
156}
157
158/// `authority` split into its host and the port written after it, if any.
159fn split_port(authority: &str) -> (&str, Option<&str>) {
160    if authority.starts_with('[') {
161        return match authority.find(']') {
162            Some(end) => {
163                let (host, rest) = authority.split_at(end + 1);
164                (host, rest.strip_prefix(':'))
165            }
166            None => (authority, None),
167        };
168    }
169    match authority.rsplit_once(':') {
170        Some((host, port)) if port.bytes().all(|byte| byte.is_ascii_digit()) => (host, Some(port)),
171        _ => (authority, None),
172    }
173}
174
175/// Whether `host` is `parent` or one of its subdomains, without regard to
176/// case: `a.example.com` and `example.com` are within `example.com`, and
177/// `notexample.com` is not. This is how a block on a domain covers its
178/// subdomains, as Mastodon's `DomainBlock.rule_for` matches every suffix of
179/// a host. An empty parent has nothing within it.
180#[must_use]
181pub fn host_within(host: &str, parent: &str) -> bool {
182    if parent.is_empty()
183        || host.len() < parent.len()
184        || !host.is_char_boundary(host.len() - parent.len())
185    {
186        return false;
187    }
188    let (rest, suffix) = host.split_at(host.len() - parent.len());
189    suffix.eq_ignore_ascii_case(parent) && (rest.is_empty() || rest.ends_with('.'))
190}
191
192/// The origin of an `http` or `https` IRI, read as the `url` crate reads it,
193/// since that is how every request Ojak makes and every identifier it hands
194/// on is read. An IRI that is not well formed has none: WHATWG URL parsing
195/// forgives what RFC 3987 does not, reading a backslash as `/`, so a hand-split
196/// authority and the parsed one could name different hosts, and an
197/// identifier that means one server to one rule and another to the next is
198/// not an identifier.
199fn web(iri: &str) -> Option<Origin> {
200    if !well_formed(iri) {
201        return None;
202    }
203    let url = url::Url::parse(iri).ok()?;
204    let host = url.host_str()?;
205    if host.is_empty() {
206        return None;
207    }
208    Some(Origin::Web {
209        scheme: url.scheme().to_string(),
210        host: host
211            .strip_prefix('[')
212            .and_then(|host| host.strip_suffix(']'))
213            .unwrap_or(host)
214            .to_string(),
215        port: url.port_or_known_default()?,
216    })
217}
218
219/// Whether `iri` has only what RFC 3987 allows in an IRI: no control
220/// characters, spaces, backslashes or the other characters it excludes, and
221/// `%` only as a percent-encoding.
222fn well_formed(iri: &str) -> bool {
223    let bytes = iri.as_bytes();
224    bytes.iter().enumerate().all(|(index, &byte)| match byte {
225        b'%' => bytes
226            .get(index + 1..index + 3)
227            .is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit)),
228        b'"' | b'<' | b'>' | b'\\' | b'^' | b'`' | b'{' | b'|' | b'}' => false,
229        byte => byte > b' ' && byte != 0x7f,
230    })
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn web(scheme: &str, host: &str, port: u16) -> Option<Origin> {
238        Some(Origin::Web {
239            scheme: scheme.into(),
240            host: host.into(),
241            port,
242        })
243    }
244
245    #[test]
246    fn same_authority_is_the_did_or_the_host() {
247        assert!(same_authority(
248            "https://A.example/users/a",
249            "http://a.example:8443/x"
250        ));
251        assert!(!same_authority("https://a.example/", "https://b.example/"));
252        assert!(!same_authority("ftp://a.example/", "https://a.example/"));
253        assert!(!same_authority("https://a.example/", "ftp://a.example/"));
254        assert!(same_authority(
255            "ap://did:key:z6MkAbc/users/alice",
256            "did:key:z6MkAbc#z6MkAbc"
257        ));
258        assert!(!same_authority(
259            "ap://did:key:z6MkAbc/users/alice",
260            "https://a.example/"
261        ));
262        assert!(!same_authority(
263            "https://gateway.example/users/bob",
264            "https://gateway.example/.well-known/apgateway/did:key:z6MkAbc/users/alice"
265        ));
266    }
267
268    #[test]
269    fn host_of_is_the_normalized_host_without_a_port() {
270        assert_eq!(
271            host_of("https://Bücher.Example:8443/inbox").as_deref(),
272            Some("xn--bcher-kva.example")
273        );
274        assert_eq!(host_of("https://[::1]/").as_deref(), Some("[::1]"));
275        assert_eq!(host_of("not a url"), None);
276        assert_eq!(host_of("mailto:a@b.example"), None);
277    }
278
279    #[test]
280    fn normalize_host_is_addressables_normalized_host() {
281        let keep = |host| normalize_host(host, Port::Keep);
282        assert_eq!(keep("Example.COM").as_deref(), Some("example.com"));
283        assert_eq!(
284            keep("bücher.example").as_deref(),
285            Some("xn--bcher-kva.example")
286        );
287        assert_eq!(keep("127.0.0.1:3000").as_deref(), Some("127.0.0.1:3000"));
288        assert_eq!(keep("a.example:443").as_deref(), Some("a.example:443"));
289        assert_eq!(
290            normalize_host("a.example:443", Port::Drop).as_deref(),
291            Some("a.example")
292        );
293        assert_eq!(
294            keep("[2001:DB8::1]:8443").as_deref(),
295            Some("[2001:db8::1]:8443")
296        );
297        assert_eq!(keep("a.example.").as_deref(), Some("a.example"));
298        assert_eq!(keep("a%2Eexample").as_deref(), Some("a.example"));
299        for invalid in ["", "a b.example", "a/b", "a@b.example", ":443"] {
300            assert_eq!(keep(invalid), None, "{invalid:?}");
301        }
302    }
303
304    #[test]
305    fn host_within_is_the_host_or_a_subdomain() {
306        assert!(host_within("example.com", "example.com"));
307        assert!(host_within("a.example.com", "example.com"));
308        assert!(host_within("deep.sub.EXAMPLE.com", "Example.COM"));
309        assert!(!host_within("notexample.com", "example.com"));
310        assert!(!host_within("example.com.attacker.test", "example.com"));
311        assert!(!host_within("example.org", "example.com"));
312        assert!(!host_within("example.com", ""));
313        assert!(!host_within("", "example.com"));
314    }
315
316    #[test]
317    fn a_web_origin_is_scheme_host_and_effective_port() {
318        assert_eq!(
319            Origin::of("https://A.Example/users/a"),
320            web("https", "a.example", 443)
321        );
322        assert_eq!(
323            Origin::of("https://a.example:443/x"),
324            web("https", "a.example", 443)
325        );
326        assert_eq!(
327            Origin::of("http://a.example:8080"),
328            web("http", "a.example", 8080)
329        );
330        assert_eq!(
331            Origin::of("https://[2001:db8::1]:8443/"),
332            web("https", "2001:db8::1", 8443)
333        );
334        assert_eq!(
335            Origin::of("https://user@a.example/"),
336            web("https", "a.example", 443)
337        );
338        assert_eq!(
339            Origin::of("https://a.example#main-key"),
340            web("https", "a.example", 443)
341        );
342    }
343
344    #[test]
345    fn a_web_origin_is_the_one_a_url_parser_reads() {
346        // An IDN host is its punycode, as the URL it is fetched at has it.
347        assert_eq!(
348            Origin::of("https://bücher.example/"),
349            web("https", "xn--bcher-kva.example", 443)
350        );
351        assert!(same_origin(
352            "https://bücher.example/a",
353            "https://xn--bcher-kva.example/b"
354        ));
355        // A percent-encoded host is the host it decodes to.
356        assert!(same_origin("https://a%2Eexample/", "https://a.example/"));
357    }
358
359    #[test]
360    fn an_identifier_that_is_not_well_formed_has_no_origin() {
361        // A URL parser reads the backslash as `/`, making the host
362        // a.example; split by hand, the host is after the `@`.
363        for iri in [
364            "https://a.example\\@evil.example/users/alice",
365            "https://a.example\\evil.example/",
366            "https://a.example/users/ alice",
367            "https://a.example/users/%zz",
368            "https://a.example/\u{7f}",
369            "https://a.example/{x}",
370        ] {
371            assert_eq!(Origin::of(iri), None, "{iri:?}");
372        }
373    }
374
375    #[test]
376    fn a_portable_origin_is_the_did() {
377        let origin = Origin::Did("did:key:z6MkAbc".into());
378        let did = Some(origin.clone());
379        assert_eq!(Origin::of("ap://did:key:z6MkAbc/users/alice"), did);
380        assert_eq!(Origin::of("ap://did%3Akey%3Az6MkAbc/users/alice"), did);
381        assert_eq!(Origin::of("ap+ef61://did:key:z6MkAbc/objects/1"), did);
382        assert_eq!(Origin::of("did:key:z6MkAbc#z6MkAbc"), did);
383        assert!(origin.is_portable());
384    }
385
386    #[test]
387    fn what_has_no_origin_is_never_the_same() {
388        for iri in [
389            "",
390            "acct:alice@a.example",
391            "https://",
392            "https://:443/",
393            "did:key",
394            "mailto:x@y",
395        ] {
396            assert_eq!(Origin::of(iri), None, "{iri}");
397        }
398        assert!(!same_origin("acct:a@b", "acct:a@b"));
399    }
400
401    #[test]
402    fn same_origin_compares_origins_not_strings() {
403        assert!(same_origin(
404            "https://a.example/users/alice#main-key",
405            "https://A.example:443/users/alice"
406        ));
407        assert!(!same_origin("https://a.example/", "http://a.example/"));
408        assert!(!same_origin(
409            "https://a.example/",
410            "https://a.example:8443/"
411        ));
412        assert!(!same_origin("https://a.example/", "https://b.example/"));
413        assert!(same_origin(
414            "ap://did:key:z6MkAbc/users/alice",
415            "did:key:z6MkAbc#z6MkAbc"
416        ));
417        // A compatible identifier is the portable object's, not the
418        // gateway's.
419        assert!(same_origin(
420            "ap://did:key:z6MkAbc/users/alice",
421            "https://gateway.example/.well-known/apgateway/did:key:z6MkAbc/users/alice"
422        ));
423        assert!(!same_origin(
424            "https://gateway.example/users/bob",
425            "https://gateway.example/.well-known/apgateway/did:key:z6MkAbc/users/alice"
426        ));
427    }
428}