Skip to main content

lance_io/object_store/providers/
aws.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration};
5
6#[cfg(test)]
7use mock_instant::thread_local::{SystemTime, UNIX_EPOCH};
8
9#[cfg(not(test))]
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use object_store::ObjectStore as OSObjectStore;
13use object_store_opendal::OpendalStore;
14use opendal::{Operator, services::S3};
15
16use aws_config::default_provider::credentials::DefaultCredentialsChain;
17use aws_credential_types::provider::ProvideCredentials;
18use object_store::{
19    ClientOptions, CredentialProvider, Result as ObjectStoreResult, RetryConfig,
20    StaticCredentialProvider,
21    aws::{
22        AmazonS3Builder, AmazonS3ConfigKey, AwsCredential as ObjectStoreAwsCredential,
23        AwsCredentialProvider,
24    },
25};
26use tokio::sync::RwLock;
27use url::Url;
28
29use crate::object_store::{
30    DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
31    ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
32    dynamic_credentials::{NamespaceCredentialsProvider, build_dynamic_credential_provider},
33    throttle::{AimdThrottleConfig, AimdThrottledStore},
34};
35use lance_core::error::{Error, Result};
36
37#[derive(Default, Debug)]
38pub struct AwsStoreProvider;
39
40impl AwsStoreProvider {
41    async fn build_amazon_s3_store(
42        &self,
43        base_path: &mut Url,
44        params: &ObjectStoreParams,
45        storage_options: &StorageOptions,
46        is_s3_express: bool,
47    ) -> Result<Arc<dyn OSObjectStore>> {
48        // Use a low retry count since the AIMD throttle layer handles
49        // throttle recovery with its own retry loop.
50        let retry_config = RetryConfig {
51            backoff: Default::default(),
52            max_retries: storage_options.client_max_retries(),
53            retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()),
54        };
55
56        let mut s3_storage_options = storage_options.as_s3_options();
57        let region = resolve_s3_region(base_path, &s3_storage_options).await?;
58
59        // Get accessor from params
60        let accessor = params.get_accessor();
61
62        let (aws_creds, region) = build_aws_credential(
63            params.s3_credentials_refresh_offset,
64            params.aws_credentials.clone(),
65            Some(&s3_storage_options),
66            region,
67            accessor,
68        )
69        .await?;
70
71        // Set S3Express flag if detected
72        if is_s3_express {
73            s3_storage_options.insert(AmazonS3ConfigKey::S3Express, true.to_string());
74        }
75
76        // Compute the metrics label before rewriting the url below, so it
77        // matches the prefix the registry uses to key this store.
78        #[cfg(feature = "metrics")]
79        let store_prefix =
80            self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
81
82        // before creating the OSObjectStore we need to rewrite the url to drop ddb related parts
83        base_path.set_scheme("s3").unwrap();
84        base_path.set_query(None);
85
86        // we can't use parse_url_opts here because we need to manually set the credentials provider
87        let mut builder =
88            AmazonS3Builder::new().with_client_options(storage_options.client_options()?);
89        for (key, value) in s3_storage_options {
90            builder = builder.with_config(key, value);
91        }
92        builder = builder
93            .with_url(base_path.as_ref())
94            .with_credentials(aws_creds)
95            .with_retry(retry_config)
96            .with_region(region);
97
98        #[cfg(feature = "metrics")]
99        {
100            builder = builder.with_http_connector(
101                crate::object_store::metrics::MeteringHttpConnector::new(store_prefix),
102            );
103        }
104
105        Ok(Arc::new(builder.build()?) as Arc<dyn OSObjectStore>)
106    }
107
108    async fn build_opendal_s3_store(
109        &self,
110        base_path: &Url,
111        storage_options: &StorageOptions,
112    ) -> Result<Arc<dyn OSObjectStore>> {
113        let bucket = base_path
114            .host_str()
115            .ok_or_else(|| Error::invalid_input("S3 URL must contain bucket name"))?
116            .to_string();
117
118        let prefix = base_path.path().trim_start_matches('/').to_string();
119
120        // Start with all storage options as the config map
121        // OpenDAL will handle environment variables through its default credentials chain
122        let mut config_map: HashMap<String, String> = storage_options.0.clone();
123
124        // Set required OpenDAL configuration
125        config_map.insert("bucket".to_string(), bucket);
126
127        if !prefix.is_empty() {
128            config_map.insert("root".to_string(), "/".to_string());
129        }
130
131        let operator = Operator::from_iter::<S3>(config_map)
132            .map_err(|e| Error::invalid_input(format!("Failed to create S3 operator: {:?}", e)))?
133            .finish();
134
135        Ok(Arc::new(OpendalStore::new(operator)) as Arc<dyn OSObjectStore>)
136    }
137}
138
139#[async_trait::async_trait]
140impl ObjectStoreProvider for AwsStoreProvider {
141    async fn new_store(
142        &self,
143        mut base_path: Url,
144        params: &ObjectStoreParams,
145    ) -> Result<ObjectStore> {
146        let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
147        let mut storage_options =
148            StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
149        storage_options.with_env_s3();
150        let download_retry_count = storage_options.download_retry_count();
151
152        let use_opendal = storage_options
153            .0
154            .get("use_opendal")
155            .map(|v| v == "true")
156            .unwrap_or(false);
157
158        // Determine S3 Express and constant size upload parts before building the store
159        let is_s3_express = check_s3_express(&base_path, &storage_options);
160
161        let use_constant_size_upload_parts = storage_options
162            .0
163            .get("aws_endpoint")
164            .map(|endpoint| endpoint.contains("r2.cloudflarestorage.com"))
165            .unwrap_or(false);
166
167        let inner = if use_opendal {
168            // Use OpenDAL implementation
169            self.build_opendal_s3_store(&base_path, &storage_options)
170                .await?
171        } else {
172            // Use default Amazon S3 implementation
173            self.build_amazon_s3_store(&mut base_path, params, &storage_options, is_s3_express)
174                .await?
175        };
176        let throttle_config = AimdThrottleConfig::from_storage_options(params.storage_options())?;
177        let inner = if throttle_config.is_disabled() {
178            inner
179        } else {
180            Arc::new(AimdThrottledStore::new(inner, throttle_config)?) as Arc<dyn OSObjectStore>
181        };
182
183        Ok(ObjectStore {
184            inner,
185            scheme: String::from(base_path.scheme()),
186            block_size,
187            max_iop_size: *DEFAULT_MAX_IOP_SIZE,
188            use_constant_size_upload_parts,
189            list_is_lexically_ordered: !is_s3_express,
190            io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
191            download_retry_count,
192            io_tracker: Default::default(),
193            store_prefix: self
194                .calculate_object_store_prefix(&base_path, params.storage_options())?,
195        })
196    }
197}
198
199/// Check if the storage is S3 Express
200fn check_s3_express(url: &Url, storage_options: &StorageOptions) -> bool {
201    storage_options
202        .0
203        .get("s3_express")
204        .map(|v| v == "true")
205        .unwrap_or(false)
206        || url.authority().ends_with("--x-s3")
207}
208
209/// Figure out the S3 region of the bucket.
210///
211/// This resolves in order of precedence:
212/// 1. The region provided in the storage options
213/// 2. (If endpoint is not set), the region returned by the S3 API for the bucket
214///
215/// It can return None if no region is provided and the endpoint is set.
216async fn resolve_s3_region(
217    url: &Url,
218    storage_options: &HashMap<AmazonS3ConfigKey, String>,
219) -> Result<Option<String>> {
220    if let Some(region) = storage_options.get(&AmazonS3ConfigKey::Region) {
221        Ok(Some(region.clone()))
222    } else if storage_options.get(&AmazonS3ConfigKey::Endpoint).is_none() {
223        // If no endpoint is set, we can assume this is AWS S3 and the region
224        // can be resolved from the bucket.
225        let bucket = url.host_str().ok_or_else(|| {
226            Error::invalid_input(format!("Could not parse bucket from url: {}", url))
227        })?;
228
229        let mut client_options = ClientOptions::default();
230        for (key, value) in storage_options {
231            if let AmazonS3ConfigKey::Client(client_key) = key {
232                client_options = client_options.with_config(*client_key, value.clone());
233            }
234        }
235
236        let bucket_region =
237            object_store::aws::resolve_bucket_region(bucket, &client_options).await?;
238        Ok(Some(bucket_region))
239    } else {
240        Ok(None)
241    }
242}
243
244/// Build AWS credentials
245///
246/// This resolves credentials from the following sources in order:
247/// 1. An explicit `storage_options_accessor` with a provider
248/// 2. An explicit `credentials` provider
249/// 3. Explicit credentials in storage_options (as in `aws_access_key_id`,
250///    `aws_secret_access_key`, `aws_session_token`)
251/// 4. The default credential provider chain from AWS SDK.
252///
253/// # Storage Options Accessor
254///
255/// When `storage_options_accessor` is provided and has a dynamic provider,
256/// credentials are fetched and cached by the accessor with automatic refresh
257/// before expiration.
258///
259/// `credentials_refresh_offset` is the amount of time before expiry to refresh credentials.
260pub async fn build_aws_credential(
261    credentials_refresh_offset: Duration,
262    credentials: Option<AwsCredentialProvider>,
263    storage_options: Option<&HashMap<AmazonS3ConfigKey, String>>,
264    region: Option<String>,
265    storage_options_accessor: Option<Arc<StorageOptionsAccessor>>,
266) -> Result<(AwsCredentialProvider, String)> {
267    use aws_config::meta::region::RegionProviderChain;
268    const DEFAULT_REGION: &str = "us-west-2";
269
270    let region = if let Some(region) = region {
271        region
272    } else {
273        RegionProviderChain::default_provider()
274            .or_else(DEFAULT_REGION)
275            .region()
276            .await
277            .map(|r| r.as_ref().to_string())
278            .unwrap_or(DEFAULT_REGION.to_string())
279    };
280
281    let storage_options_credentials = storage_options.and_then(extract_static_s3_credentials);
282
283    // Explicit aws_credentials takes precedence over dynamic credentials.
284    if credentials.is_none()
285        && let Some(dynamic_creds) = build_dynamic_credential_provider::<ObjectStoreAwsCredential>(
286            storage_options_accessor.clone(),
287        )
288        .await?
289    {
290        return Ok((dynamic_creds, region));
291    }
292
293    if storage_options_accessor
294        .as_ref()
295        .is_some_and(|a| a.has_provider())
296    {
297        log::debug!(
298            "Storage options from provider do not contain explicit AWS credentials, \
299             falling back to default AWS credentials chain."
300        );
301    }
302
303    // Fall back to existing logic for static credentials
304    if let Some(creds) = credentials {
305        Ok((creds, region))
306    } else if let Some(creds) = storage_options_credentials {
307        Ok((Arc::new(creds), region))
308    } else {
309        let credentials_provider = DefaultCredentialsChain::builder().build().await;
310
311        Ok((
312            Arc::new(AwsCredentialAdapter::new(
313                Arc::new(credentials_provider),
314                credentials_refresh_offset,
315            )),
316            region,
317        ))
318    }
319}
320
321fn extract_static_s3_credentials(
322    options: &HashMap<AmazonS3ConfigKey, String>,
323) -> Option<StaticCredentialProvider<ObjectStoreAwsCredential>> {
324    let key_id = options.get(&AmazonS3ConfigKey::AccessKeyId).cloned();
325    let secret_key = options.get(&AmazonS3ConfigKey::SecretAccessKey).cloned();
326    let token = options.get(&AmazonS3ConfigKey::Token).cloned();
327    match (key_id, secret_key, token) {
328        (Some(key_id), Some(secret_key), token) => {
329            Some(StaticCredentialProvider::new(ObjectStoreAwsCredential {
330                key_id,
331                secret_key,
332                token,
333            }))
334        }
335        _ => None,
336    }
337}
338
339/// Adapt an AWS SDK cred into object_store credentials
340#[derive(Debug)]
341pub struct AwsCredentialAdapter {
342    pub inner: Arc<dyn ProvideCredentials>,
343
344    // RefCell can't be shared across threads, so we use HashMap
345    cache: Arc<RwLock<HashMap<String, Arc<aws_credential_types::Credentials>>>>,
346
347    // The amount of time before expiry to refresh credentials
348    credentials_refresh_offset: Duration,
349}
350
351impl AwsCredentialAdapter {
352    pub fn new(
353        provider: Arc<dyn ProvideCredentials>,
354        credentials_refresh_offset: Duration,
355    ) -> Self {
356        Self {
357            inner: provider,
358            cache: Arc::new(RwLock::new(HashMap::new())),
359            credentials_refresh_offset,
360        }
361    }
362}
363
364const AWS_CREDS_CACHE_KEY: &str = "aws_credentials";
365
366/// Convert std::time::SystemTime from AWS SDK to our mockable SystemTime
367fn to_system_time(time: std::time::SystemTime) -> SystemTime {
368    let duration_since_epoch = time
369        .duration_since(std::time::UNIX_EPOCH)
370        .expect("time should be after UNIX_EPOCH");
371    UNIX_EPOCH + duration_since_epoch
372}
373
374#[async_trait::async_trait]
375impl CredentialProvider for AwsCredentialAdapter {
376    type Credential = ObjectStoreAwsCredential;
377
378    async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
379        let cached_creds = {
380            let cache_value = self.cache.read().await.get(AWS_CREDS_CACHE_KEY).cloned();
381            let expired = cache_value
382                .clone()
383                .map(|cred| {
384                    cred.expiry()
385                        .map(|exp| {
386                            to_system_time(exp)
387                                .checked_sub(self.credentials_refresh_offset)
388                                .expect("this time should always be valid")
389                                < SystemTime::now()
390                        })
391                        // no expiry is never expire
392                        .unwrap_or(false)
393                })
394                .unwrap_or(true); // no cred is the same as expired;
395            if expired { None } else { cache_value.clone() }
396        };
397
398        if let Some(creds) = cached_creds {
399            Ok(Arc::new(Self::Credential {
400                key_id: creds.access_key_id().to_string(),
401                secret_key: creds.secret_access_key().to_string(),
402                token: creds.session_token().map(|s| s.to_string()),
403            }))
404        } else {
405            let refreshed_creds =
406                Arc::new(self.inner.provide_credentials().await.map_err(|e| {
407                    Error::internal(format!("Failed to get AWS credentials: {:?}", e))
408                })?);
409
410            self.cache
411                .write()
412                .await
413                .insert(AWS_CREDS_CACHE_KEY.to_string(), refreshed_creds.clone());
414
415            Ok(Arc::new(Self::Credential {
416                key_id: refreshed_creds.access_key_id().to_string(),
417                secret_key: refreshed_creds.secret_access_key().to_string(),
418                token: refreshed_creds.session_token().map(|s| s.to_string()),
419            }))
420        }
421    }
422}
423
424impl StorageOptions {
425    /// Add values from the environment to storage options
426    pub fn with_env_s3(&mut self) {
427        for (os_key, os_value) in std::env::vars_os() {
428            if let (Some(key), Some(value)) = (os_key.to_str(), os_value.to_str())
429                && let Ok(config_key) = AmazonS3ConfigKey::from_str(&key.to_ascii_lowercase())
430                && !self.0.contains_key(config_key.as_ref())
431            {
432                self.0
433                    .insert(config_key.as_ref().to_string(), value.to_string());
434            }
435        }
436    }
437
438    /// Subset of options relevant for s3 storage
439    pub fn as_s3_options(&self) -> HashMap<AmazonS3ConfigKey, String> {
440        self.0
441            .iter()
442            .filter_map(|(key, value)| {
443                let s3_key = AmazonS3ConfigKey::from_str(&key.to_ascii_lowercase()).ok()?;
444                Some((s3_key, value.clone()))
445            })
446            .collect()
447    }
448}
449
450impl ObjectStoreParams {
451    /// Create a new instance of [`ObjectStoreParams`] based on the AWS credentials.
452    pub fn with_aws_credentials(
453        aws_credentials: Option<AwsCredentialProvider>,
454        region: Option<String>,
455    ) -> Self {
456        let storage_options_accessor = region.map(|region| {
457            let opts: HashMap<String, String> =
458                [("region".into(), region)].iter().cloned().collect();
459            Arc::new(StorageOptionsAccessor::with_static_options(opts))
460        });
461        Self {
462            aws_credentials,
463            storage_options_accessor,
464            ..Default::default()
465        }
466    }
467}
468
469pub type DynamicStorageOptionsCredentialProvider =
470    NamespaceCredentialsProvider<ObjectStoreAwsCredential>;
471
472#[cfg(test)]
473mod tests {
474    use crate::object_store::ObjectStoreRegistry;
475    use crate::object_store::StorageOptionsProvider;
476    use mock_instant::thread_local::MockClock;
477    use object_store::path::Path;
478    use std::sync::atomic::{AtomicBool, Ordering};
479
480    use super::*;
481
482    #[derive(Debug, Default)]
483    struct MockAwsCredentialsProvider {
484        called: AtomicBool,
485    }
486
487    #[async_trait::async_trait]
488    impl CredentialProvider for MockAwsCredentialsProvider {
489        type Credential = ObjectStoreAwsCredential;
490
491        async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
492            self.called.store(true, Ordering::Relaxed);
493            Ok(Arc::new(Self::Credential {
494                key_id: "".to_string(),
495                secret_key: "".to_string(),
496                token: None,
497            }))
498        }
499    }
500
501    #[tokio::test]
502    async fn test_injected_aws_creds_option_is_used() {
503        let mock_provider = Arc::new(MockAwsCredentialsProvider::default());
504        let registry = Arc::new(ObjectStoreRegistry::default());
505
506        let params = ObjectStoreParams {
507            aws_credentials: Some(mock_provider.clone() as AwsCredentialProvider),
508            ..ObjectStoreParams::default()
509        };
510
511        // Not called yet
512        assert!(!mock_provider.called.load(Ordering::Relaxed));
513
514        let (store, _) = ObjectStore::from_uri_and_params(registry, "s3://not-a-bucket", &params)
515            .await
516            .unwrap();
517
518        // fails, but we don't care
519        let _ = store
520            .open(&Path::parse("/").unwrap())
521            .await
522            .unwrap()
523            .get_range(0..1)
524            .await;
525
526        // Not called yet
527        assert!(mock_provider.called.load(Ordering::Relaxed));
528    }
529
530    #[test]
531    fn test_s3_path_parsing() {
532        let provider = AwsStoreProvider;
533
534        let cases = [
535            ("s3://bucket/path/to/file", "path/to/file"),
536            // for non ASCII string tests: the URL encodes them, extract_path must decode back
537            ("s3://bucket/测试path/to/file", "测试path/to/file"),
538            ("s3://bucket/path/&to/file", "path/&to/file"),
539            ("s3://bucket/path/=to/file", "path/=to/file"),
540            (
541                "s3+ddb://bucket/path/to/file?ddbTableName=test",
542                "path/to/file",
543            ),
544        ];
545
546        for (uri, expected_path) in cases {
547            let url = Url::parse(uri).unwrap();
548            let path = provider.extract_path(&url).unwrap();
549            // extract_path decodes url.path(), so the Path stores the raw (decoded)
550            // string. Path::parse keeps its input verbatim, matching that, whereas
551            // Path::from would percent-encode non-ASCII bytes and not match.
552            let expected_path = Path::parse(expected_path).unwrap();
553            assert_eq!(path, expected_path)
554        }
555    }
556
557    // Regression test for https://github.com/lance-format/lance/issues/6643
558    // extract_path must NOT double-encode paths that contain non-ASCII characters.
559    // url.path() returns a percent-encoded string; we must decode it back to raw
560    // UTF-8 before storing it in a Path, so the object store HTTP client can apply
561    // a single, correct percent-encoding when building the request URL.
562    #[test]
563    fn test_s3_non_ascii_path_no_double_encoding() {
564        let provider = AwsStoreProvider;
565
566        // "s3://bucket/中文路径" → url.path() == "/%E4%B8%AD%E6%96%87%E8%B7%AF%E5%BE%84".
567        // The buggy Path::parse(url.path()) stored "%E4%B8%AD..." verbatim; the S3
568        // client then percent-encodes the '%' again, yielding "%25E4%25B8%25AD...".
569        // With Path::from_url_path the Path stores the decoded UTF-8 instead.
570        let url = Url::parse("s3://bucket/中文路径").unwrap();
571        let path = provider.extract_path(&url).unwrap();
572
573        // The Path must hold the decoded UTF-8, not the percent-encoded form.
574        assert_eq!(path.as_ref(), "中文路径");
575    }
576
577    #[test]
578    fn test_is_s3_express() {
579        let cases = [
580            (
581                "s3://bucket/path/to/file",
582                HashMap::from([("s3_express".to_string(), "true".to_string())]),
583                true,
584            ),
585            (
586                "s3://bucket/path/to/file",
587                HashMap::from([("s3_express".to_string(), "false".to_string())]),
588                false,
589            ),
590            ("s3://bucket/path/to/file", HashMap::from([]), false),
591            (
592                "s3://bucket--x-s3/path/to/file",
593                HashMap::from([("s3_express".to_string(), "true".to_string())]),
594                true,
595            ),
596            (
597                "s3://bucket--x-s3/path/to/file",
598                HashMap::from([("s3_express".to_string(), "false".to_string())]),
599                true, // URL takes precedence
600            ),
601            ("s3://bucket--x-s3/path/to/file", HashMap::from([]), true),
602        ];
603
604        for (uri, storage_map, expected) in cases {
605            let url = Url::parse(uri).unwrap();
606            let storage_options = StorageOptions(storage_map);
607            let is_s3_express = check_s3_express(&url, &storage_options);
608            assert_eq!(is_s3_express, expected);
609        }
610    }
611
612    #[tokio::test]
613    async fn test_use_opendal_flag() {
614        use crate::object_store::StorageOptionsAccessor;
615        let provider = AwsStoreProvider;
616        let url = Url::parse("s3://test-bucket/path").unwrap();
617        let params_with_flag = ObjectStoreParams {
618            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
619                HashMap::from([
620                    ("use_opendal".to_string(), "true".to_string()),
621                    ("region".to_string(), "us-west-2".to_string()),
622                ]),
623            ))),
624            ..Default::default()
625        };
626
627        let store = provider
628            .new_store(url.clone(), &params_with_flag)
629            .await
630            .unwrap();
631        assert_eq!(store.scheme, "s3");
632    }
633
634    #[derive(Debug)]
635    struct MockStorageOptionsProvider {
636        call_count: Arc<RwLock<usize>>,
637        expires_in_millis: Option<u64>,
638    }
639
640    impl MockStorageOptionsProvider {
641        fn new(expires_in_millis: Option<u64>) -> Self {
642            Self {
643                call_count: Arc::new(RwLock::new(0)),
644                expires_in_millis,
645            }
646        }
647
648        async fn get_call_count(&self) -> usize {
649            *self.call_count.read().await
650        }
651    }
652
653    #[async_trait::async_trait]
654    impl StorageOptionsProvider for MockStorageOptionsProvider {
655        async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
656            let count = {
657                let mut c = self.call_count.write().await;
658                *c += 1;
659                *c
660            };
661
662            let mut options = HashMap::from([
663                ("aws_access_key_id".to_string(), format!("AKID_{}", count)),
664                (
665                    "aws_secret_access_key".to_string(),
666                    format!("SECRET_{}", count),
667                ),
668                ("aws_session_token".to_string(), format!("TOKEN_{}", count)),
669            ]);
670
671            if let Some(expires_in) = self.expires_in_millis {
672                let now_ms = SystemTime::now()
673                    .duration_since(UNIX_EPOCH)
674                    .unwrap()
675                    .as_millis() as u64;
676                let expires_at = now_ms + expires_in;
677                options.insert("expires_at_millis".to_string(), expires_at.to_string());
678            }
679
680            Ok(Some(options))
681        }
682
683        fn provider_id(&self) -> String {
684            let ptr = Arc::as_ptr(&self.call_count) as usize;
685            format!("MockStorageOptionsProvider {{ id: {} }}", ptr)
686        }
687    }
688
689    #[tokio::test]
690    async fn test_dynamic_credential_provider_with_initial_cache() {
691        MockClock::set_system_time(Duration::from_secs(100_000));
692
693        let now_ms = MockClock::system_time().as_millis() as u64;
694
695        // Create a mock provider that returns credentials expiring in 10 minutes
696        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
697            600_000, // Expires in 10 minutes
698        )));
699
700        // Create initial options with cached credentials that expire in 10 minutes
701        let expires_at = now_ms + 600_000; // 10 minutes from now
702        let initial_options = HashMap::from([
703            ("aws_access_key_id".to_string(), "AKID_CACHED".to_string()),
704            (
705                "aws_secret_access_key".to_string(),
706                "SECRET_CACHED".to_string(),
707            ),
708            ("aws_session_token".to_string(), "TOKEN_CACHED".to_string()),
709            ("expires_at_millis".to_string(), expires_at.to_string()),
710            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
711        ]);
712
713        let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
714            mock.clone(),
715            initial_options,
716        );
717
718        // First call should use cached credentials (not expired yet)
719        let cred = provider.get_credential().await.unwrap();
720        assert_eq!(cred.key_id, "AKID_CACHED");
721        assert_eq!(cred.secret_key, "SECRET_CACHED");
722        assert_eq!(cred.token, Some("TOKEN_CACHED".to_string()));
723
724        // Should not have called the provider yet
725        assert_eq!(mock.get_call_count().await, 0);
726    }
727
728    #[tokio::test]
729    async fn test_dynamic_credential_provider_with_expired_cache() {
730        MockClock::set_system_time(Duration::from_secs(100_000));
731
732        let now_ms = MockClock::system_time().as_millis() as u64;
733
734        // Create a mock provider that returns credentials expiring in 10 minutes
735        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
736            600_000, // Expires in 10 minutes
737        )));
738
739        // Create initial options with credentials that expired 1 second ago
740        let expired_time = now_ms - 1_000; // 1 second ago
741        let initial_options = HashMap::from([
742            ("aws_access_key_id".to_string(), "AKID_EXPIRED".to_string()),
743            (
744                "aws_secret_access_key".to_string(),
745                "SECRET_EXPIRED".to_string(),
746            ),
747            ("expires_at_millis".to_string(), expired_time.to_string()),
748            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
749        ]);
750
751        let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
752            mock.clone(),
753            initial_options,
754        );
755
756        // First call should fetch new credentials because cached ones are expired
757        let cred = provider.get_credential().await.unwrap();
758        assert_eq!(cred.key_id, "AKID_1");
759        assert_eq!(cred.secret_key, "SECRET_1");
760        assert_eq!(cred.token, Some("TOKEN_1".to_string()));
761
762        // Should have called the provider once
763        assert_eq!(mock.get_call_count().await, 1);
764    }
765
766    #[tokio::test]
767    async fn test_dynamic_credential_provider_refresh_lead_time() {
768        MockClock::set_system_time(Duration::from_secs(100_000));
769
770        // Create a mock provider that returns credentials expiring in 30 seconds
771        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
772            30_000, // Expires in 30 seconds
773        )));
774
775        // Create credential provider with default 60 second refresh offset
776        // This means credentials should be refreshed when they have less than 60 seconds left
777        let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
778
779        // First call should fetch credentials from provider (no initial cache)
780        // Credentials expire in 30 seconds, which is less than our 60 second refresh offset,
781        // so they should be considered "needs refresh" immediately
782        let cred = provider.get_credential().await.unwrap();
783        assert_eq!(cred.key_id, "AKID_1");
784        assert_eq!(mock.get_call_count().await, 1);
785
786        // Second call should trigger refresh because credentials expire in 30 seconds
787        // but our refresh lead time is 60 seconds (now + 60sec > expires_at)
788        // The mock will return new credentials (AKID_2) with the same expiration
789        let cred = provider.get_credential().await.unwrap();
790        assert_eq!(cred.key_id, "AKID_2");
791        assert_eq!(mock.get_call_count().await, 2);
792    }
793
794    #[tokio::test]
795    async fn test_dynamic_credential_provider_no_initial_cache() {
796        MockClock::set_system_time(Duration::from_secs(100_000));
797
798        // Create a mock provider that returns credentials expiring in 2 minutes
799        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
800            120_000, // Expires in 2 minutes
801        )));
802
803        // Create credential provider without initial cache, using default 60 second refresh offset
804        let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
805
806        // First call should fetch from provider (call count = 1)
807        let cred = provider.get_credential().await.unwrap();
808        assert_eq!(cred.key_id, "AKID_1");
809        assert_eq!(cred.secret_key, "SECRET_1");
810        assert_eq!(cred.token, Some("TOKEN_1".to_string()));
811        assert_eq!(mock.get_call_count().await, 1);
812
813        // Second call should use cached credentials (not expired yet, still > 60 seconds remaining)
814        let cred = provider.get_credential().await.unwrap();
815        assert_eq!(cred.key_id, "AKID_1");
816        assert_eq!(mock.get_call_count().await, 1); // Still 1, didn't fetch again
817
818        // Advance time to 90 seconds - should trigger refresh (within 60 sec refresh offset)
819        // At this point, credentials expire in 30 seconds (< 60 sec offset)
820        MockClock::set_system_time(Duration::from_secs(100_000 + 90));
821        let cred = provider.get_credential().await.unwrap();
822        assert_eq!(cred.key_id, "AKID_2");
823        assert_eq!(cred.secret_key, "SECRET_2");
824        assert_eq!(cred.token, Some("TOKEN_2".to_string()));
825        assert_eq!(mock.get_call_count().await, 2);
826
827        // Advance time to 210 seconds total (90 + 120) - should trigger another refresh
828        MockClock::set_system_time(Duration::from_secs(100_000 + 210));
829        let cred = provider.get_credential().await.unwrap();
830        assert_eq!(cred.key_id, "AKID_3");
831        assert_eq!(cred.secret_key, "SECRET_3");
832        assert_eq!(mock.get_call_count().await, 3);
833    }
834
835    #[tokio::test]
836    async fn test_dynamic_credential_provider_with_initial_options() {
837        MockClock::set_system_time(Duration::from_secs(100_000));
838
839        let now_ms = MockClock::system_time().as_millis() as u64;
840
841        // Create a mock provider that returns credentials expiring in 10 minutes
842        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
843            600_000, // Expires in 10 minutes
844        )));
845
846        // Create initial options with expiration in 10 minutes
847        let expires_at = now_ms + 600_000; // 10 minutes from now
848        let initial_options = HashMap::from([
849            ("aws_access_key_id".to_string(), "AKID_INITIAL".to_string()),
850            (
851                "aws_secret_access_key".to_string(),
852                "SECRET_INITIAL".to_string(),
853            ),
854            ("aws_session_token".to_string(), "TOKEN_INITIAL".to_string()),
855            ("expires_at_millis".to_string(), expires_at.to_string()),
856            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
857        ]);
858
859        // Create credential provider with initial options
860        let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
861            mock.clone(),
862            initial_options,
863        );
864
865        // First call should use the initial credential (not expired yet)
866        let cred = provider.get_credential().await.unwrap();
867        assert_eq!(cred.key_id, "AKID_INITIAL");
868        assert_eq!(cred.secret_key, "SECRET_INITIAL");
869        assert_eq!(cred.token, Some("TOKEN_INITIAL".to_string()));
870
871        // Should not have called the provider yet
872        assert_eq!(mock.get_call_count().await, 0);
873
874        // Advance time to 6 minutes - this should trigger a refresh
875        // (5 minute refresh offset means we refresh 5 minutes before expiration)
876        MockClock::set_system_time(Duration::from_secs(100_000 + 360));
877        let cred = provider.get_credential().await.unwrap();
878        assert_eq!(cred.key_id, "AKID_1");
879        assert_eq!(cred.secret_key, "SECRET_1");
880        assert_eq!(cred.token, Some("TOKEN_1".to_string()));
881
882        // Should have called the provider once
883        assert_eq!(mock.get_call_count().await, 1);
884
885        // Advance time to 11 minutes total - this should trigger another refresh
886        MockClock::set_system_time(Duration::from_secs(100_000 + 660));
887        let cred = provider.get_credential().await.unwrap();
888        assert_eq!(cred.key_id, "AKID_2");
889        assert_eq!(cred.secret_key, "SECRET_2");
890        assert_eq!(cred.token, Some("TOKEN_2".to_string()));
891
892        // Should have called the provider twice
893        assert_eq!(mock.get_call_count().await, 2);
894
895        // Advance time to 16 minutes total - this should trigger yet another refresh
896        MockClock::set_system_time(Duration::from_secs(100_000 + 960));
897        let cred = provider.get_credential().await.unwrap();
898        assert_eq!(cred.key_id, "AKID_3");
899        assert_eq!(cred.secret_key, "SECRET_3");
900        assert_eq!(cred.token, Some("TOKEN_3".to_string()));
901
902        // Should have called the provider three times
903        assert_eq!(mock.get_call_count().await, 3);
904    }
905
906    #[tokio::test]
907    async fn test_dynamic_credential_provider_concurrent_access() {
908        // Create a mock provider with far future expiration
909        let mock = Arc::new(MockStorageOptionsProvider::new(Some(9999999999999)));
910
911        let provider = Arc::new(DynamicStorageOptionsCredentialProvider::from_provider(
912            mock.clone(),
913        ));
914
915        // Spawn 10 concurrent tasks that all try to get credentials at the same time
916        let mut handles = vec![];
917        for i in 0..10 {
918            let provider = provider.clone();
919            let handle = tokio::spawn(async move {
920                let cred = provider.get_credential().await.unwrap();
921                // Verify we got the correct credentials (should all be AKID_1 from first fetch)
922                assert_eq!(cred.key_id, "AKID_1");
923                assert_eq!(cred.secret_key, "SECRET_1");
924                assert_eq!(cred.token, Some("TOKEN_1".to_string()));
925                i // Return task number for verification
926            });
927            handles.push(handle);
928        }
929
930        // Wait for all tasks to complete
931        let results: Vec<_> = futures::future::join_all(handles)
932            .await
933            .into_iter()
934            .map(|r| r.unwrap())
935            .collect();
936
937        // Verify all 10 tasks completed successfully
938        assert_eq!(results.len(), 10);
939        for i in 0..10 {
940            assert!(results.contains(&i));
941        }
942
943        // The provider should have been called exactly once (first request triggers fetch,
944        // subsequent requests use cache)
945        let call_count = mock.get_call_count().await;
946        assert_eq!(
947            call_count, 1,
948            "Provider should be called exactly once despite concurrent access"
949        );
950    }
951
952    #[tokio::test]
953    async fn test_dynamic_credential_provider_concurrent_refresh() {
954        MockClock::set_system_time(Duration::from_secs(100_000));
955
956        let now_ms = MockClock::system_time().as_millis() as u64;
957
958        // Create initial options with credentials that expired in the past (1000 seconds ago)
959        let expires_at = now_ms - 1_000_000;
960        let initial_options = HashMap::from([
961            ("aws_access_key_id".to_string(), "AKID_OLD".to_string()),
962            (
963                "aws_secret_access_key".to_string(),
964                "SECRET_OLD".to_string(),
965            ),
966            ("aws_session_token".to_string(), "TOKEN_OLD".to_string()),
967            ("expires_at_millis".to_string(), expires_at.to_string()),
968            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
969        ]);
970
971        // Mock will return credentials expiring in 1 hour
972        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
973            3_600_000, // Expires in 1 hour
974        )));
975
976        let provider = Arc::new(
977            DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
978                mock.clone(),
979                initial_options,
980            ),
981        );
982
983        // Spawn 20 concurrent tasks that all try to get credentials at the same time
984        // Since the initial credential is expired, they'll all try to refresh
985        let mut handles = vec![];
986        for i in 0..20 {
987            let provider = provider.clone();
988            let handle = tokio::spawn(async move {
989                let cred = provider.get_credential().await.unwrap();
990                // All should get the new credentials (AKID_1 from first fetch)
991                assert_eq!(cred.key_id, "AKID_1");
992                assert_eq!(cred.secret_key, "SECRET_1");
993                assert_eq!(cred.token, Some("TOKEN_1".to_string()));
994                i
995            });
996            handles.push(handle);
997        }
998
999        // Wait for all tasks to complete
1000        let results: Vec<_> = futures::future::join_all(handles)
1001            .await
1002            .into_iter()
1003            .map(|r| r.unwrap())
1004            .collect();
1005
1006        // Verify all 20 tasks completed successfully
1007        assert_eq!(results.len(), 20);
1008
1009        // The provider should have been called at least once, but possibly more times
1010        // due to the try_write mechanism and race conditions
1011        let call_count = mock.get_call_count().await;
1012        assert!(
1013            call_count >= 1,
1014            "Provider should be called at least once, was called {} times",
1015            call_count
1016        );
1017
1018        // It shouldn't be called 20 times though - the lock should prevent most concurrent fetches
1019        assert!(
1020            call_count < 10,
1021            "Provider should not be called too many times due to lock contention, was called {} times",
1022            call_count
1023        );
1024    }
1025
1026    #[tokio::test]
1027    async fn test_explicit_aws_credentials_takes_precedence_over_accessor() {
1028        // Create a mock storage options provider that should NOT be called
1029        let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1030
1031        // Create an accessor with the mock provider
1032        let accessor = Arc::new(StorageOptionsAccessor::with_provider(
1033            mock_storage_provider.clone(),
1034        ));
1035
1036        // Create an explicit AWS credentials provider
1037        let explicit_cred_provider = Arc::new(MockAwsCredentialsProvider::default());
1038
1039        // Build credentials with both aws_credentials AND accessor
1040        // The explicit aws_credentials should take precedence
1041        let (result, _region) = build_aws_credential(
1042            Duration::from_secs(300),
1043            Some(explicit_cred_provider.clone() as AwsCredentialProvider),
1044            None, // no storage_options
1045            Some("us-west-2".to_string()),
1046            Some(accessor),
1047        )
1048        .await
1049        .unwrap();
1050
1051        // Get credential from the result
1052        let cred = result.get_credential().await.unwrap();
1053
1054        // The explicit provider should have been called (it returns empty strings)
1055        assert!(explicit_cred_provider.called.load(Ordering::Relaxed));
1056
1057        // The storage options provider should NOT have been called
1058        assert_eq!(
1059            mock_storage_provider.get_call_count().await,
1060            0,
1061            "Storage options provider should not be called when explicit aws_credentials is provided"
1062        );
1063
1064        // Verify we got credentials from the explicit provider (empty strings)
1065        assert_eq!(cred.key_id, "");
1066        assert_eq!(cred.secret_key, "");
1067    }
1068
1069    #[tokio::test]
1070    async fn test_accessor_used_when_no_explicit_aws_credentials() {
1071        MockClock::set_system_time(Duration::from_secs(100_000));
1072
1073        let now_ms = MockClock::system_time().as_millis() as u64;
1074
1075        // Create a mock storage options provider
1076        let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1077
1078        // Create initial options
1079        let expires_at = now_ms + 600_000; // 10 minutes from now
1080        let initial_options = HashMap::from([
1081            (
1082                "aws_access_key_id".to_string(),
1083                "AKID_FROM_ACCESSOR".to_string(),
1084            ),
1085            (
1086                "aws_secret_access_key".to_string(),
1087                "SECRET_FROM_ACCESSOR".to_string(),
1088            ),
1089            (
1090                "aws_session_token".to_string(),
1091                "TOKEN_FROM_ACCESSOR".to_string(),
1092            ),
1093            ("expires_at_millis".to_string(), expires_at.to_string()),
1094            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1095        ]);
1096
1097        // Create an accessor with initial options and provider
1098        let accessor = Arc::new(StorageOptionsAccessor::with_initial_and_provider(
1099            initial_options,
1100            mock_storage_provider.clone(),
1101        ));
1102
1103        // Build credentials with accessor but NO explicit aws_credentials
1104        let (result, _region) = build_aws_credential(
1105            Duration::from_secs(300),
1106            None, // no explicit aws_credentials
1107            None, // no storage_options
1108            Some("us-west-2".to_string()),
1109            Some(accessor),
1110        )
1111        .await
1112        .unwrap();
1113
1114        // Get credential - should use the initial accessor credentials
1115        let cred = result.get_credential().await.unwrap();
1116        assert_eq!(cred.key_id, "AKID_FROM_ACCESSOR");
1117        assert_eq!(cred.secret_key, "SECRET_FROM_ACCESSOR");
1118
1119        // Storage options provider should NOT have been called yet (using cached initial creds)
1120        assert_eq!(mock_storage_provider.get_call_count().await, 0);
1121
1122        // Advance time to trigger refresh (past the 5 minute refresh offset)
1123        MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1124
1125        // Get credential again - should now fetch from provider
1126        let cred = result.get_credential().await.unwrap();
1127        assert_eq!(cred.key_id, "AKID_1");
1128        assert_eq!(cred.secret_key, "SECRET_1");
1129
1130        // Storage options provider should have been called once
1131        assert_eq!(mock_storage_provider.get_call_count().await, 1);
1132    }
1133}