Skip to main content

use_origin/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// A lightweight web origin.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Origin {
7    pub scheme: String,
8    pub host: String,
9    pub port: Option<u16>,
10}
11
12/// Parses a strict origin string without path, query, or fragment content.
13#[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/// Extracts the origin portion from a URL or origin-like string.
24#[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/// Returns `true` when both inputs resolve to the same origin.
40#[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/// Formats an origin as `scheme://host[:port]`.
60#[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/// Returns `true` for secure-origin schemes.
69#[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/// Returns the conventional default port for a scheme.
75#[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}