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(crate) 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(crate) fn scheme(&self) -> &str {
51 &self.scheme
52 }
53
54 pub(crate) fn authority(&self) -> &str {
56 &self.authority
57 }
58
59 pub(crate) fn path_and_query(&self) -> &str {
61 &self.path_and_query
62 }
63
64 pub(crate) 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 as_str(&self) -> &str {
200 self.url.as_str()
201 }
202
203 pub(crate) fn url(&self) -> &EndpointUrl {
204 &self.url
205 }
206
207 pub fn is_https(&self) -> bool {
209 self.url.scheme() == "https"
210 }
211
212 pub(crate) fn object_url(
219 &self,
220 bucket: &str,
221 object_key: Option<&str>,
222 style: AddressingStyle,
223 ) -> Result<EndpointUrl, S3Error> {
224 validate_bucket_for_path(bucket)?;
225 let authority = if style == AddressingStyle::VirtualHosted {
226 validate_virtual_host_bucket(bucket)?;
227 let parsed = Authority::from_str(self.url.authority())
228 .map_err(|_| S3Error::configuration("endpoint authority is invalid"))?;
229 if IpAddr::from_str(parsed.host().trim_matches(['[', ']'])).is_ok() {
230 return Err(S3Error::configuration(
231 "virtual-hosted addressing cannot be used with an IP endpoint",
232 ));
233 }
234 parsed.port_u16().map_or_else(
235 || format!("{bucket}.{}", parsed.host()),
236 |port| format!("{bucket}.{}:{port}", parsed.host()),
237 )
238 } else {
239 self.url.authority().to_owned()
240 };
241
242 let mut path = self.base_path.clone();
243 if style == AddressingStyle::Path {
244 encode_path_component(bucket.as_bytes(), &mut path);
245 }
246 if let Some(key) = object_key {
247 if style == AddressingStyle::Path && !path.ends_with('/') {
248 path.push('/');
249 }
250 encode_object_key(key.as_bytes(), &mut path);
251 } else if style == AddressingStyle::VirtualHosted && path.len() > 1 {
252 path.pop();
253 }
254 EndpointUrl::from_parts(self.url.scheme(), &authority, &path)
255 }
256}
257
258impl Default for Endpoint {
259 fn default() -> Self {
260 Self::new("https://s3.amazonaws.com").expect("constant AWS endpoint is valid")
261 }
262}
263
264impl fmt::Debug for Endpoint {
265 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
266 formatter.debug_tuple("Endpoint").field(&self.url).finish()
267 }
268}
269
270impl fmt::Display for Endpoint {
271 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272 formatter.write_str(self.url.as_str())
273 }
274}
275
276impl FromStr for Endpoint {
277 type Err = S3Error;
278
279 fn from_str(value: &str) -> Result<Self, Self::Err> {
280 Self::new(value)
281 }
282}
283
284fn encode_object_key(bytes: &[u8], output: &mut String) {
285 for &byte in bytes {
286 if byte == b'/' || is_unreserved(byte) {
287 output.push(char::from(byte));
288 } else {
289 push_percent_encoded(byte, output);
290 }
291 }
292}
293
294fn encode_path_component(bytes: &[u8], output: &mut String) {
295 for &byte in bytes {
296 if is_unreserved(byte) {
297 output.push(char::from(byte));
298 } else {
299 push_percent_encoded(byte, output);
300 }
301 }
302}
303
304const fn is_unreserved(byte: u8) -> bool {
305 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
306}
307
308fn push_percent_encoded(byte: u8, output: &mut String) {
309 const HEX: &[u8; 16] = b"0123456789ABCDEF";
310 output.push('%');
311 output.push(char::from(HEX[usize::from(byte >> 4)]));
312 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
313}
314
315fn validate_region(region: &str) -> Result<(), S3Error> {
316 if region.is_empty()
317 || region.len() > 64
318 || !region
319 .bytes()
320 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
321 {
322 return Err(S3Error::configuration("AWS region is invalid"));
323 }
324 Ok(())
325}
326
327fn validate_bucket_for_path(bucket: &str) -> Result<(), S3Error> {
328 if bucket.is_empty()
329 || bucket.len() > 255
330 || bucket
331 .bytes()
332 .any(|byte| byte == b'/' || byte == b'\\' || byte.is_ascii_control())
333 {
334 return Err(S3Error::configuration("bucket name is invalid"));
335 }
336 Ok(())
337}
338
339fn validate_virtual_host_bucket(bucket: &str) -> Result<(), S3Error> {
340 if !(3..=63).contains(&bucket.len())
341 || bucket.starts_with(['-', '.'])
342 || bucket.ends_with(['-', '.'])
343 || bucket.contains("..")
344 || !bucket.bytes().all(|byte| {
345 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'.'
346 })
347 || IpAddr::from_str(bucket).is_ok()
348 {
349 return Err(S3Error::configuration(
350 "bucket is not valid for virtual-hosted addressing",
351 ));
352 }
353 Ok(())
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use proptest::prelude::*;
360
361 #[test]
362 fn rejects_credential_bearing_and_ambiguous_endpoints() {
363 assert!(Endpoint::new("https://user:password@s3.example.test").is_err());
364 assert!(Endpoint::new("https://s3.example.test?target=elsewhere").is_err());
365 assert!(Endpoint::new("https://s3.example.test/#fragment").is_err());
366 assert!(Endpoint::new("ftp://s3.example.test").is_err());
367 }
368
369 #[test]
370 fn constructs_both_addressing_styles() {
371 let endpoint = Endpoint::new("https://storage.example.test/api").unwrap();
372 let path = endpoint
373 .object_url("my-bucket", Some("folder/a b"), AddressingStyle::Path)
374 .unwrap();
375 assert_eq!(
376 path.as_str(),
377 "https://storage.example.test/api/my-bucket/folder/a%20b"
378 );
379
380 let hosted = endpoint
381 .object_url(
382 "my-bucket",
383 Some("folder/a b"),
384 AddressingStyle::VirtualHosted,
385 )
386 .unwrap();
387 assert_eq!(
388 hosted.as_str(),
389 "https://my-bucket.storage.example.test/api/folder/a%20b"
390 );
391 }
392
393 #[test]
394 fn exact_s3_key_paths_are_never_normalized() {
395 let endpoint = Endpoint::new("https://storage.example.test:9443/root//").unwrap();
396 for (key, expected) in [
397 (".", "/root//bucket/."),
398 ("..", "/root//bucket/.."),
399 ("a/../b", "/root//bucket/a/../b"),
400 ("//a///b", "/root//bucket///a///b"),
401 ] {
402 let url = endpoint
403 .object_url("bucket", Some(key), AddressingStyle::Path)
404 .unwrap();
405 assert_eq!(url.scheme(), "https");
406 assert_eq!(url.authority(), "storage.example.test:9443");
407 assert_eq!(url.path_and_query(), expected);
408 assert_eq!(
409 url.request_uri().path_and_query().unwrap().as_str(),
410 expected
411 );
412 }
413 }
414
415 #[test]
416 fn query_is_appended_without_changing_path_or_origin() {
417 let url = Endpoint::new("https://storage.example.test")
418 .unwrap()
419 .object_url("bucket", Some("a/../b"), AddressingStyle::Path)
420 .unwrap()
421 .with_query("partNumber=7&uploadId=a%2Fb")
422 .unwrap();
423 assert_eq!(url.authority(), "storage.example.test");
424 assert_eq!(
425 url.path_and_query(),
426 "/bucket/a/../b?partNumber=7&uploadId=a%2Fb"
427 );
428 assert_eq!(
429 url.request_uri().path_and_query().unwrap().as_str(),
430 "/bucket/a/../b?partNumber=7&uploadId=a%2Fb"
431 );
432 }
433
434 proptest! {
435 #[test]
436 fn encoded_object_keys_do_not_change_origin(key in "[A-Za-z0-9 ./_%+-]{0,80}") {
437 let endpoint = Endpoint::new("https://storage.example.test").unwrap();
438 let url = endpoint.object_url("bucket", Some(&key), AddressingStyle::Path).unwrap();
439 prop_assert_eq!(url.scheme(), "https");
440 prop_assert_eq!(url.authority(), "storage.example.test");
441 prop_assert!(!url.path_and_query().contains('?'));
442 prop_assert!(!url.path_and_query().contains('#'));
443 }
444 }
445}