dpop_verifier/
uri.rs

1use crate::DpopError;
2use url::{Position, Url};
3
4/// Normalize scheme/host/port/path for DPoP htu compare:
5/// - http/https only; lowercase scheme+host
6/// - drop query & fragment
7/// - elide default ports (80/443)
8/// - ensure non-empty path ("/")
9/// - resolve dot-segments
10pub fn normalize_htu(input: &str) -> Result<String, DpopError> {
11    let mut url = Url::parse(input).map_err(|_| DpopError::MalformedHtu)?;
12    let scheme = url.scheme().to_ascii_lowercase();
13    if scheme != "http" && scheme != "https" {
14        return Err(DpopError::MalformedHtu);
15    }
16
17    match url.host_str() {
18        Some(host) if !host.is_empty() => {
19            let lower = host.to_ascii_lowercase();
20            url.set_host(Some(&lower))
21                .map_err(|_| DpopError::MalformedHtu)?;
22        }
23        _ => {
24            return Err(DpopError::MalformedHtu);
25        }
26    }
27
28    url.set_fragment(None);
29    url.set_query(None);
30
31    let is_default = (scheme == "http" && url.port() == Some(80))
32        || (scheme == "https" && url.port() == Some(443));
33    if is_default {
34        let _ = url.set_port(None);
35    }
36    if url.path().is_empty() {
37        url.set_path("/");
38    }
39    // resolve dot-segments
40    let mut norm: Vec<&str> = Vec::new();
41    {
42        let segs = url.path_segments().ok_or(DpopError::MalformedHtu)?;
43        for s in segs {
44            match s {
45                "" | "." => {}
46                ".." => {
47                    norm.pop();
48                }
49                other => norm.push(other),
50            }
51        }
52    }
53    let mut new_path = String::from("/");
54    new_path.push_str(&norm.join("/"));
55    url.set_path(&new_path);
56
57    Ok(url[..Position::AfterPath].to_string())
58}
59
60/// Normalize HTTP method for DPoP htm compare.
61pub fn normalize_method(method: &str) -> Result<String, DpopError> {
62    let uppercase_method = method.trim().to_ascii_uppercase();
63    // Restrict to standard methods (expand if you need more)
64    if !matches!(
65        uppercase_method.as_str(),
66        "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "TRACE"
67    ) {
68        return Err(DpopError::InvalidMethod);
69    }
70    Ok(uppercase_method)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::normalize_htu;
76    use crate::DpopError;
77
78    #[test]
79    fn lowercases_scheme_and_host_and_strips_qf_and_default_ports() {
80        let got = normalize_htu("HTTPS://EXAMPLE.com:443/Api/v1?q=1#frag").unwrap();
81        assert_eq!(got, "https://example.com/Api/v1");
82    }
83
84    #[test]
85    fn keeps_non_default_port() {
86        let got = normalize_htu("http://example.com:8080/a/b").unwrap();
87        assert_eq!(got, "http://example.com:8080/a/b");
88    }
89
90    #[test]
91    fn empty_path_becomes_root() {
92        let got = normalize_htu("https://example.com").unwrap();
93        assert_eq!(got, "https://example.com/");
94    }
95
96    #[test]
97    fn resolves_dot_segments() {
98        let got = normalize_htu("https://ex.com/a/./b/../c").unwrap();
99        assert_eq!(got, "https://ex.com/a/c");
100    }
101
102    #[test]
103    fn collapses_redundant_slashes() {
104        // Multiple slashes produce empty path segments; our normalizer skips "".
105        let got = normalize_htu("https://ex.com//a///b////c").unwrap();
106        assert_eq!(got, "https://ex.com/a/b/c");
107    }
108
109    #[test]
110    fn ipv6_and_default_port_elision() {
111        let got = normalize_htu("https://[2001:db8::1]:443/a").unwrap();
112        assert_eq!(got, "https://[2001:db8::1]/a");
113    }
114
115    #[test]
116    fn ipv6_non_default_port_kept() {
117        let got = normalize_htu("http://[2001:db8::1]:8080/a").unwrap();
118        assert_eq!(got, "http://[2001:db8::1]:8080/a");
119    }
120
121    #[test]
122    fn rejects_non_http_schemes() {
123        let err = normalize_htu("ftp://example.com/a").unwrap_err();
124        assert!(matches!(err, DpopError::MalformedHtu));
125    }
126
127    #[test]
128    fn rejects_missing_or_empty_host() {
129        assert!(matches!(
130            normalize_htu("https:"),
131            Err(DpopError::MalformedHtu)
132        ));
133        assert!(matches!(
134            normalize_htu("https://"),
135            Err(DpopError::MalformedHtu)
136        ));
137    }
138
139    #[test]
140    fn idempotent_when_already_normalized() {
141        let input = "https://example.com/a/b";
142        let once = normalize_htu(input).unwrap();
143        let twice = normalize_htu(&once).unwrap();
144        assert_eq!(once, twice);
145        assert_eq!(twice, "https://example.com/a/b");
146    }
147
148    #[test]
149    fn preserves_path_case() {
150        // Only scheme/host are lowercased; path casing must be preserved.
151        let got = normalize_htu("https://EX.com/Api/V1/Users").unwrap();
152        assert_eq!(got, "https://ex.com/Api/V1/Users");
153    }
154
155    #[test]
156    fn removes_query_and_fragment_only() {
157        let got = normalize_htu("http://ex.com/a/b?x=1&y=2#sec").unwrap();
158        assert_eq!(got, "http://ex.com/a/b");
159    }
160
161    #[test]
162    fn resolves_trailing_parent_segment() {
163        let got = normalize_htu("http://ex.com/a/b/..").unwrap();
164        assert_eq!(got, "http://ex.com/a");
165    }
166}