Skip to main content

herolib_crypt/httpsig/
components.rs

1//! HTTP derived component extraction.
2//!
3//! Implements RFC 9421 derived components: @method, @path, @authority.
4
5use crate::httpsig::error::HttpSigError;
6
7/// Extract and normalize the @authority component from a Host header.
8///
9/// Performs RFC 9421 canonicalization:
10/// - Converts to lowercase
11/// - Removes default ports (80 for HTTP, 443 for HTTPS)
12///
13/// # Example
14///
15/// ```
16/// use herolib_crypt::httpsig::extract_authority;
17///
18/// assert_eq!(extract_authority("api.example.com"), "api.example.com");
19/// assert_eq!(extract_authority("API.EXAMPLE.COM"), "api.example.com");
20/// assert_eq!(extract_authority("api.example.com:443"), "api.example.com");
21/// assert_eq!(extract_authority("api.example.com:8080"), "api.example.com:8080");
22/// ```
23pub fn extract_authority(host: &str) -> String {
24    let lowercase = host.to_lowercase();
25
26    // Remove default ports (including the colon)
27    if lowercase.ends_with(":80") {
28        lowercase[..lowercase.len() - 3].to_string()
29    } else if lowercase.ends_with(":443") {
30        lowercase[..lowercase.len() - 4].to_string()
31    } else {
32        lowercase
33    }
34}
35
36/// Extract the @method derived component.
37///
38/// The method is converted to uppercase per HTTP standards.
39pub fn extract_method(method: &str) -> String {
40    method.to_uppercase()
41}
42
43/// Extract the @path derived component.
44///
45/// The path must start with '/' per RFC 9421.
46pub fn extract_path(path: &str) -> Result<String, HttpSigError> {
47    if !path.starts_with('/') {
48        return Err(HttpSigError::ParseError(format!(
49            "Path must start with '/': {}",
50            path
51        )));
52    }
53
54    Ok(path.to_string())
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn test_extract_authority_simple() {
63        assert_eq!(extract_authority("example.com"), "example.com");
64    }
65
66    #[test]
67    fn test_extract_authority_lowercase() {
68        assert_eq!(extract_authority("EXAMPLE.COM"), "example.com");
69        assert_eq!(extract_authority("Example.Com"), "example.com");
70    }
71
72    #[test]
73    fn test_extract_authority_default_ports() {
74        assert_eq!(extract_authority("example.com:80"), "example.com");
75        assert_eq!(extract_authority("example.com:443"), "example.com");
76    }
77
78    #[test]
79    fn test_extract_authority_custom_port() {
80        assert_eq!(extract_authority("example.com:8080"), "example.com:8080");
81        assert_eq!(extract_authority("example.com:3000"), "example.com:3000");
82    }
83
84    #[test]
85    fn test_extract_method() {
86        assert_eq!(extract_method("get"), "GET");
87        assert_eq!(extract_method("post"), "POST");
88        assert_eq!(extract_method("POST"), "POST");
89    }
90
91    #[test]
92    fn test_extract_path_valid() {
93        assert_eq!(extract_path("/api/v1/users").unwrap(), "/api/v1/users");
94        assert_eq!(extract_path("/").unwrap(), "/");
95    }
96
97    #[test]
98    fn test_extract_path_invalid() {
99        let result = extract_path("api/v1/users");
100        assert!(matches!(result, Err(HttpSigError::ParseError(_))));
101    }
102}