Skip to main content

rustauth_core/api/
path.rs

1use std::collections::BTreeMap;
2
3use crate::utils::url::normalize_pathname;
4
5#[derive(Debug, Clone, Default, PartialEq, Eq)]
6pub struct PathParams(BTreeMap<String, String>);
7
8impl PathParams {
9    pub fn new(params: BTreeMap<String, String>) -> Self {
10        Self(params)
11    }
12
13    pub fn get(&self, name: &str) -> Option<&str> {
14        self.0.get(name).map(String::as_str)
15    }
16
17    pub fn is_empty(&self) -> bool {
18        self.0.is_empty()
19    }
20}
21
22fn hex_value(byte: u8) -> Option<u8> {
23    match byte {
24        b'0'..=b'9' => Some(byte - b'0'),
25        b'a'..=b'f' => Some(byte - b'a' + 10),
26        b'A'..=b'F' => Some(byte - b'A' + 10),
27        _ => None,
28    }
29}
30
31pub(super) fn match_path_pattern(pattern: &str, path: &str) -> Option<BTreeMap<String, String>> {
32    if pattern == path {
33        return Some(BTreeMap::new());
34    }
35    let pattern_segments = route_segments(pattern);
36    let path_segments = route_segments(path);
37    if pattern_segments.len() != path_segments.len() {
38        return None;
39    }
40    let mut params = BTreeMap::new();
41    for (pattern, value) in pattern_segments.into_iter().zip(path_segments) {
42        if let Some(name) = pattern.strip_prefix(':') {
43            if name.is_empty() || value.is_empty() {
44                return None;
45            }
46            params.insert(name.to_owned(), percent_decode_path_segment(value));
47        } else if pattern != value {
48            return None;
49        }
50    }
51    Some(params)
52}
53
54fn route_segments(value: &str) -> Vec<&str> {
55    let value = value.strip_prefix('/').unwrap_or(value);
56    if value.is_empty() {
57        Vec::new()
58    } else {
59        value.split('/').collect()
60    }
61}
62
63fn percent_decode_path_segment(value: &str) -> String {
64    let mut decoded = Vec::with_capacity(value.len());
65    let bytes = value.as_bytes();
66    let mut index = 0;
67    while index < bytes.len() {
68        match bytes[index] {
69            b'%' if index + 2 < bytes.len() => {
70                let high = hex_value(bytes[index + 1]);
71                let low = hex_value(bytes[index + 2]);
72                if let (Some(high), Some(low)) = (high, low) {
73                    decoded.push((high << 4) | low);
74                    index += 3;
75                } else {
76                    decoded.push(bytes[index]);
77                    index += 1;
78                }
79            }
80            byte => {
81                decoded.push(byte);
82                index += 1;
83            }
84        }
85    }
86    String::from_utf8(decoded).unwrap_or_else(|_| value.to_owned())
87}
88
89pub(super) fn path_matches(pattern: &str, path: &str) -> bool {
90    if let Some((prefix, suffix)) = pattern.split_once('*') {
91        return path.starts_with(prefix) && path.ends_with(suffix);
92    }
93    pattern == path
94}
95
96pub(super) fn route_pathname(
97    request_url: &str,
98    base_path: &str,
99    skip_trailing_slashes: bool,
100) -> String {
101    if skip_trailing_slashes {
102        return normalize_pathname(request_url, base_path);
103    }
104
105    let Some(pathname) = pathname_from_url(request_url) else {
106        return "/".to_owned();
107    };
108    let base_path = trim_trailing_slashes(base_path);
109
110    if base_path == "/" {
111        return pathname;
112    }
113    if pathname == base_path {
114        return "/".to_owned();
115    }
116
117    let base_prefix = format!("{base_path}/");
118    if let Some(without_base_path) = pathname.strip_prefix(&base_prefix) {
119        format!("/{without_base_path}")
120    } else {
121        pathname
122    }
123}
124
125fn pathname_from_url(request_url: &str) -> Option<String> {
126    if request_url.starts_with('/') {
127        let path = request_url
128            .split_once('?')
129            .map_or(request_url, |(path, _)| path);
130        let path = path.split_once('#').map_or(path, |(path, _)| path);
131        return Some(path.to_owned());
132    }
133    let (_, after_scheme) = request_url.split_once("://")?;
134    let path_start = after_scheme.find('/')?;
135    let path_with_query = &after_scheme[path_start..];
136    let path = path_with_query
137        .split_once('?')
138        .map_or(path_with_query, |(path, _)| path);
139    let path = path.split_once('#').map_or(path, |(path, _)| path);
140
141    Some(path.to_owned())
142}
143
144fn trim_trailing_slashes(path: &str) -> String {
145    let trimmed = path.trim_end_matches('/');
146    if trimmed.is_empty() {
147        "/".to_owned()
148    } else if trimmed.starts_with('/') {
149        trimmed.to_owned()
150    } else {
151        format!("/{trimmed}")
152    }
153}