cloud_sdk_reqwest/shared/
endpoint.rs1use core::fmt;
2use reqwest::Url;
3use std::string::String;
4#[cfg(test)]
5use std::string::ToString;
6
7use cloud_sdk::transport::RequestTarget;
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum EndpointError {
12 InvalidUrl,
14 HttpsRequired,
16 MissingHost,
18 CredentialsForbidden,
20 QueryForbidden,
22 FragmentForbidden,
24 TrailingSlash,
26 InvalidTargetEncoding,
28 TargetNormalized,
30 AllocationFailed,
32}
33
34#[derive(Clone)]
36pub struct HttpsEndpoint {
37 base: Url,
38 prefix: String,
39}
40
41impl fmt::Debug for HttpsEndpoint {
42 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43 formatter.write_str("HttpsEndpoint([redacted])")
44 }
45}
46
47impl HttpsEndpoint {
48 pub fn new(value: &str) -> Result<Self, EndpointError> {
50 Self::new_inner(value, true)
51 }
52
53 fn new_inner(value: &str, require_https: bool) -> Result<Self, EndpointError> {
54 let base = Url::parse(value).map_err(|_| EndpointError::InvalidUrl)?;
55 if require_https && base.scheme() != "https" {
56 return Err(EndpointError::HttpsRequired);
57 }
58 if base.host_str().is_none() {
59 return Err(EndpointError::MissingHost);
60 }
61 if !base.username().is_empty() || base.password().is_some() {
62 return Err(EndpointError::CredentialsForbidden);
63 }
64 if base.query().is_some() {
65 return Err(EndpointError::QueryForbidden);
66 }
67 if base.fragment().is_some() {
68 return Err(EndpointError::FragmentForbidden);
69 }
70 if base.path() != "/" && base.path().ends_with('/') {
71 return Err(EndpointError::TrailingSlash);
72 }
73
74 let mut prefix = String::from(base.as_str());
75 if base.path() == "/" {
76 prefix.pop();
77 }
78 Ok(Self { base, prefix })
79 }
80
81 pub(crate) fn compose(&self, target: RequestTarget<'_>) -> Result<Url, EndpointError> {
82 validate_target_encoding(target.as_str())?;
83 let mut absolute = self.prefix.clone();
84 absolute
85 .try_reserve_exact(target.as_str().len())
86 .map_err(|_| EndpointError::AllocationFailed)?;
87 absolute.push_str(target.as_str());
88 let url = Url::parse(&absolute).map_err(|_| EndpointError::InvalidUrl)?;
89 if url.as_str() != absolute {
90 return Err(EndpointError::TargetNormalized);
91 }
92 self.verify_origin(&url)?;
93 Ok(url)
94 }
95
96 pub(crate) fn verify_origin(&self, url: &Url) -> Result<(), EndpointError> {
97 if url.scheme() != self.base.scheme()
98 || url.host_str() != self.base.host_str()
99 || url.port_or_known_default() != self.base.port_or_known_default()
100 || !url.username().is_empty()
101 || url.password().is_some()
102 {
103 return Err(EndpointError::TargetNormalized);
104 }
105 Ok(())
106 }
107
108 #[cfg(test)]
109 pub(crate) fn local_http(value: &str) -> Result<Self, EndpointError> {
110 let endpoint = Self::new_inner(value, false)?;
111 if endpoint.base.scheme() != "http"
112 || !endpoint.base.host().is_some_and(|host| {
113 host.to_string()
114 .parse::<std::net::IpAddr>()
115 .is_ok_and(|ip| ip.is_loopback())
116 })
117 {
118 return Err(EndpointError::HttpsRequired);
119 }
120 Ok(endpoint)
121 }
122}
123
124fn validate_target_encoding(target: &str) -> Result<(), EndpointError> {
125 let path_end = target.find('?').unwrap_or(target.len());
126 let bytes = target.as_bytes();
127 let mut index = 0_usize;
128 while index < bytes.len() {
129 if bytes.get(index) != Some(&b'%') {
130 index = index
131 .checked_add(1)
132 .ok_or(EndpointError::InvalidTargetEncoding)?;
133 continue;
134 }
135 let high = bytes
136 .get(
137 index
138 .checked_add(1)
139 .ok_or(EndpointError::InvalidTargetEncoding)?,
140 )
141 .and_then(|byte| decode_hex(*byte));
142 let low = bytes
143 .get(
144 index
145 .checked_add(2)
146 .ok_or(EndpointError::InvalidTargetEncoding)?,
147 )
148 .and_then(|byte| decode_hex(*byte));
149 let (Some(high), Some(low)) = (high, low) else {
150 return Err(EndpointError::InvalidTargetEncoding);
151 };
152 let decoded = high
153 .checked_mul(16)
154 .and_then(|value| value.checked_add(low))
155 .ok_or(EndpointError::InvalidTargetEncoding)?;
156 if index < path_end
157 && (decoded <= b' '
158 || decoded >= 0x7f
159 || matches!(decoded, b'.' | b'/' | b'\\' | b'?' | b'#' | b'%'))
160 {
161 return Err(EndpointError::InvalidTargetEncoding);
162 }
163 index = index
164 .checked_add(3)
165 .ok_or(EndpointError::InvalidTargetEncoding)?;
166 }
167 Ok(())
168}
169
170const fn decode_hex(byte: u8) -> Option<u8> {
171 match byte {
172 b'0'..=b'9' => byte.checked_sub(b'0'),
173 b'a'..=b'f' => match byte.checked_sub(b'a') {
174 Some(value) => value.checked_add(10),
175 None => None,
176 },
177 b'A'..=b'F' => match byte.checked_sub(b'A') {
178 Some(value) => value.checked_add(10),
179 None => None,
180 },
181 _ => None,
182 }
183}