1use crate::portable::{ApUri, did_of};
19use alloc::string::{String, ToString};
20
21#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
23pub enum Origin {
24 Web {
28 scheme: String,
29 host: String,
30 port: u16,
31 },
32 Did(String),
34}
35
36impl Origin {
37 #[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 #[must_use]
55 pub fn is_portable(&self) -> bool {
56 matches!(self, Self::Did(_))
57 }
58}
59
60#[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#[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#[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
104fn 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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub enum Port {
128 Keep,
132 Drop,
134}
135
136#[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
158fn 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#[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
192fn 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
219fn 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 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 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 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 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}