1use std::fmt;
4use std::net::IpAddr;
5use std::str::FromStr;
6
7use http::Uri;
8use http::uri::{Authority, Scheme};
9
10use crate::error::S3Error;
11
12#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
14pub enum AddressingStyle {
15 #[default]
17 Path,
18 VirtualHosted,
20}
21
22#[derive(Clone, Eq, PartialEq)]
28pub struct EndpointUrl {
29 serialized: String,
30 scheme: String,
31 authority: String,
32 path_and_query: String,
33}
34
35impl EndpointUrl {
36 fn from_parts(scheme: &str, authority: &str, path_and_query: &str) -> Result<Self, S3Error> {
37 let serialized = format!("{scheme}://{authority}{path_and_query}");
38 serialized
39 .parse::<Uri>()
40 .map_err(|_| S3Error::configuration("endpoint does not form a valid HTTP URI"))?;
41 Ok(Self {
42 serialized,
43 scheme: scheme.to_owned(),
44 authority: authority.to_owned(),
45 path_and_query: path_and_query.to_owned(),
46 })
47 }
48
49 pub fn scheme(&self) -> &str {
51 &self.scheme
52 }
53
54 pub fn authority(&self) -> &str {
56 &self.authority
57 }
58
59 pub fn path_and_query(&self) -> &str {
61 &self.path_and_query
62 }
63
64 pub fn as_str(&self) -> &str {
66 &self.serialized
67 }
68
69 pub(crate) fn request_uri(&self) -> Uri {
70 self.serialized
71 .parse()
72 .expect("EndpointUrl construction validates its HTTP URI")
73 }
74
75 pub(crate) fn with_query(&self, query: &str) -> Result<Self, S3Error> {
76 if query.starts_with('?') || query.contains('#') {
77 return Err(S3Error::configuration(
78 "request query must not include '?' or a fragment",
79 ));
80 }
81 let path = self
82 .path_and_query
83 .split_once('?')
84 .map_or(self.path_and_query.as_str(), |(path, _)| path);
85 let path_and_query = if query.is_empty() {
86 path.to_owned()
87 } else {
88 format!("{path}?{query}")
89 };
90 Self::from_parts(&self.scheme, &self.authority, &path_and_query)
91 }
92
93 pub(crate) fn with_authority(&self, authority: &str) -> Result<Self, S3Error> {
94 let parsed = Authority::from_str(authority)
95 .map_err(|_| S3Error::configuration("redirect authority is invalid"))?;
96 if parsed.as_str().contains('@') || parsed.host().is_empty() {
97 return Err(S3Error::configuration("redirect authority is invalid"));
98 }
99 Self::from_parts(&self.scheme, parsed.as_str(), &self.path_and_query)
100 }
101}
102
103impl fmt::Debug for EndpointUrl {
104 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
105 let (path, query) = self
106 .path_and_query
107 .split_once('?')
108 .map_or((self.path_and_query.as_str(), false), |(path, _)| {
109 (path, true)
110 });
111 formatter
112 .debug_struct("EndpointUrl")
113 .field("scheme", &self.scheme)
114 .field("authority", &self.authority)
115 .field("path", &path)
116 .field("has_redacted_query", &query)
117 .finish_non_exhaustive()
118 }
119}
120
121#[derive(Clone, Eq, PartialEq)]
123pub struct Endpoint {
124 url: EndpointUrl,
125 base_path: String,
126}
127
128impl Endpoint {
129 pub fn new(endpoint: impl AsRef<str>) -> Result<Self, S3Error> {
136 let input = endpoint.as_ref();
137 if input.contains('#') {
138 return Err(S3Error::configuration(
139 "endpoint must not contain a query string or fragment",
140 ));
141 }
142 let uri = input
143 .parse::<Uri>()
144 .map_err(|_| S3Error::configuration("endpoint must be a valid absolute URL"))?;
145 let scheme = uri
146 .scheme()
147 .ok_or_else(|| S3Error::configuration("endpoint must include a scheme"))?;
148 if scheme != &Scheme::HTTPS && scheme != &Scheme::HTTP {
149 return Err(S3Error::configuration(
150 "endpoint scheme must be HTTPS or explicitly enabled HTTP",
151 ));
152 }
153 let authority = uri
154 .authority()
155 .ok_or_else(|| S3Error::configuration("endpoint must include a host"))?;
156 if authority.as_str().contains('@') {
157 return Err(S3Error::configuration(
158 "endpoint must not contain user information",
159 ));
160 }
161 if authority.host().is_empty() {
162 return Err(S3Error::configuration("endpoint must include a host"));
163 }
164 if uri
165 .path_and_query()
166 .is_some_and(|value| value.query().is_some())
167 {
168 return Err(S3Error::configuration(
169 "endpoint must not contain a query string or fragment",
170 ));
171 }
172
173 let mut base_path = uri.path().to_owned();
174 if base_path.is_empty() {
175 base_path.push('/');
176 }
177 if !base_path.starts_with('/') {
178 return Err(S3Error::configuration("endpoint path must be absolute"));
179 }
180 if !base_path.ends_with('/') {
181 base_path.push('/');
182 }
183 let url = EndpointUrl::from_parts(scheme.as_str(), authority.as_str(), &base_path)?;
184 Ok(Self { url, base_path })
185 }
186
187 pub fn for_aws_region(region: &str) -> Result<Self, S3Error> {
194 validate_region(region)?;
195 Self::new(format!("https://s3.{region}.amazonaws.com"))
196 }
197
198 pub fn url(&self) -> &EndpointUrl {
200 &self.url
201 }
202
203 pub fn is_https(&self) -> bool {
205 self.url.scheme() == "https"
206 }
207
208 pub fn object_url(
215 &self,
216 bucket: &str,
217 object_key: Option<&str>,
218 style: AddressingStyle,
219 ) -> Result<EndpointUrl, S3Error> {
220 validate_bucket_for_path(bucket)?;
221 let authority = if style == AddressingStyle::VirtualHosted {
222 validate_virtual_host_bucket(bucket)?;
223 let parsed = Authority::from_str(self.url.authority())
224 .map_err(|_| S3Error::configuration("endpoint authority is invalid"))?;
225 if IpAddr::from_str(parsed.host().trim_matches(['[', ']'])).is_ok() {
226 return Err(S3Error::configuration(
227 "virtual-hosted addressing cannot be used with an IP endpoint",
228 ));
229 }
230 parsed.port_u16().map_or_else(
231 || format!("{bucket}.{}", parsed.host()),
232 |port| format!("{bucket}.{}:{port}", parsed.host()),
233 )
234 } else {
235 self.url.authority().to_owned()
236 };
237
238 let mut path = self.base_path.clone();
239 if style == AddressingStyle::Path {
240 encode_path_component(bucket.as_bytes(), &mut path);
241 }
242 if let Some(key) = object_key {
243 if style == AddressingStyle::Path && !path.ends_with('/') {
244 path.push('/');
245 }
246 encode_object_key(key.as_bytes(), &mut path);
247 } else if style == AddressingStyle::VirtualHosted && path.len() > 1 {
248 path.pop();
249 }
250 EndpointUrl::from_parts(self.url.scheme(), &authority, &path)
251 }
252}
253
254impl Default for Endpoint {
255 fn default() -> Self {
256 Self::new("https://s3.amazonaws.com").expect("constant AWS endpoint is valid")
257 }
258}
259
260impl fmt::Debug for Endpoint {
261 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
262 formatter.debug_tuple("Endpoint").field(&self.url).finish()
263 }
264}
265
266impl fmt::Display for Endpoint {
267 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
268 formatter.write_str(self.url.as_str())
269 }
270}
271
272impl FromStr for Endpoint {
273 type Err = S3Error;
274
275 fn from_str(value: &str) -> Result<Self, Self::Err> {
276 Self::new(value)
277 }
278}
279
280fn encode_object_key(bytes: &[u8], output: &mut String) {
281 for &byte in bytes {
282 if byte == b'/' || is_unreserved(byte) {
283 output.push(char::from(byte));
284 } else {
285 push_percent_encoded(byte, output);
286 }
287 }
288}
289
290fn encode_path_component(bytes: &[u8], output: &mut String) {
291 for &byte in bytes {
292 if is_unreserved(byte) {
293 output.push(char::from(byte));
294 } else {
295 push_percent_encoded(byte, output);
296 }
297 }
298}
299
300const fn is_unreserved(byte: u8) -> bool {
301 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
302}
303
304fn push_percent_encoded(byte: u8, output: &mut String) {
305 const HEX: &[u8; 16] = b"0123456789ABCDEF";
306 output.push('%');
307 output.push(char::from(HEX[usize::from(byte >> 4)]));
308 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
309}
310
311fn validate_region(region: &str) -> Result<(), S3Error> {
312 if region.is_empty()
313 || region.len() > 64
314 || !region
315 .bytes()
316 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
317 {
318 return Err(S3Error::configuration("AWS region is invalid"));
319 }
320 Ok(())
321}
322
323fn validate_bucket_for_path(bucket: &str) -> Result<(), S3Error> {
324 if bucket.is_empty()
325 || bucket.len() > 255
326 || bucket
327 .bytes()
328 .any(|byte| byte == b'/' || byte == b'\\' || byte.is_ascii_control())
329 {
330 return Err(S3Error::configuration("bucket name is invalid"));
331 }
332 Ok(())
333}
334
335fn validate_virtual_host_bucket(bucket: &str) -> Result<(), S3Error> {
336 if !(3..=63).contains(&bucket.len())
337 || bucket.starts_with(['-', '.'])
338 || bucket.ends_with(['-', '.'])
339 || bucket.contains("..")
340 || !bucket.bytes().all(|byte| {
341 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'.'
342 })
343 || IpAddr::from_str(bucket).is_ok()
344 {
345 return Err(S3Error::configuration(
346 "bucket is not valid for virtual-hosted addressing",
347 ));
348 }
349 Ok(())
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use proptest::prelude::*;
356
357 #[test]
358 fn rejects_credential_bearing_and_ambiguous_endpoints() {
359 assert!(Endpoint::new("https://user:password@s3.example.test").is_err());
360 assert!(Endpoint::new("https://s3.example.test?target=elsewhere").is_err());
361 assert!(Endpoint::new("https://s3.example.test/#fragment").is_err());
362 assert!(Endpoint::new("ftp://s3.example.test").is_err());
363 }
364
365 #[test]
366 fn constructs_both_addressing_styles() {
367 let endpoint = Endpoint::new("https://storage.example.test/api").unwrap();
368 let path = endpoint
369 .object_url("my-bucket", Some("folder/a b"), AddressingStyle::Path)
370 .unwrap();
371 assert_eq!(
372 path.as_str(),
373 "https://storage.example.test/api/my-bucket/folder/a%20b"
374 );
375
376 let hosted = endpoint
377 .object_url(
378 "my-bucket",
379 Some("folder/a b"),
380 AddressingStyle::VirtualHosted,
381 )
382 .unwrap();
383 assert_eq!(
384 hosted.as_str(),
385 "https://my-bucket.storage.example.test/api/folder/a%20b"
386 );
387 }
388
389 #[test]
390 fn exact_s3_key_paths_are_never_normalized() {
391 let endpoint = Endpoint::new("https://storage.example.test:9443/root//").unwrap();
392 for (key, expected) in [
393 (".", "/root//bucket/."),
394 ("..", "/root//bucket/.."),
395 ("a/../b", "/root//bucket/a/../b"),
396 ("//a///b", "/root//bucket///a///b"),
397 ] {
398 let url = endpoint
399 .object_url("bucket", Some(key), AddressingStyle::Path)
400 .unwrap();
401 assert_eq!(url.scheme(), "https");
402 assert_eq!(url.authority(), "storage.example.test:9443");
403 assert_eq!(url.path_and_query(), expected);
404 assert_eq!(
405 url.request_uri().path_and_query().unwrap().as_str(),
406 expected
407 );
408 }
409 }
410
411 #[test]
412 fn query_is_appended_without_changing_path_or_origin() {
413 let url = Endpoint::new("https://storage.example.test")
414 .unwrap()
415 .object_url("bucket", Some("a/../b"), AddressingStyle::Path)
416 .unwrap()
417 .with_query("partNumber=7&uploadId=a%2Fb")
418 .unwrap();
419 assert_eq!(url.authority(), "storage.example.test");
420 assert_eq!(
421 url.path_and_query(),
422 "/bucket/a/../b?partNumber=7&uploadId=a%2Fb"
423 );
424 assert_eq!(
425 url.request_uri().path_and_query().unwrap().as_str(),
426 "/bucket/a/../b?partNumber=7&uploadId=a%2Fb"
427 );
428 }
429
430 proptest! {
431 #[test]
432 fn encoded_object_keys_do_not_change_origin(key in "[A-Za-z0-9 ./_%+-]{0,80}") {
433 let endpoint = Endpoint::new("https://storage.example.test").unwrap();
434 let url = endpoint.object_url("bucket", Some(&key), AddressingStyle::Path).unwrap();
435 prop_assert_eq!(url.scheme(), "https");
436 prop_assert_eq!(url.authority(), "storage.example.test");
437 prop_assert!(!url.path_and_query().contains('?'));
438 prop_assert!(!url.path_and_query().contains('#'));
439 }
440 }
441}