Skip to main content

s3_wire/endpoint/
mod.rs

1//! Endpoint validation and exact request-target construction.
2
3use 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/// How a bucket name is represented in request URLs.
13#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
14pub enum AddressingStyle {
15    /// Put the bucket name in the request path.
16    #[default]
17    Path,
18    /// Put the bucket name before the endpoint hostname.
19    VirtualHosted,
20}
21
22/// Standard AWS S3 endpoint variant.
23#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
24pub enum AwsEndpointVariant {
25    /// Regional IPv4 endpoint.
26    #[default]
27    Standard,
28    /// Regional dual-stack IPv4/IPv6 endpoint.
29    DualStack,
30    /// Regional FIPS endpoint.
31    Fips,
32    /// Regional FIPS endpoint with dual-stack support.
33    FipsDualStack,
34}
35
36/// An absolute endpoint URL whose path retains its exact wire representation.
37///
38/// Unlike a general-purpose browser URL, this value does not resolve dot segments
39/// or collapse repeated slashes. This matters because every byte in an S3 object
40/// key is significant and is covered by the request signature.
41#[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    /// Returns the URL scheme.
64    pub(crate) fn scheme(&self) -> &str {
65        &self.scheme
66    }
67
68    /// Returns the URL authority, including an explicit port when present.
69    pub(crate) fn authority(&self) -> &str {
70        &self.authority
71    }
72
73    /// Returns the exact encoded path and optional query sent on the wire.
74    pub(crate) fn path_and_query(&self) -> &str {
75        &self.path_and_query
76    }
77
78    /// Returns the complete absolute URL without changing its request target.
79    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/// A validated S3 service endpoint.
136#[derive(Clone, Eq, PartialEq)]
137pub struct Endpoint {
138    url: EndpointUrl,
139    base_path: String,
140}
141
142impl Endpoint {
143    /// Parses and validates an HTTP or HTTPS endpoint.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error when the URL is not an absolute HTTP(S) URL, contains
148    /// credentials, a query, or a fragment, or has no usable host.
149    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    /// Creates the standard regional AWS S3 endpoint.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error when `region` cannot be represented safely in an AWS
206    /// regional hostname.
207    pub fn for_aws_region(region: &str) -> Result<Self, S3Error> {
208        Self::for_aws_region_variant(region, AwsEndpointVariant::Standard)
209    }
210
211    /// Creates a partition-aware regional AWS S3 endpoint variant.
212    ///
213    /// China regions use the `amazonaws.com.cn` DNS suffix. Commercial and
214    /// GovCloud regions use `amazonaws.com`. The region is still used as the
215    /// SigV4 signing region by [`crate::S3Config`].
216    ///
217    /// # Errors
218    ///
219    /// Returns an error when `region` cannot be represented safely in an AWS
220    /// regional hostname.
221    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    /// Returns the validated endpoint as an absolute URL string.
243    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    /// Returns whether this endpoint uses HTTPS.
252    pub fn is_https(&self) -> bool {
253        self.url.scheme() == "https"
254    }
255
256    /// Builds an encoded bucket or object URL using the requested addressing style.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error when the bucket is invalid for the selected addressing
261    /// style or the resulting URL is not a valid absolute HTTP URI.
262    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}