Skip to main content

mountpoint_s3_crt/s3/
endpoint_resolver.rs

1//! Endpoint resolver helps determine which S3 endpoint to use for a given configuration.
2
3use mountpoint_s3_crt_sys::*;
4use std::{
5    ffi::{OsStr, OsString},
6    os::unix::prelude::OsStrExt,
7    ptr::{self, NonNull},
8};
9use thiserror::Error;
10
11use crate::{
12    CrtError, ToAwsByteCursor, aws_byte_cursor_as_slice,
13    common::{allocator::Allocator, error::Error},
14};
15
16use super::s3_library_init;
17
18/// Errors returned on operations from `resolve()`.
19#[derive(Debug, Error, PartialEq, Eq)]
20pub enum ResolverError {
21    /// Pass through an error from the resolver rules
22    #[error("{0}")]
23    EndpointNotResolved(String),
24
25    /// Internal CRT error
26    #[error("CRT error: {0}")]
27    CrtError(#[from] Error),
28}
29
30/// [RuleEngine] to resolve endpoint with given [RequestContext] and ruleset
31#[derive(Debug)]
32pub struct RuleEngine {
33    inner: NonNull<aws_endpoints_rule_engine>,
34}
35
36impl RuleEngine {
37    /// Creates a new endpoint [RuleEngine].
38    pub fn new(allocator: &Allocator) -> Result<Self, Error> {
39        s3_library_init(allocator);
40        // SAFETY: `allocator.inner` is a valid aws_allocator and and we check the return is non-null.
41        // SAFETY: aws_s3_endpoint_resolver_new will acquire a reference to keep it alive after this function call, so it's safe to return an owned version to it.
42        let inner = unsafe { aws_s3_endpoint_resolver_new(allocator.inner.as_ptr()).ok_or_last_error()? };
43        Ok(Self { inner })
44    }
45
46    /// Resolve the endpoint with given [RequestContext] and ruleset.
47    pub fn resolve(&self, context: RequestContext) -> Result<ResolvedEndpoint, ResolverError> {
48        // SAFETY: `aws_endpoints_rule_engine_resolve` ensures that it returns a `aws_endpoints_resolved_endpoint` which is non-null later or it will return an error.
49        // SAFETY: Next, we handle all the arms `aws_endpoints_resolved_endpoint_get_type` separately.
50        // In case of `AWS_ENDPOINTS_RESOLVED_ERROR`, releasing the `out_endpoint` reference as Drop is not implemented for it anywhere.
51        // `aws_endpoints_resolved_endpoint_get_error` gives out a non-null `resolved_endpoint_error` pointer.
52        // Then owning the `resolved_endpoint_error` as OsString makes it safe to return. The return value is not borrowing the reference.
53        unsafe {
54            let mut out_endpoint: *mut aws_endpoints_resolved_endpoint = ptr::null_mut();
55            aws_endpoints_rule_engine_resolve(self.inner.as_ptr(), context.inner.as_ptr(), &mut out_endpoint)
56                .ok_or_last_error()?;
57            match aws_endpoints_resolved_endpoint_get_type(out_endpoint) {
58                aws_endpoints_resolved_endpoint_type::AWS_ENDPOINTS_RESOLVED_ERROR => {
59                    let mut out_error: aws_byte_cursor = Default::default();
60                    aws_endpoints_resolved_endpoint_get_error(out_endpoint, &mut out_error);
61                    let resolved_endpoint_error = std::str::from_utf8(aws_byte_cursor_as_slice(&out_error))
62                        .expect("endpoint errors are always valid UTF-8")
63                        .to_owned();
64                    aws_endpoints_resolved_endpoint_release(out_endpoint);
65                    Err(ResolverError::EndpointNotResolved(resolved_endpoint_error))
66                }
67                aws_endpoints_resolved_endpoint_type::AWS_ENDPOINTS_RESOLVED_ENDPOINT => Ok(ResolvedEndpoint {
68                    inner: NonNull::new_unchecked(out_endpoint),
69                }),
70                _ => unreachable!("Invalid Resolved Endpoint type"),
71            }
72        }
73    }
74}
75
76impl Drop for RuleEngine {
77    fn drop(&mut self) {
78        // SAFETY: `self.inner` is a valid `aws_endpoints_rule_engine`, and on Drop it's safe to decrement
79        // the reference count since this is balancing the `acquire` in `new`.
80        unsafe {
81            aws_endpoints_rule_engine_release(self.inner.as_ptr());
82        }
83    }
84}
85
86// SAFETY: `aws_endpoints_rule_engine` is reference counted and its methods are thread-safe
87unsafe impl Send for RuleEngine {}
88// SAFETY: `aws_endpoints_rule_engine` is reference counted and its methods are thread-safe
89unsafe impl Sync for RuleEngine {}
90
91/// Stores context about the endpoint configuration for use by the endpoint resolver
92#[derive(Debug)]
93pub struct RequestContext {
94    inner: NonNull<aws_endpoints_request_context>,
95}
96
97impl RequestContext {
98    /// Creates a new endpoint [RequestContext].
99    pub fn new(allocator: &Allocator) -> Result<Self, Error> {
100        s3_library_init(allocator);
101        // SAFETY: `allocator.inner` is a valid aws_allocator and and we check the return is non-null.
102        // SAFETY: aws_endpoints_request_context_new will acquire a reference to keep it alive after this function call, so it's safe to return an owned version to it.
103        let inner = unsafe { aws_endpoints_request_context_new(allocator.inner.as_ptr()).ok_or_last_error()? };
104        Ok(Self { inner })
105    }
106
107    /// Add the parameter to [RequestContext] whose value is in form of string
108    pub fn add_string(
109        &mut self,
110        allocator: &Allocator,
111        name: impl AsRef<OsStr>,
112        value: impl AsRef<OsStr>,
113    ) -> Result<(), Error> {
114        // SAFETY: allocator.inner and self.inner should be valid pointers.
115        // `name` and `value` will be copied by CRT, thus borrowing is safe.
116        unsafe {
117            aws_endpoints_request_context_add_string(
118                allocator.inner.as_ptr(),
119                self.inner.as_ptr(),
120                name.as_aws_byte_cursor(),
121                value.as_aws_byte_cursor(),
122            )
123            .ok_or_last_error()
124        }
125    }
126
127    /// Add the parameter to [RequestContext] whose value is in form of boolean
128    pub fn add_boolean(&mut self, allocator: &Allocator, name: impl AsRef<OsStr>, value: bool) -> Result<(), Error> {
129        // SAFETY: allocator.inner and self.inner should be valid pointers.
130        // `name` will be copied by CRT, thus borrowing is safe.
131        unsafe {
132            aws_endpoints_request_context_add_boolean(
133                allocator.inner.as_ptr(),
134                self.inner.as_ptr(),
135                name.as_aws_byte_cursor(),
136                value,
137            )
138            .ok_or_last_error()
139        }
140    }
141}
142
143impl Drop for RequestContext {
144    fn drop(&mut self) {
145        // SAFETY: `self.inner` is a valid `aws_endpoints_request_context`, and on Drop it's safe to decrement
146        // the reference count since this is balancing the `acquire` in `new`.
147        unsafe {
148            aws_endpoints_request_context_release(self.inner.as_ptr());
149        }
150    }
151}
152
153/// [ResolvedEndpoint] for the given context using [RuleEngine] and rule set
154#[derive(Debug)]
155pub struct ResolvedEndpoint {
156    inner: NonNull<aws_endpoints_resolved_endpoint>,
157}
158
159impl ResolvedEndpoint {
160    /// Get URI from the [ResolvedEndpoint]
161    pub fn get_url(&self) -> OsString {
162        let mut url: aws_byte_cursor = Default::default();
163        // SAFETY: self.inner is valid pointer to resolved endpoint as it is supposed to be used after Resolve(). url is passed as valid mutable pointer.
164        //`aws_endpoint_resolved_enpoint_get_url` ensures to return an initialized aws_byte_cursor for url in such case.
165        unsafe {
166            aws_endpoints_resolved_endpoint_get_url(self.inner.as_ptr(), &mut url);
167        }
168        // SAFETY: `uri` does not outlive the aws_byte_cursor `url` as an owned OsString is returned rather than reference to a slice.
169        unsafe {
170            let uri = aws_byte_cursor_as_slice(&url);
171            OsStr::from_bytes(uri).to_os_string()
172        }
173    }
174
175    /// Get properties like `authSchemes` from the [ResolvedEndpoint]
176    pub fn get_properties(&self) -> OsString {
177        let mut properties: aws_byte_cursor = Default::default();
178        // SAFETY: self.inner is valid pointer to resolved endpoint as it is supposed to be used after Resolve(). url is passed as valid mutable pointer.
179        //`aws_endpoint_resolved_enpoint_get_properties` ensures to return an initialized aws_byte_cursor for properties in such case.
180        unsafe {
181            aws_endpoints_resolved_endpoint_get_properties(self.inner.as_ptr(), &mut properties);
182        }
183        // SAFETY: `endpoint_properties` does not outlive the aws_byte_cursor `properties` as an owned OsString is returned rather than reference to a slice.
184        unsafe {
185            let endpoint_properties = aws_byte_cursor_as_slice(&properties);
186            OsStr::from_bytes(endpoint_properties).to_os_string()
187        }
188    }
189}
190
191impl Drop for ResolvedEndpoint {
192    fn drop(&mut self) {
193        // SAFETY: `self.inner` is a valid `aws_endpoints_resolved_endpoint`, and on Drop it's safe to decrement
194        // the reference count since this is balancing the `acquire` in `new`.
195        unsafe {
196            aws_endpoints_resolved_endpoint_release(self.inner.as_ptr());
197        }
198    }
199}
200
201#[cfg(test)]
202mod test {
203    use crate::common::allocator::Allocator;
204
205    use super::{RequestContext, ResolverError, RuleEngine};
206
207    use test_case::test_case;
208
209    fn test_endpoint_resolver_init(
210        bucket: &str,
211        region: &str,
212        allocator: &Allocator,
213        request_context: &mut RequestContext,
214    ) {
215        request_context.add_string(allocator, "Bucket", bucket).unwrap();
216        request_context.add_string(allocator, "Region", region).unwrap();
217    }
218
219    #[test_case("s3-bucket-test", "cn-north-1", "https://s3-bucket-test.s3.cn-north-1.amazonaws.com.cn"; "regions outside aws partition")]
220    #[test_case("s3-bucket-test", "eu-west-1", "https://s3-bucket-test.s3.eu-west-1.amazonaws.com"; "regions within aws partition")]
221    #[test_case("s3-bucket-test", "us-gov-west-1", "https://s3-bucket-test.s3.us-gov-west-1.amazonaws.com"; "regions in aws-us-gov partition")]
222    #[test_case("s3-bucket.test", "eu-west-1", "https://s3.eu-west-1.amazonaws.com/s3-bucket.test"; "bucket name with . to check default behviour for aliases")]
223    #[test_case("mountpoint-o-o000s3-bucket-test0000000000000000000000000--op-s3", "us-east-1", "https://mountpoint-o-o000s3-bucket-test0000000000000000000000000--op-s3.op-000s3-bucket-test.s3-outposts.us-east-1.amazonaws.com"; "Outpost Access Point alias")]
224    #[test_case("arn:aws:s3::accountID:accesspoint/s3-bucket-test.mrap", "eu-west-1", "https://s3-bucket-test.mrap.accesspoint.s3-global.amazonaws.com"; "ARN as bucket name")]
225    #[test_case("s3-bucket-test", "invalid-region", "https://s3-bucket-test.s3.invalid-region.amazonaws.com"; "invalid region name")]
226    fn test_default_regions_and_buckets_setting(bucket: &str, region: &str, resolved_endpoint: &str) {
227        let new_allocator = Allocator::default();
228        let endpoint_rule_engine = RuleEngine::new(&new_allocator).unwrap();
229        let mut endpoint_request_context = RequestContext::new(&new_allocator).unwrap();
230        test_endpoint_resolver_init(bucket, region, &new_allocator, &mut endpoint_request_context);
231        let endpoint_resolved = endpoint_rule_engine
232            .resolve(endpoint_request_context)
233            .expect("endpoint should resolve as rules should match context");
234        let endpoint_uri = endpoint_resolved.get_url();
235        assert_eq!(endpoint_uri, resolved_endpoint);
236    }
237
238    #[test_case("UseFIPS", "https://s3-bucket-test.s3-fips.us-east-1.amazonaws.com"; "Using FIPS option")]
239    #[test_case("UseDualStack", "https://s3-bucket-test.s3.dualstack.us-east-1.amazonaws.com"; "Using Dual Stack (IPv6) option")]
240    #[test_case("Accelerate", "https://s3-bucket-test.s3-accelerate.amazonaws.com"; "Using transfer acceleration option")]
241    #[test_case("ForcePathStyle", "https://s3.us-east-1.amazonaws.com/s3-bucket-test"; "Addressing style set to Path style")]
242    #[test_case("InvalidOption", "https://s3-bucket-test.s3.us-east-1.amazonaws.com"; "Invalid option is ignored")]
243    fn test_optional_settings(mount_option: &str, resolved_endpoint: &str) {
244        let new_allocator = Allocator::default();
245        let endpoint_rule_engine = RuleEngine::new(&new_allocator).unwrap();
246        let mut endpoint_request_context = RequestContext::new(&new_allocator).unwrap();
247        test_endpoint_resolver_init(
248            "s3-bucket-test",
249            "us-east-1",
250            &new_allocator,
251            &mut endpoint_request_context,
252        );
253        endpoint_request_context
254            .add_boolean(&new_allocator, mount_option, true)
255            .unwrap();
256        let endpoint_resolved = endpoint_rule_engine
257            .resolve(endpoint_request_context)
258            .expect("endpoint should resolve as rules should match context");
259        let endpoint_uri = endpoint_resolved.get_url();
260        assert_eq!(endpoint_uri, resolved_endpoint);
261    }
262
263    #[test_case(vec!["UseDualStack".to_string(), "Accelerate".to_string()], "https://s3-bucket-test.s3-accelerate.dualstack.amazonaws.com"; "Using Dual Stack and transfer acceleration")]
264    #[test_case(vec!["UseDualStack".to_string(), "UseFIPS".to_string()], "https://s3-bucket-test.s3-fips.dualstack.us-east-1.amazonaws.com"; "Using Dual Stack and FIPS settings")]
265    fn test_options_combination_setting(mount_options: Vec<String>, resolved_endpoint: &str) {
266        let new_allocator = Allocator::default();
267        let endpoint_rule_engine = RuleEngine::new(&new_allocator).unwrap();
268        let mut endpoint_request_context = RequestContext::new(&new_allocator).unwrap();
269        test_endpoint_resolver_init(
270            "s3-bucket-test",
271            "us-east-1",
272            &new_allocator,
273            &mut endpoint_request_context,
274        );
275        for options in mount_options.iter() {
276            endpoint_request_context
277                .add_boolean(&new_allocator, options, true)
278                .unwrap();
279        }
280        let endpoint_resolved = endpoint_rule_engine
281            .resolve(endpoint_request_context)
282            .expect("endpoint should resolve as rules should match context");
283        let endpoint_uri = endpoint_resolved.get_url();
284        assert_eq!(endpoint_uri, resolved_endpoint);
285    }
286
287    #[test]
288    fn test_incorrect_transfer_acceleration_path_style_combination() {
289        let new_allocator = Allocator::default();
290        let endpoint_rule_engine = RuleEngine::new(&new_allocator).unwrap();
291        let mut endpoint_request_context = RequestContext::new(&new_allocator).unwrap();
292        test_endpoint_resolver_init(
293            "s3-bucket-test",
294            "us-east-1",
295            &new_allocator,
296            &mut endpoint_request_context,
297        );
298        endpoint_request_context
299            .add_boolean(&new_allocator, "ForcePathStyle", true)
300            .unwrap();
301        endpoint_request_context
302            .add_boolean(&new_allocator, "Accelerate", true)
303            .unwrap();
304        let err = endpoint_rule_engine
305            .resolve(endpoint_request_context)
306            .expect_err("Transfer acceleration should not work with Path style addressing");
307        assert_eq!(
308            err,
309            ResolverError::EndpointNotResolved("Path-style addressing cannot be used with S3 Accelerate".to_owned())
310        );
311    }
312
313    #[test]
314    fn test_resolver_without_region_failure() {
315        let new_allocator = Allocator::default();
316        let endpoint_rule_engine = RuleEngine::new(&new_allocator).unwrap();
317        let mut endpoint_request_context = RequestContext::new(&new_allocator).unwrap();
318        endpoint_request_context
319            .add_string(&new_allocator, "Bucket", "test-bucket")
320            .unwrap();
321        let err = endpoint_rule_engine
322            .resolve(endpoint_request_context)
323            .expect_err("Ednpoint should not be formed without region specified");
324        assert_eq!(
325            err,
326            ResolverError::EndpointNotResolved("A region must be set when sending requests to S3.".to_owned())
327        );
328    }
329
330    #[test_case("arn:aws:s3-object-lambda:eu-west-1:AccountID:accesspoint/olap-bucket-test", "{\"authSchemes\":[{\"disableDoubleEncoding\":true,\"name\":\"sigv4\",\"signingName\":\"s3-object-lambda\",\"signingRegion\":\"eu-west-1\"}]}"; "OLAP ARN")]
331    #[test_case("arn:aws:s3:us-east-2:AccountID:accesspoint/access-point-test", "{\"authSchemes\":[{\"disableDoubleEncoding\":true,\"name\":\"sigv4\",\"signingName\":\"s3\",\"signingRegion\":\"us-east-2\"}]}"; "Access Point ARN")]
332    #[test_case("arn:aws:s3::accountID:accesspoint/s3-bucket-test.mrap", "{\"authSchemes\":[{\"disableDoubleEncoding\":true,\"name\":\"sigv4a\",\"signingName\":\"s3\",\"signingRegionSet\":[\"*\"]}]}"; "MRAP")]
333    fn test_endpoint_properties_for_arn(arn: &str, endpoint_property: &str) {
334        let new_allocator = Allocator::default();
335        let endpoint_rule_engine = RuleEngine::new(&new_allocator).unwrap();
336        let mut endpoint_request_context = RequestContext::new(&new_allocator).unwrap();
337        test_endpoint_resolver_init(arn, "us-east-1", &new_allocator, &mut endpoint_request_context);
338        let resolved_endpoint = endpoint_rule_engine.resolve(endpoint_request_context).unwrap();
339        let property = resolved_endpoint.get_properties();
340        assert_eq!(property, endpoint_property);
341    }
342
343    #[test_case("my-access-point-random-s3alias", "{\"authSchemes\":[{\"disableDoubleEncoding\":true,\"name\":\"sigv4\",\"signingName\":\"s3\",\"signingRegion\":\"us-east-2\"}]}"; "AccessPoint Alias")]
344    #[test_case("my-object-lambda-acc-random-alias--ol-s3", "{\"authSchemes\":[{\"disableDoubleEncoding\":true,\"name\":\"sigv4\",\"signingName\":\"s3\",\"signingRegion\":\"us-east-2\"}]}"; "ObjectLambda Alias")]
345    #[test_case("test-bucket", "{\"authSchemes\":[{\"disableDoubleEncoding\":true,\"name\":\"sigv4\",\"signingName\":\"s3\",\"signingRegion\":\"us-east-2\"}]}"; "Normal bucket")]
346    fn test_endpoint_properties_for_bucket_alias(bucket: &str, endpoint_property: &str) {
347        let new_allocator = Allocator::default();
348        let endpoint_rule_engine = RuleEngine::new(&new_allocator).unwrap();
349        let mut endpoint_request_context = RequestContext::new(&new_allocator).unwrap();
350        test_endpoint_resolver_init(bucket, "us-east-2", &new_allocator, &mut endpoint_request_context);
351        let resolved_endpoint = endpoint_rule_engine.resolve(endpoint_request_context).unwrap();
352        let property = resolved_endpoint.get_properties();
353        assert_eq!(property, endpoint_property);
354    }
355}