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, Copy, Debug, Default, Eq, PartialEq)]
24pub enum AwsEndpointVariant {
25 #[default]
27 Standard,
28 DualStack,
30 Fips,
32 FipsDualStack,
34}
35
36#[derive(Clone, Eq, PartialEq)]
42pub(crate) struct EndpointUrl {
43 serialized: String,
44 scheme: String,
45 authority: String,
46 path_and_query: String,
47}
48
49impl EndpointUrl {
50 fn from_parts(scheme: &str, authority: &str, path_and_query: &str) -> Result<Self, S3Error> {
51 let serialized = format!("{scheme}://{authority}{path_and_query}");
52 serialized
53 .parse::<Uri>()
54 .map_err(|_| S3Error::configuration("endpoint does not form a valid HTTP URI"))?;
55 Ok(Self {
56 serialized,
57 scheme: scheme.to_owned(),
58 authority: authority.to_owned(),
59 path_and_query: path_and_query.to_owned(),
60 })
61 }
62
63 pub(crate) fn scheme(&self) -> &str {
65 &self.scheme
66 }
67
68 pub(crate) fn authority(&self) -> &str {
70 &self.authority
71 }
72
73 pub(crate) fn path_and_query(&self) -> &str {
75 &self.path_and_query
76 }
77
78 pub(crate) fn as_str(&self) -> &str {
80 &self.serialized
81 }
82
83 pub(crate) fn request_uri(&self) -> Uri {
84 self.serialized
85 .parse()
86 .expect("EndpointUrl construction validates its HTTP URI")
87 }
88
89 pub(crate) fn with_query(&self, query: &str) -> Result<Self, S3Error> {
90 if query.starts_with('?') || query.contains('#') {
91 return Err(S3Error::configuration(
92 "request query must not include '?' or a fragment",
93 ));
94 }
95 let path = self
96 .path_and_query
97 .split_once('?')
98 .map_or(self.path_and_query.as_str(), |(path, _)| path);
99 let path_and_query = if query.is_empty() {
100 path.to_owned()
101 } else {
102 format!("{path}?{query}")
103 };
104 Self::from_parts(&self.scheme, &self.authority, &path_and_query)
105 }
106
107 pub(crate) fn with_authority(&self, authority: &str) -> Result<Self, S3Error> {
108 let parsed = Authority::from_str(authority)
109 .map_err(|_| S3Error::configuration("redirect authority is invalid"))?;
110 if parsed.as_str().contains('@') || parsed.host().is_empty() {
111 return Err(S3Error::configuration("redirect authority is invalid"));
112 }
113 Self::from_parts(&self.scheme, parsed.as_str(), &self.path_and_query)
114 }
115}
116
117impl fmt::Debug for EndpointUrl {
118 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119 let (path, query) = self
120 .path_and_query
121 .split_once('?')
122 .map_or((self.path_and_query.as_str(), false), |(path, _)| {
123 (path, true)
124 });
125 formatter
126 .debug_struct("EndpointUrl")
127 .field("scheme", &self.scheme)
128 .field("authority", &self.authority)
129 .field("path", &path)
130 .field("has_redacted_query", &query)
131 .finish_non_exhaustive()
132 }
133}
134
135#[derive(Clone, Eq, PartialEq)]
137pub struct Endpoint {
138 url: EndpointUrl,
139 base_path: String,
140}
141
142impl Endpoint {
143 pub fn new(endpoint: impl AsRef<str>) -> Result<Self, S3Error> {
150 let input = endpoint.as_ref();
151 if input.contains('#') {
152 return Err(S3Error::configuration(
153 "endpoint must not contain a query string or fragment",
154 ));
155 }
156 let uri = input
157 .parse::<Uri>()
158 .map_err(|_| S3Error::configuration("endpoint must be a valid absolute URL"))?;
159 let scheme = uri
160 .scheme()
161 .ok_or_else(|| S3Error::configuration("endpoint must include a scheme"))?;
162 if scheme != &Scheme::HTTPS && scheme != &Scheme::HTTP {
163 return Err(S3Error::configuration(
164 "endpoint scheme must be HTTPS or explicitly enabled HTTP",
165 ));
166 }
167 let authority = uri
168 .authority()
169 .ok_or_else(|| S3Error::configuration("endpoint must include a host"))?;
170 if authority.as_str().contains('@') {
171 return Err(S3Error::configuration(
172 "endpoint must not contain user information",
173 ));
174 }
175 if authority.host().is_empty() {
176 return Err(S3Error::configuration("endpoint must include a host"));
177 }
178 if uri
179 .path_and_query()
180 .is_some_and(|value| value.query().is_some())
181 {
182 return Err(S3Error::configuration(
183 "endpoint must not contain a query string or fragment",
184 ));
185 }
186
187 let mut base_path = uri.path().to_owned();
188 if base_path.is_empty() {
189 base_path.push('/');
190 }
191 if !base_path.starts_with('/') {
192 return Err(S3Error::configuration("endpoint path must be absolute"));
193 }
194 if !base_path.ends_with('/') {
195 base_path.push('/');
196 }
197 let url = EndpointUrl::from_parts(scheme.as_str(), authority.as_str(), &base_path)?;
198 Ok(Self { url, base_path })
199 }
200
201 pub fn for_aws_region(region: &str) -> Result<Self, S3Error> {
208 Self::for_aws_region_variant(region, AwsEndpointVariant::Standard)
209 }
210
211 pub fn for_aws_region_variant(
222 region: &str,
223 variant: AwsEndpointVariant,
224 ) -> Result<Self, S3Error> {
225 validate_region(region)?;
226 let suffix = if region.starts_with("cn-") {
227 "amazonaws.com.cn"
228 } else {
229 "amazonaws.com"
230 };
231 let service = match variant {
232 AwsEndpointVariant::Standard | AwsEndpointVariant::DualStack => "s3",
233 AwsEndpointVariant::Fips | AwsEndpointVariant::FipsDualStack => "s3-fips",
234 };
235 let dualstack = match variant {
236 AwsEndpointVariant::DualStack | AwsEndpointVariant::FipsDualStack => ".dualstack",
237 AwsEndpointVariant::Standard | AwsEndpointVariant::Fips => "",
238 };
239 Self::new(format!("https://{service}{dualstack}.{region}.{suffix}"))
240 }
241
242 pub fn as_str(&self) -> &str {
244 self.url.as_str()
245 }
246
247 pub(crate) fn url(&self) -> &EndpointUrl {
248 &self.url
249 }
250
251 pub fn is_https(&self) -> bool {
253 self.url.scheme() == "https"
254 }
255
256 pub(crate) fn object_url(
263 &self,
264 bucket: &str,
265 object_key: Option<&str>,
266 style: AddressingStyle,
267 ) -> Result<EndpointUrl, S3Error> {
268 validate_bucket_for_path(bucket)?;
269 let authority = if style == AddressingStyle::VirtualHosted {
270 validate_virtual_host_bucket(bucket)?;
271 let parsed = Authority::from_str(self.url.authority())
272 .map_err(|_| S3Error::configuration("endpoint authority is invalid"))?;
273 if IpAddr::from_str(parsed.host().trim_matches(['[', ']'])).is_ok() {
274 return Err(S3Error::configuration(
275 "virtual-hosted addressing cannot be used with an IP endpoint",
276 ));
277 }
278 parsed.port_u16().map_or_else(
279 || format!("{bucket}.{}", parsed.host()),
280 |port| format!("{bucket}.{}:{port}", parsed.host()),
281 )
282 } else {
283 self.url.authority().to_owned()
284 };
285
286 let mut path = self.base_path.clone();
287 if style == AddressingStyle::Path {
288 encode_path_component(bucket.as_bytes(), &mut path);
289 }
290 if let Some(key) = object_key {
291 if style == AddressingStyle::Path && !path.ends_with('/') {
292 path.push('/');
293 }
294 encode_object_key(key.as_bytes(), &mut path);
295 } else if style == AddressingStyle::VirtualHosted && path.len() > 1 {
296 path.pop();
297 }
298 EndpointUrl::from_parts(self.url.scheme(), &authority, &path)
299 }
300}
301
302impl Default for Endpoint {
303 fn default() -> Self {
304 Self::new("https://s3.amazonaws.com").expect("constant AWS endpoint is valid")
305 }
306}
307
308impl fmt::Debug for Endpoint {
309 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
310 formatter.debug_tuple("Endpoint").field(&self.url).finish()
311 }
312}
313
314impl fmt::Display for Endpoint {
315 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
316 formatter.write_str(self.url.as_str())
317 }
318}
319
320impl FromStr for Endpoint {
321 type Err = S3Error;
322
323 fn from_str(value: &str) -> Result<Self, Self::Err> {
324 Self::new(value)
325 }
326}
327
328fn encode_object_key(bytes: &[u8], output: &mut String) {
329 for &byte in bytes {
330 if byte == b'/' || is_unreserved(byte) {
331 output.push(char::from(byte));
332 } else {
333 push_percent_encoded(byte, output);
334 }
335 }
336}
337
338fn encode_path_component(bytes: &[u8], output: &mut String) {
339 for &byte in bytes {
340 if is_unreserved(byte) {
341 output.push(char::from(byte));
342 } else {
343 push_percent_encoded(byte, output);
344 }
345 }
346}
347
348const fn is_unreserved(byte: u8) -> bool {
349 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
350}
351
352fn push_percent_encoded(byte: u8, output: &mut String) {
353 const HEX: &[u8; 16] = b"0123456789ABCDEF";
354 output.push('%');
355 output.push(char::from(HEX[usize::from(byte >> 4)]));
356 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
357}
358
359fn validate_region(region: &str) -> Result<(), S3Error> {
360 if region.is_empty()
361 || region.len() > 64
362 || !region
363 .bytes()
364 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
365 {
366 return Err(S3Error::configuration("AWS region is invalid"));
367 }
368 Ok(())
369}
370
371fn validate_bucket_for_path(bucket: &str) -> Result<(), S3Error> {
372 if bucket.is_empty()
373 || bucket.len() > 255
374 || bucket
375 .bytes()
376 .any(|byte| byte == b'/' || byte == b'\\' || byte.is_ascii_control())
377 {
378 return Err(S3Error::configuration("bucket name is invalid"));
379 }
380 Ok(())
381}
382
383fn validate_virtual_host_bucket(bucket: &str) -> Result<(), S3Error> {
384 if !(3..=63).contains(&bucket.len())
385 || bucket.starts_with(['-', '.'])
386 || bucket.ends_with(['-', '.'])
387 || bucket.contains("..")
388 || !bucket.bytes().all(|byte| {
389 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'.'
390 })
391 || IpAddr::from_str(bucket).is_ok()
392 {
393 return Err(S3Error::configuration(
394 "bucket is not valid for virtual-hosted addressing",
395 ));
396 }
397 Ok(())
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use proptest::prelude::*;
404
405 #[test]
406 fn rejects_credential_bearing_and_ambiguous_endpoints() {
407 assert!(Endpoint::new("https://user:password@s3.example.test").is_err());
408 assert!(Endpoint::new("https://s3.example.test?target=elsewhere").is_err());
409 assert!(Endpoint::new("https://s3.example.test/#fragment").is_err());
410 assert!(Endpoint::new("ftp://s3.example.test").is_err());
411 }
412
413 #[test]
414 fn constructs_both_addressing_styles() {
415 let endpoint = Endpoint::new("https://storage.example.test/api").unwrap();
416 let path = endpoint
417 .object_url("my-bucket", Some("folder/a b"), AddressingStyle::Path)
418 .unwrap();
419 assert_eq!(
420 path.as_str(),
421 "https://storage.example.test/api/my-bucket/folder/a%20b"
422 );
423
424 let hosted = endpoint
425 .object_url(
426 "my-bucket",
427 Some("folder/a b"),
428 AddressingStyle::VirtualHosted,
429 )
430 .unwrap();
431 assert_eq!(
432 hosted.as_str(),
433 "https://my-bucket.storage.example.test/api/folder/a%20b"
434 );
435 }
436
437 #[test]
438 fn constructs_partition_and_variant_aware_aws_endpoints() {
439 assert_eq!(
440 Endpoint::for_aws_region("us-west-2").unwrap().as_str(),
441 "https://s3.us-west-2.amazonaws.com/"
442 );
443 assert_eq!(
444 Endpoint::for_aws_region_variant("cn-north-1", AwsEndpointVariant::DualStack)
445 .unwrap()
446 .as_str(),
447 "https://s3.dualstack.cn-north-1.amazonaws.com.cn/"
448 );
449 assert_eq!(
450 Endpoint::for_aws_region_variant("us-gov-west-1", AwsEndpointVariant::FipsDualStack)
451 .unwrap()
452 .as_str(),
453 "https://s3-fips.dualstack.us-gov-west-1.amazonaws.com/"
454 );
455 }
456
457 #[test]
458 fn exact_s3_key_paths_are_never_normalized() {
459 let endpoint = Endpoint::new("https://storage.example.test:9443/root//").unwrap();
460 for (key, expected) in [
461 (".", "/root//bucket/."),
462 ("..", "/root//bucket/.."),
463 ("a/../b", "/root//bucket/a/../b"),
464 ("//a///b", "/root//bucket///a///b"),
465 ] {
466 let url = endpoint
467 .object_url("bucket", Some(key), AddressingStyle::Path)
468 .unwrap();
469 assert_eq!(url.scheme(), "https");
470 assert_eq!(url.authority(), "storage.example.test:9443");
471 assert_eq!(url.path_and_query(), expected);
472 assert_eq!(
473 url.request_uri().path_and_query().unwrap().as_str(),
474 expected
475 );
476 }
477 }
478
479 #[test]
480 fn query_is_appended_without_changing_path_or_origin() {
481 let url = Endpoint::new("https://storage.example.test")
482 .unwrap()
483 .object_url("bucket", Some("a/../b"), AddressingStyle::Path)
484 .unwrap()
485 .with_query("partNumber=7&uploadId=a%2Fb")
486 .unwrap();
487 assert_eq!(url.authority(), "storage.example.test");
488 assert_eq!(
489 url.path_and_query(),
490 "/bucket/a/../b?partNumber=7&uploadId=a%2Fb"
491 );
492 assert_eq!(
493 url.request_uri().path_and_query().unwrap().as_str(),
494 "/bucket/a/../b?partNumber=7&uploadId=a%2Fb"
495 );
496 }
497
498 proptest! {
499 #[test]
500 fn encoded_object_keys_do_not_change_origin(key in "[A-Za-z0-9 ./_%+-]{0,80}") {
501 let endpoint = Endpoint::new("https://storage.example.test").unwrap();
502 let url = endpoint.object_url("bucket", Some(&key), AddressingStyle::Path).unwrap();
503 prop_assert_eq!(url.scheme(), "https");
504 prop_assert_eq!(url.authority(), "storage.example.test");
505 prop_assert!(!url.path_and_query().contains('?'));
506 prop_assert!(!url.path_and_query().contains('#'));
507 }
508 }
509}