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 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 fn scheme(&self) -> &str {
51        &self.scheme
52    }
53
54    /// Returns the URL authority, including an explicit port when present.
55    pub fn authority(&self) -> &str {
56        &self.authority
57    }
58
59    /// Returns the exact encoded path and optional query sent on the wire.
60    pub 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 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 URL.
199    pub fn url(&self) -> &EndpointUrl {
200        &self.url
201    }
202
203    /// Returns whether this endpoint uses HTTPS.
204    pub fn is_https(&self) -> bool {
205        self.url.scheme() == "https"
206    }
207
208    /// Builds an encoded bucket or object URL using the requested addressing style.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error when the bucket is invalid for the selected addressing
213    /// style or the resulting URL is not a valid absolute HTTP URI.
214    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}