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/// An absolute endpoint URL whose path retains its exact wire representation.
23///
24/// Unlike a general-purpose browser URL, this value does not resolve dot segments
25/// or collapse repeated slashes. This matters because every byte in an S3 object
26/// key is significant and is covered by the request signature.
27#[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    /// Returns the URL scheme.
50    pub(crate) fn scheme(&self) -> &str {
51        &self.scheme
52    }
53
54    /// Returns the URL authority, including an explicit port when present.
55    pub(crate) fn authority(&self) -> &str {
56        &self.authority
57    }
58
59    /// Returns the exact encoded path and optional query sent on the wire.
60    pub(crate) fn path_and_query(&self) -> &str {
61        &self.path_and_query
62    }
63
64    /// Returns the complete absolute URL without changing its request target.
65    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/// A validated S3 service endpoint.
122#[derive(Clone, Eq, PartialEq)]
123pub struct Endpoint {
124    url: EndpointUrl,
125    base_path: String,
126}
127
128impl Endpoint {
129    /// Parses and validates an HTTP or HTTPS endpoint.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error when the URL is not an absolute HTTP(S) URL, contains
134    /// credentials, a query, or a fragment, or has no usable host.
135    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    /// Creates the standard regional AWS S3 endpoint.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error when `region` cannot be represented safely in an AWS
192    /// regional hostname.
193    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    /// Returns the validated endpoint as an absolute URL string.
199    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    /// Returns whether this endpoint uses HTTPS.
208    pub fn is_https(&self) -> bool {
209        self.url.scheme() == "https"
210    }
211
212    /// Builds an encoded bucket or object URL using the requested addressing style.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error when the bucket is invalid for the selected addressing
217    /// style or the resulting URL is not a valid absolute HTTP URI.
218    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}