1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Origin {
7 pub scheme: String,
8 pub host: String,
9 pub port: Option<u16>,
10}
11
12#[must_use]
14pub fn parse_origin(input: &str) -> Option<Origin> {
15 let trimmed = input.trim();
16 let origin = extract_origin(trimmed)?;
17 let normalized = trimmed.trim_end_matches('/');
18 normalized
19 .eq_ignore_ascii_case(&format_origin(&origin))
20 .then_some(origin)
21}
22
23#[must_use]
25pub fn extract_origin(input: &str) -> Option<Origin> {
26 let trimmed = input.trim();
27 let scheme = extract_scheme(trimmed)?;
28 let remainder = &trimmed[scheme.len() + 1..];
29 let authority_input = remainder.strip_prefix("//")?;
30 let authority_end = authority_input
31 .find(['/', '?', '#'])
32 .unwrap_or(authority_input.len());
33 let authority = &authority_input[..authority_end];
34 let (host, port) = parse_authority(authority)?;
35
36 Some(Origin { scheme, host, port })
37}
38
39#[must_use]
41pub fn same_origin(a: &str, b: &str) -> bool {
42 let Some(left) = extract_origin(a) else {
43 return false;
44 };
45 let Some(right) = extract_origin(b) else {
46 return false;
47 };
48
49 let left_port = left.port.or_else(|| default_port_for_scheme(&left.scheme));
50 let right_port = right
51 .port
52 .or_else(|| default_port_for_scheme(&right.scheme));
53
54 left.scheme.eq_ignore_ascii_case(&right.scheme)
55 && left.host.eq_ignore_ascii_case(&right.host)
56 && left_port == right_port
57}
58
59#[must_use]
61pub fn format_origin(origin: &Origin) -> String {
62 match origin.port {
63 Some(port) => format!("{}://{}:{port}", origin.scheme, origin.host),
64 None => format!("{}://{}", origin.scheme, origin.host),
65 }
66}
67
68#[must_use]
70pub fn is_secure_origin(input: &str) -> bool {
71 matches!(extract_origin(input), Some(Origin { scheme, .. }) if matches!(scheme.as_str(), "https" | "wss"))
72}
73
74#[must_use]
76pub fn default_port_for_scheme(scheme: &str) -> Option<u16> {
77 match scheme.trim().to_ascii_lowercase().as_str() {
78 "http" | "ws" => Some(80),
79 "https" | "wss" => Some(443),
80 _ => None,
81 }
82}
83
84fn extract_scheme(input: &str) -> Option<String> {
85 let colon_index = input.find(':')?;
86 let candidate = &input[..colon_index];
87 if candidate.is_empty() {
88 return None;
89 }
90 let mut characters = candidate.chars();
91 let first = characters.next()?;
92 if !first.is_ascii_alphabetic() {
93 return None;
94 }
95 if characters
96 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.'))
97 {
98 Some(candidate.to_ascii_lowercase())
99 } else {
100 None
101 }
102}
103
104fn parse_authority(authority: &str) -> Option<(String, Option<u16>)> {
105 let host_port = authority
106 .rsplit_once('@')
107 .map_or(authority, |(_, tail)| tail);
108 if host_port.is_empty() {
109 return None;
110 }
111
112 if let Some(after_bracket) = host_port.strip_prefix('[') {
113 let end = after_bracket.find(']')?;
114 let host = after_bracket[..end].to_string();
115 let tail = &after_bracket[end + 1..];
116 let port = tail.strip_prefix(':').and_then(|value| value.parse().ok());
117 return Some((host, port));
118 }
119
120 if let Some((host, port_text)) = host_port.rsplit_once(':') {
121 if !host.is_empty()
122 && !port_text.is_empty()
123 && port_text
124 .chars()
125 .all(|character| character.is_ascii_digit())
126 {
127 return Some((host.to_string(), port_text.parse().ok()));
128 }
129 }
130
131 Some((host_port.to_string(), None))
132}