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 opendal::{Operator, services::S3};
14
15use aws_config::default_provider::credentials::DefaultCredentialsChain;
16use aws_config::ecs::EcsCredentialsProvider;
17use aws_config::provider_config::ProviderConfig;
18use aws_config::web_identity_token::WebIdentityTokenCredentialsProvider;
19use aws_config::{BehaviorVersion, Region, SdkConfig};
20use aws_credential_types::provider::ProvideCredentials;
21use object_store::{
22    ClientOptions, CredentialProvider, Result as ObjectStoreResult, RetryConfig,
23    StaticCredentialProvider,
24    aws::{
25        AmazonS3Builder, AmazonS3ConfigKey, AwsCredential as ObjectStoreAwsCredential,
26        AwsCredentialProvider,
27    },
28};
29use tokio::sync::RwLock;
30use url::Url;
31
32use crate::object_store::opendal_store::OpendalStore;
33use crate::object_store::{
34    DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
35    ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
36    dynamic_credentials::{NamespaceCredentialsProvider, build_dynamic_credential_provider},
37    throttle::{AimdThrottleConfig, AimdThrottleState, AimdThrottledStore, cloud_http_connector},
38};
39use lance_core::error::{Error, Result};
40
41#[derive(Default, Debug)]
42pub struct AwsStoreProvider;
43
44struct ResolvedS3StorageOptions {
45    options: HashMap<AmazonS3ConfigKey, String>,
46    profile_region: Option<String>,
47}
48
49impl ResolvedS3StorageOptions {
50    fn new(
51        mut options: HashMap<AmazonS3ConfigKey, String>,
52        profile_config: Option<&SdkConfig>,
53    ) -> Self {
54        if effective_s3_endpoint(&options).is_none()
55            && let Some(endpoint) = profile_config.and_then(SdkConfig::endpoint_url)
56        {
57            options.insert(AmazonS3ConfigKey::Endpoint, endpoint.to_string());
58        }
59        let profile_region = profile_config
60            .and_then(SdkConfig::region)
61            .map(|region| region.as_ref().to_string());
62        Self {
63            options,
64            profile_region,
65        }
66    }
67
68    fn effective_endpoint(&self) -> Option<&str> {
69        effective_s3_endpoint(&self.options)
70    }
71
72    fn requires_constant_size_upload_parts(&self) -> bool {
73        self.effective_endpoint()
74            .is_some_and(|endpoint| endpoint.contains("r2.cloudflarestorage.com"))
75    }
76}
77
78impl AwsStoreProvider {
79    async fn build_amazon_s3_store(
80        &self,
81        base_path: &mut Url,
82        params: &ObjectStoreParams,
83        storage_options: &StorageOptions,
84        mut resolved_s3_options: ResolvedS3StorageOptions,
85        is_s3_express: bool,
86        throttle_state: Option<&AimdThrottleState>,
87    ) -> Result<Arc<dyn OSObjectStore>> {
88        // Use a low retry count since the AIMD throttle layer handles
89        // throttle recovery with its own retry loop.
90        let retry_config = RetryConfig {
91            backoff: Default::default(),
92            max_retries: storage_options.client_max_retries(),
93            retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()),
94        };
95
96        let region = resolve_s3_region(base_path, &resolved_s3_options).await?;
97
98        // Get accessor from params
99        let accessor = params.get_accessor();
100
101        let provider_scheme = storage_options.aws_provider_scheme()?;
102
103        let (aws_creds, region) = build_aws_credential(
104            params.s3_credentials_refresh_offset,
105            params.aws_credentials.clone(),
106            Some(&resolved_s3_options.options),
107            region,
108            accessor,
109            provider_scheme,
110        )
111        .await?;
112
113        // Set S3Express flag if detected
114        if is_s3_express {
115            resolved_s3_options
116                .options
117                .insert(AmazonS3ConfigKey::S3Express, true.to_string());
118        }
119
120        // Compute the metrics label before rewriting the URL below so it
121        // matches the prefix the registry uses to key this store.
122        let store_prefix =
123            self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
124
125        // before creating the OSObjectStore we need to rewrite the url to drop ddb related parts
126        base_path.set_scheme("s3").unwrap();
127        base_path.set_query(None);
128
129        // we can't use parse_url_opts here because we need to manually set the credentials provider
130        let mut builder =
131            AmazonS3Builder::new().with_client_options(storage_options.client_options()?);
132        for (key, value) in resolved_s3_options.options {
133            builder = builder.with_config(key, value);
134        }
135        builder = builder
136            .with_url(base_path.as_ref())
137            .with_credentials(aws_creds)
138            .with_retry(retry_config)
139            .with_region(region);
140
141        builder = builder.with_http_connector(cloud_http_connector(throttle_state, store_prefix));
142
143        Ok(Arc::new(builder.build()?) as Arc<dyn OSObjectStore>)
144    }
145
146    async fn build_opendal_s3_store(
147        &self,
148        base_path: &Url,
149        storage_options: &StorageOptions,
150    ) -> Result<Arc<dyn OSObjectStore>> {
151        let bucket = base_path
152            .host_str()
153            .ok_or_else(|| Error::invalid_input("S3 URL must contain bucket name"))?
154            .to_string();
155
156        let prefix = base_path.path().trim_start_matches('/').to_string();
157
158        // Start with all storage options as the config map
159        // OpenDAL will handle environment variables through its default credentials chain
160        let mut config_map: HashMap<String, String> = storage_options.0.clone();
161
162        if let Some(provider_scheme) = storage_options.aws_provider_scheme()? {
163            return Result::Err(Error::not_supported(format!(
164                "OpendalStore does not currently support an explicit provider_scheme (currently set to {:?})",
165                provider_scheme
166            )));
167        }
168
169        // Set required OpenDAL configuration
170        config_map.insert("bucket".to_string(), bucket);
171
172        if !prefix.is_empty() {
173            config_map.insert("root".to_string(), "/".to_string());
174        }
175
176        let operator = Operator::from_iter::<S3>(config_map)
177            .map_err(|e| Error::invalid_input(format!("Failed to create S3 operator: {:?}", e)))?;
178
179        Ok(Arc::new(OpendalStore::new(operator)) as Arc<dyn OSObjectStore>)
180    }
181}
182
183#[async_trait::async_trait]
184impl ObjectStoreProvider for AwsStoreProvider {
185    async fn new_store(
186        &self,
187        mut base_path: Url,
188        params: &ObjectStoreParams,
189    ) -> Result<ObjectStore> {
190        let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
191        let mut storage_options =
192            StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
193        storage_options.with_env_s3();
194        let download_retry_count = storage_options.download_retry_count();
195
196        let use_opendal = storage_options
197            .0
198            .get("use_opendal")
199            .map(|v| v == "true")
200            .unwrap_or(false);
201
202        let profile_config = if std::env::var_os("AWS_PROFILE").is_some() {
203            Some(aws_config::load_defaults(BehaviorVersion::latest()).await)
204        } else {
205            None
206        };
207        let resolved_s3_options =
208            ResolvedS3StorageOptions::new(storage_options.as_s3_options(), profile_config.as_ref());
209
210        // Determine S3 Express and constant size upload parts before building the store
211        let is_s3_express = check_s3_express(&base_path, &storage_options);
212
213        let use_constant_size_upload_parts =
214            resolved_s3_options.requires_constant_size_upload_parts();
215
216        let throttle_config = AimdThrottleConfig::from_storage_options(params.storage_options())?;
217        let throttle_state = if throttle_config.is_disabled() {
218            None
219        } else {
220            Some(AimdThrottleState::new(throttle_config)?)
221        };
222
223        let inner = if use_opendal {
224            // Use OpenDAL implementation
225            self.build_opendal_s3_store(&base_path, &storage_options)
226                .await?
227        } else {
228            // Use default Amazon S3 implementation
229            self.build_amazon_s3_store(
230                &mut base_path,
231                params,
232                &storage_options,
233                resolved_s3_options,
234                is_s3_express,
235                throttle_state.as_ref(),
236            )
237            .await?
238        };
239        let inner = if let Some(throttle_state) = throttle_state {
240            Arc::new(AimdThrottledStore::new_with_state(
241                inner,
242                throttle_state,
243                !use_opendal,
244            )) as Arc<dyn OSObjectStore>
245        } else {
246            inner
247        };
248
249        Ok(ObjectStore {
250            inner,
251            local_dir_operations: None,
252            scheme: String::from(base_path.scheme()),
253            block_size,
254            max_iop_size: *DEFAULT_MAX_IOP_SIZE,
255            use_constant_size_upload_parts,
256            list_is_lexically_ordered: !is_s3_express,
257            io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
258            download_retry_count,
259            io_tracker: Default::default(),
260            store_prefix: self
261                .calculate_object_store_prefix(&base_path, params.storage_options())?,
262        })
263    }
264}
265
266/// Check if the storage is S3 Express
267fn check_s3_express(url: &Url, storage_options: &StorageOptions) -> bool {
268    storage_options
269        .0
270        .get("s3_express")
271        .map(|v| v == "true")
272        .unwrap_or(false)
273        || url.authority().ends_with("--x-s3")
274}
275
276fn effective_s3_endpoint(storage_options: &HashMap<AmazonS3ConfigKey, String>) -> Option<&str> {
277    storage_options
278        .get(&AmazonS3ConfigKey::S3Endpoint)
279        .or_else(|| storage_options.get(&AmazonS3ConfigKey::Endpoint))
280        .map(String::as_str)
281}
282
283/// Figure out the S3 region of the bucket.
284///
285/// This resolves in order of precedence:
286/// 1. The region provided in the storage options
287/// 2. The selected AWS profile's region when a custom endpoint is configured
288/// 3. (If endpoint is not set), the region returned by the S3 API for the bucket
289///
290/// It can return None if no region is provided and the endpoint is set.
291async fn resolve_s3_region(
292    url: &Url,
293    resolved_s3_options: &ResolvedS3StorageOptions,
294) -> Result<Option<String>> {
295    let storage_options = &resolved_s3_options.options;
296    if let Some(region) = storage_options.get(&AmazonS3ConfigKey::Region) {
297        Ok(Some(region.clone()))
298    } else if resolved_s3_options.effective_endpoint().is_none() {
299        // If no endpoint is set, we can assume this is AWS S3 and the region
300        // can be resolved from the bucket.
301        let bucket = url.host_str().ok_or_else(|| {
302            Error::invalid_input(format!("Could not parse bucket from url: {}", url))
303        })?;
304
305        let mut client_options = ClientOptions::default();
306        for (key, value) in storage_options {
307            if let AmazonS3ConfigKey::Client(client_key) = key {
308                client_options = client_options.with_config(*client_key, value.clone());
309            }
310        }
311
312        let bucket_region =
313            object_store::aws::resolve_bucket_region(bucket, &client_options).await?;
314        Ok(Some(bucket_region))
315    } else {
316        Ok(resolved_s3_options.profile_region.clone())
317    }
318}
319
320/// Selects which AWS credential provider to use for a dataset.
321///
322/// When set, overrides automatic credential resolution for everything except an
323/// explicitly-supplied `credentials` provider or `storage_options_accessor`.
324#[derive(Debug, Clone, PartialEq)]
325pub enum AwsProviderScheme {
326    /// Require static access-key credentials (`aws_access_key_id` +
327    /// `aws_secret_access_key`). Returns an error if they are absent.
328    Token,
329    /// Use the ECS/Pod Identity container credential endpoint.
330    /// The endpoint URI is read from the `AWS_CONTAINER_CREDENTIALS_FULL_URI`
331    /// or `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` environment variables.
332    Ecs,
333    /// Use IRSA (IAM Roles for Service Accounts) web identity token credentials.
334    /// The token file and role ARN are read from the `AWS_WEB_IDENTITY_TOKEN_FILE`
335    /// and `AWS_ROLE_ARN` environment variables.
336    Irsa,
337}
338
339/// Build AWS credentials
340///
341/// This resolves credentials from the following sources in order:
342/// 1. An explicit `credentials` provider
343/// 2. An explicit `storage_options_accessor` with a provider
344/// 3. If `provider_scheme` is set:
345///    - [`AwsProviderScheme::Token`]: static access-key credentials (error if absent)
346///    - [`AwsProviderScheme::Ecs`]: ECS container credential provider
347///    - [`AwsProviderScheme::Irsa`]: web identity token (IRSA) provider
348/// 4. Static access-key credentials from `storage_options`, if present
349/// 5. The default AWS credential provider chain
350///
351/// # Storage Options Accessor
352///
353/// When `storage_options_accessor` is provided and has a dynamic provider,
354/// credentials are fetched and cached by the accessor with automatic refresh
355/// before expiration.
356///
357/// `credentials_refresh_offset` is the amount of time before expiry to refresh credentials.
358pub async fn build_aws_credential(
359    credentials_refresh_offset: Duration,
360    credentials: Option<AwsCredentialProvider>,
361    storage_options: Option<&HashMap<AmazonS3ConfigKey, String>>,
362    region: Option<String>,
363    storage_options_accessor: Option<Arc<StorageOptionsAccessor>>,
364    provider_scheme: Option<AwsProviderScheme>,
365) -> Result<(AwsCredentialProvider, String)> {
366    use aws_config::meta::region::RegionProviderChain;
367    const DEFAULT_REGION: &str = "us-west-2";
368
369    let region = if let Some(region) = region {
370        region
371    } else {
372        RegionProviderChain::default_provider()
373            .or_else(DEFAULT_REGION)
374            .region()
375            .await
376            .map(|r| r.as_ref().to_string())
377            .unwrap_or(DEFAULT_REGION.to_string())
378    };
379
380    // If the user supplied their own credential provider that takes top priority
381    if let Some(creds) = credentials {
382        return Ok((creds, region));
383    }
384
385    // Otherwise, if the user provided a storage_options_accessor, try and use that
386    if let Some(dynamic_creds) = build_dynamic_credential_provider::<ObjectStoreAwsCredential>(
387        storage_options_accessor.clone(),
388    )
389    .await?
390    {
391        return Ok((dynamic_creds, region));
392    }
393
394    // If the user provided a storage_options_accessor, then it must not have matched AWS.
395    // Log a message and ignore it.
396    if storage_options_accessor
397        .as_ref()
398        .is_some_and(|a| a.has_provider())
399    {
400        log::debug!(
401            "Storage options from provider do not contain explicit AWS credentials, \
402             falling back to default AWS credentials chain."
403        );
404    }
405
406    // If the caller specified an explicit provider scheme, use only that provider.
407    if let Some(scheme) = provider_scheme {
408        return match scheme {
409            AwsProviderScheme::Token => {
410                let creds = storage_options
411                    .and_then(extract_static_s3_credentials)
412                    .ok_or_else(|| {
413                        Error::invalid_input(
414                            "aws_provider_scheme=token requires aws_access_key_id \
415                             and aws_secret_access_key to be set",
416                        )
417                    })?;
418                Ok((Arc::new(creds), region))
419            }
420            AwsProviderScheme::Ecs => {
421                let provider = EcsCredentialsProvider::builder().build();
422                Ok((
423                    Arc::new(AwsCredentialAdapter::new(
424                        Arc::new(provider),
425                        credentials_refresh_offset,
426                    )),
427                    region,
428                ))
429            }
430            AwsProviderScheme::Irsa => {
431                let conf = ProviderConfig::default().with_region(Some(Region::new(region.clone())));
432                let provider = WebIdentityTokenCredentialsProvider::builder()
433                    .configure(&conf)
434                    .build();
435                Ok((
436                    Arc::new(AwsCredentialAdapter::new(
437                        Arc::new(provider),
438                        credentials_refresh_offset,
439                    )),
440                    region,
441                ))
442            }
443        };
444    }
445
446    if let Some(opts) = storage_options {
447        // Check for static credentials (access key & secret)
448        if let Some(creds) = extract_static_s3_credentials(opts) {
449            return Ok((Arc::new(creds), region));
450        }
451    }
452
453    let credentials_provider = DefaultCredentialsChain::builder().build().await;
454    Ok((
455        Arc::new(AwsCredentialAdapter::new(
456            Arc::new(credentials_provider),
457            credentials_refresh_offset,
458        )),
459        region,
460    ))
461}
462
463fn extract_static_s3_credentials(
464    options: &HashMap<AmazonS3ConfigKey, String>,
465) -> Option<StaticCredentialProvider<ObjectStoreAwsCredential>> {
466    let key_id = options.get(&AmazonS3ConfigKey::AccessKeyId).cloned();
467    let secret_key = options.get(&AmazonS3ConfigKey::SecretAccessKey).cloned();
468    let token = options.get(&AmazonS3ConfigKey::Token).cloned();
469    match (key_id, secret_key, token) {
470        (Some(key_id), Some(secret_key), token) => {
471            Some(StaticCredentialProvider::new(ObjectStoreAwsCredential {
472                key_id,
473                secret_key,
474                token,
475            }))
476        }
477        _ => None,
478    }
479}
480
481/// Adapt an AWS SDK cred into object_store credentials
482#[derive(Debug)]
483pub struct AwsCredentialAdapter {
484    pub inner: Arc<dyn ProvideCredentials>,
485
486    // RefCell can't be shared across threads, so we use HashMap
487    cache: Arc<RwLock<HashMap<String, Arc<aws_credential_types::Credentials>>>>,
488
489    // The amount of time before expiry to refresh credentials
490    credentials_refresh_offset: Duration,
491}
492
493impl AwsCredentialAdapter {
494    pub fn new(
495        provider: Arc<dyn ProvideCredentials>,
496        credentials_refresh_offset: Duration,
497    ) -> Self {
498        Self {
499            inner: provider,
500            cache: Arc::new(RwLock::new(HashMap::new())),
501            credentials_refresh_offset,
502        }
503    }
504}
505
506const AWS_CREDS_CACHE_KEY: &str = "aws_credentials";
507
508/// Convert std::time::SystemTime from AWS SDK to our mockable SystemTime
509fn to_system_time(time: std::time::SystemTime) -> SystemTime {
510    let duration_since_epoch = time
511        .duration_since(std::time::UNIX_EPOCH)
512        .expect("time should be after UNIX_EPOCH");
513    UNIX_EPOCH + duration_since_epoch
514}
515
516#[async_trait::async_trait]
517impl CredentialProvider for AwsCredentialAdapter {
518    type Credential = ObjectStoreAwsCredential;
519
520    async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
521        let cached_creds = {
522            let cache_value = self.cache.read().await.get(AWS_CREDS_CACHE_KEY).cloned();
523            let expired = cache_value
524                .clone()
525                .map(|cred| {
526                    cred.expiry()
527                        .map(|exp| {
528                            to_system_time(exp)
529                                .checked_sub(self.credentials_refresh_offset)
530                                .expect("this time should always be valid")
531                                < SystemTime::now()
532                        })
533                        // no expiry is never expire
534                        .unwrap_or(false)
535                })
536                .unwrap_or(true); // no cred is the same as expired;
537            if expired { None } else { cache_value.clone() }
538        };
539
540        if let Some(creds) = cached_creds {
541            Ok(Arc::new(Self::Credential {
542                key_id: creds.access_key_id().to_string(),
543                secret_key: creds.secret_access_key().to_string(),
544                token: creds.session_token().map(|s| s.to_string()),
545            }))
546        } else {
547            let refreshed_creds = Arc::new(
548                self.inner
549                    .provide_credentials()
550                    .await
551                    .map_err(|e| Error::io(format!("Failed to get AWS credentials: {:?}", e)))?,
552            );
553
554            self.cache
555                .write()
556                .await
557                .insert(AWS_CREDS_CACHE_KEY.to_string(), refreshed_creds.clone());
558
559            Ok(Arc::new(Self::Credential {
560                key_id: refreshed_creds.access_key_id().to_string(),
561                secret_key: refreshed_creds.secret_access_key().to_string(),
562                token: refreshed_creds.session_token().map(|s| s.to_string()),
563            }))
564        }
565    }
566}
567
568impl StorageOptions {
569    /// Add values from the environment to storage options.
570    ///
571    /// Only adds keys that are not already present, so explicitly-set options
572    /// (including empty-string sentinels) always take precedence over env vars.
573    pub fn with_env_s3(&mut self) {
574        for (os_key, os_value) in std::env::vars_os() {
575            if let (Some(key), Some(value)) = (os_key.to_str(), os_value.to_str())
576                && let Ok(config_key) = AmazonS3ConfigKey::from_str(&key.to_ascii_lowercase())
577                && !self.0.contains_key(config_key.as_ref())
578            {
579                self.0
580                    .insert(config_key.as_ref().to_string(), value.to_string());
581            }
582        }
583    }
584
585    /// Subset of options relevant for s3 storage
586    pub fn as_s3_options(&self) -> HashMap<AmazonS3ConfigKey, String> {
587        self.0
588            .iter()
589            .filter_map(|(key, value)| {
590                let s3_key = AmazonS3ConfigKey::from_str(&key.to_ascii_lowercase()).ok()?;
591                Some((s3_key, value.clone()))
592            })
593            .collect()
594    }
595
596    /// Parse the `aws_provider_scheme` storage option, if set.
597    pub fn aws_provider_scheme(&self) -> Result<Option<AwsProviderScheme>> {
598        match self.0.get("aws_provider_scheme").map(|s| s.as_str()) {
599            None | Some("") => Ok(None),
600            Some("token") => Ok(Some(AwsProviderScheme::Token)),
601            Some("ecs") => Ok(Some(AwsProviderScheme::Ecs)),
602            Some("irsa") => Ok(Some(AwsProviderScheme::Irsa)),
603            Some(other) => Err(Error::invalid_input(format!(
604                "Invalid aws_provider_scheme '{}'. Valid values are: token, ecs, irsa",
605                other
606            ))),
607        }
608    }
609}
610
611impl ObjectStoreParams {
612    /// Create a new instance of [`ObjectStoreParams`] based on the AWS credentials.
613    pub fn with_aws_credentials(
614        aws_credentials: Option<AwsCredentialProvider>,
615        region: Option<String>,
616    ) -> Self {
617        let storage_options_accessor = region.map(|region| {
618            let opts: HashMap<String, String> =
619                [("region".into(), region)].iter().cloned().collect();
620            Arc::new(StorageOptionsAccessor::with_static_options(opts))
621        });
622        Self {
623            aws_credentials,
624            storage_options_accessor,
625            ..Default::default()
626        }
627    }
628}
629
630pub type DynamicStorageOptionsCredentialProvider =
631    NamespaceCredentialsProvider<ObjectStoreAwsCredential>;
632
633#[cfg(test)]
634mod tests {
635    use crate::object_store::ObjectStoreRegistry;
636    use crate::object_store::StorageOptionsProvider;
637    #[allow(deprecated)]
638    use aws_config::profile::profile_file::{ProfileFileKind, ProfileFiles};
639    use aws_credential_types::provider::error::CredentialsError;
640    use mock_instant::thread_local::MockClock;
641    use object_store::path::Path;
642    use std::sync::atomic::{AtomicBool, Ordering};
643
644    use super::*;
645
646    #[derive(Debug, Default)]
647    struct MockAwsCredentialsProvider {
648        called: AtomicBool,
649    }
650
651    #[async_trait::async_trait]
652    impl CredentialProvider for MockAwsCredentialsProvider {
653        type Credential = ObjectStoreAwsCredential;
654
655        async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
656            self.called.store(true, Ordering::Relaxed);
657            Ok(Arc::new(Self::Credential {
658                key_id: "".to_string(),
659                secret_key: "".to_string(),
660                token: None,
661            }))
662        }
663    }
664
665    #[allow(deprecated)]
666    async fn load_test_profile(config: &str) -> SdkConfig {
667        let profile_files = ProfileFiles::builder()
668            .with_contents(ProfileFileKind::Config, config)
669            .build();
670        aws_config::defaults(BehaviorVersion::latest())
671            .profile_name("selected")
672            .profile_files(profile_files)
673            .load()
674            .await
675    }
676
677    #[derive(Debug)]
678    struct FailingAwsCredentialsProvider;
679
680    impl ProvideCredentials for FailingAwsCredentialsProvider {
681        fn provide_credentials<'a>(
682            &'a self,
683        ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
684        where
685            Self: 'a,
686        {
687            aws_credential_types::provider::future::ProvideCredentials::new(async {
688                Err(CredentialsError::provider_error(Box::new(
689                    std::io::Error::other("Glue credential endpoint unavailable"),
690                )))
691            })
692        }
693    }
694
695    #[tokio::test]
696    async fn test_aws_credential_failure_is_io_error() {
697        let provider = AwsCredentialAdapter::new(
698            Arc::new(FailingAwsCredentialsProvider),
699            Duration::from_secs(60),
700        );
701
702        let error = provider.get_credential().await.unwrap_err();
703        let object_store::Error::Generic { source, .. } = &error else {
704            panic!("expected a generic object store error, got {error}");
705        };
706        assert!(matches!(
707            source.downcast_ref::<Error>(),
708            Some(Error::IO { .. })
709        ));
710
711        let message = error.to_string();
712        assert!(message.contains("Failed to get AWS credentials"));
713        assert!(message.contains("Glue credential endpoint unavailable"));
714        assert!(!message.contains("Encountered internal error"));
715    }
716
717    #[tokio::test]
718    async fn test_injected_aws_creds_option_is_used() {
719        let mock_provider = Arc::new(MockAwsCredentialsProvider::default());
720        let registry = Arc::new(ObjectStoreRegistry::default());
721
722        let params = ObjectStoreParams {
723            aws_credentials: Some(mock_provider.clone() as AwsCredentialProvider),
724            ..ObjectStoreParams::default()
725        };
726
727        // Not called yet
728        assert!(!mock_provider.called.load(Ordering::Relaxed));
729
730        let (store, _) = ObjectStore::from_uri_and_params(registry, "s3://not-a-bucket", &params)
731            .await
732            .unwrap();
733
734        // fails, but we don't care
735        let _ = store
736            .open(&Path::parse("/").unwrap())
737            .await
738            .unwrap()
739            .get_range(0..1)
740            .await;
741
742        // Not called yet
743        assert!(mock_provider.called.load(Ordering::Relaxed));
744    }
745
746    #[tokio::test]
747    async fn test_resolve_s3_region_from_aws_profile() {
748        let profile_config = load_test_profile(
749            "[profile selected]\n\
750             region = us-west-004\n\
751             endpoint_url = https://s3.us-west-004.backblazeb2.com\n\
752             aws_access_key_id = test-key\n\
753             aws_secret_access_key = test-secret",
754        )
755        .await;
756        let url = Url::parse("s3://test-bucket/path").unwrap();
757
758        let resolved_s3_options =
759            ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
760        let region = resolve_s3_region(&url, &resolved_s3_options).await.unwrap();
761
762        assert_eq!(region.as_deref(), Some("us-west-004"));
763        assert_eq!(
764            resolved_s3_options
765                .options
766                .get(&AmazonS3ConfigKey::Endpoint),
767            Some(&"https://s3.us-west-004.backblazeb2.com".to_string())
768        );
769
770        let explicit_options = HashMap::from([
771            (AmazonS3ConfigKey::Region, "explicit-region".to_string()),
772            (
773                AmazonS3ConfigKey::Endpoint,
774                "https://explicit.example.com".to_string(),
775            ),
776        ]);
777        let resolved_s3_options =
778            ResolvedS3StorageOptions::new(explicit_options, Some(&profile_config));
779        let region = resolve_s3_region(&url, &resolved_s3_options).await.unwrap();
780
781        assert_eq!(region.as_deref(), Some("explicit-region"));
782        assert_eq!(
783            resolved_s3_options
784                .options
785                .get(&AmazonS3ConfigKey::Endpoint),
786            Some(&"https://explicit.example.com".to_string())
787        );
788    }
789
790    #[tokio::test]
791    async fn test_region_only_aws_profile_preserves_bucket_discovery() {
792        let profile_config = load_test_profile("[profile selected]\nregion = us-east-1").await;
793        let resolved_s3_options =
794            ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
795
796        let url = Url::parse("s3:///path").unwrap();
797        let error = resolve_s3_region(&url, &resolved_s3_options)
798            .await
799            .unwrap_err();
800
801        assert!(matches!(error, Error::InvalidInput { .. }));
802        assert!(error.to_string().contains("Could not parse bucket"));
803    }
804
805    #[tokio::test]
806    async fn test_r2_aws_profile_requires_constant_size_upload_parts() {
807        let profile_config = load_test_profile(
808            "[profile selected]\n\
809             region = auto\n\
810             endpoint_url = https://account.r2.cloudflarestorage.com",
811        )
812        .await;
813        let resolved_s3_options =
814            ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
815
816        assert!(resolved_s3_options.requires_constant_size_upload_parts());
817    }
818
819    #[test]
820    fn test_s3_path_parsing() {
821        let provider = AwsStoreProvider;
822
823        let cases = [
824            ("s3://bucket/path/to/file", "path/to/file"),
825            // for non ASCII string tests: the URL encodes them, extract_path must decode back
826            ("s3://bucket/测试path/to/file", "测试path/to/file"),
827            ("s3://bucket/path/&to/file", "path/&to/file"),
828            ("s3://bucket/path/=to/file", "path/=to/file"),
829            (
830                "s3+ddb://bucket/path/to/file?ddbTableName=test",
831                "path/to/file",
832            ),
833        ];
834
835        for (uri, expected_path) in cases {
836            let url = Url::parse(uri).unwrap();
837            let path = provider.extract_path(&url).unwrap();
838            // extract_path decodes url.path(), so the Path stores the raw (decoded)
839            // string. Path::parse keeps its input verbatim, matching that, whereas
840            // Path::from would percent-encode non-ASCII bytes and not match.
841            let expected_path = Path::parse(expected_path).unwrap();
842            assert_eq!(path, expected_path)
843        }
844    }
845
846    // Regression test for https://github.com/lance-format/lance/issues/6643
847    // extract_path must NOT double-encode paths that contain non-ASCII characters.
848    // url.path() returns a percent-encoded string; we must decode it back to raw
849    // UTF-8 before storing it in a Path, so the object store HTTP client can apply
850    // a single, correct percent-encoding when building the request URL.
851    #[test]
852    fn test_s3_non_ascii_path_no_double_encoding() {
853        let provider = AwsStoreProvider;
854
855        // "s3://bucket/中文路径" → url.path() == "/%E4%B8%AD%E6%96%87%E8%B7%AF%E5%BE%84".
856        // The buggy Path::parse(url.path()) stored "%E4%B8%AD..." verbatim; the S3
857        // client then percent-encodes the '%' again, yielding "%25E4%25B8%25AD...".
858        // With Path::from_url_path the Path stores the decoded UTF-8 instead.
859        let url = Url::parse("s3://bucket/中文路径").unwrap();
860        let path = provider.extract_path(&url).unwrap();
861
862        // The Path must hold the decoded UTF-8, not the percent-encoded form.
863        assert_eq!(path.as_ref(), "中文路径");
864    }
865
866    #[test]
867    fn test_is_s3_express() {
868        let cases = [
869            (
870                "s3://bucket/path/to/file",
871                HashMap::from([("s3_express".to_string(), "true".to_string())]),
872                true,
873            ),
874            (
875                "s3://bucket/path/to/file",
876                HashMap::from([("s3_express".to_string(), "false".to_string())]),
877                false,
878            ),
879            ("s3://bucket/path/to/file", HashMap::from([]), false),
880            (
881                "s3://bucket--x-s3/path/to/file",
882                HashMap::from([("s3_express".to_string(), "true".to_string())]),
883                true,
884            ),
885            (
886                "s3://bucket--x-s3/path/to/file",
887                HashMap::from([("s3_express".to_string(), "false".to_string())]),
888                true, // URL takes precedence
889            ),
890            ("s3://bucket--x-s3/path/to/file", HashMap::from([]), true),
891        ];
892
893        for (uri, storage_map, expected) in cases {
894            let url = Url::parse(uri).unwrap();
895            let storage_options = StorageOptions(storage_map);
896            let is_s3_express = check_s3_express(&url, &storage_options);
897            assert_eq!(is_s3_express, expected);
898        }
899    }
900
901    #[tokio::test]
902    async fn test_use_opendal_flag() {
903        use crate::object_store::StorageOptionsAccessor;
904        let provider = AwsStoreProvider;
905        let url = Url::parse("s3://test-bucket/path").unwrap();
906        let params_with_flag = ObjectStoreParams {
907            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
908                HashMap::from([
909                    ("use_opendal".to_string(), "true".to_string()),
910                    ("region".to_string(), "us-west-2".to_string()),
911                ]),
912            ))),
913            ..Default::default()
914        };
915
916        let store = provider
917            .new_store(url.clone(), &params_with_flag)
918            .await
919            .unwrap();
920        assert_eq!(store.scheme, "s3");
921    }
922
923    #[derive(Debug)]
924    struct MockStorageOptionsProvider {
925        call_count: Arc<RwLock<usize>>,
926        expires_in_millis: Option<u64>,
927    }
928
929    impl MockStorageOptionsProvider {
930        fn new(expires_in_millis: Option<u64>) -> Self {
931            Self {
932                call_count: Arc::new(RwLock::new(0)),
933                expires_in_millis,
934            }
935        }
936
937        async fn get_call_count(&self) -> usize {
938            *self.call_count.read().await
939        }
940    }
941
942    #[async_trait::async_trait]
943    impl StorageOptionsProvider for MockStorageOptionsProvider {
944        async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
945            let count = {
946                let mut c = self.call_count.write().await;
947                *c += 1;
948                *c
949            };
950
951            let mut options = HashMap::from([
952                ("aws_access_key_id".to_string(), format!("AKID_{}", count)),
953                (
954                    "aws_secret_access_key".to_string(),
955                    format!("SECRET_{}", count),
956                ),
957                ("aws_session_token".to_string(), format!("TOKEN_{}", count)),
958            ]);
959
960            if let Some(expires_in) = self.expires_in_millis {
961                let now_ms = SystemTime::now()
962                    .duration_since(UNIX_EPOCH)
963                    .unwrap()
964                    .as_millis() as u64;
965                let expires_at = now_ms + expires_in;
966                options.insert("expires_at_millis".to_string(), expires_at.to_string());
967            }
968
969            Ok(Some(options))
970        }
971
972        fn provider_id(&self) -> String {
973            let ptr = Arc::as_ptr(&self.call_count) as usize;
974            format!("MockStorageOptionsProvider {{ id: {} }}", ptr)
975        }
976    }
977
978    #[tokio::test]
979    async fn test_dynamic_credential_provider_with_initial_cache() {
980        MockClock::set_system_time(Duration::from_secs(100_000));
981
982        let now_ms = MockClock::system_time().as_millis() as u64;
983
984        // Create a mock provider that returns credentials expiring in 10 minutes
985        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
986            600_000, // Expires in 10 minutes
987        )));
988
989        // Create initial options with cached credentials that expire in 10 minutes
990        let expires_at = now_ms + 600_000; // 10 minutes from now
991        let initial_options = HashMap::from([
992            ("aws_access_key_id".to_string(), "AKID_CACHED".to_string()),
993            (
994                "aws_secret_access_key".to_string(),
995                "SECRET_CACHED".to_string(),
996            ),
997            ("aws_session_token".to_string(), "TOKEN_CACHED".to_string()),
998            ("expires_at_millis".to_string(), expires_at.to_string()),
999            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1000        ]);
1001
1002        let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1003            mock.clone(),
1004            initial_options,
1005        );
1006
1007        // First call should use cached credentials (not expired yet)
1008        let cred = provider.get_credential().await.unwrap();
1009        assert_eq!(cred.key_id, "AKID_CACHED");
1010        assert_eq!(cred.secret_key, "SECRET_CACHED");
1011        assert_eq!(cred.token, Some("TOKEN_CACHED".to_string()));
1012
1013        // Should not have called the provider yet
1014        assert_eq!(mock.get_call_count().await, 0);
1015    }
1016
1017    #[tokio::test]
1018    async fn test_dynamic_credential_provider_with_expired_cache() {
1019        MockClock::set_system_time(Duration::from_secs(100_000));
1020
1021        let now_ms = MockClock::system_time().as_millis() as u64;
1022
1023        // Create a mock provider that returns credentials expiring in 10 minutes
1024        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1025            600_000, // Expires in 10 minutes
1026        )));
1027
1028        // Create initial options with credentials that expired 1 second ago
1029        let expired_time = now_ms - 1_000; // 1 second ago
1030        let initial_options = HashMap::from([
1031            ("aws_access_key_id".to_string(), "AKID_EXPIRED".to_string()),
1032            (
1033                "aws_secret_access_key".to_string(),
1034                "SECRET_EXPIRED".to_string(),
1035            ),
1036            ("expires_at_millis".to_string(), expired_time.to_string()),
1037            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1038        ]);
1039
1040        let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1041            mock.clone(),
1042            initial_options,
1043        );
1044
1045        // First call should fetch new credentials because cached ones are expired
1046        let cred = provider.get_credential().await.unwrap();
1047        assert_eq!(cred.key_id, "AKID_1");
1048        assert_eq!(cred.secret_key, "SECRET_1");
1049        assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1050
1051        // Should have called the provider once
1052        assert_eq!(mock.get_call_count().await, 1);
1053    }
1054
1055    #[tokio::test]
1056    async fn test_dynamic_credential_provider_refresh_lead_time() {
1057        MockClock::set_system_time(Duration::from_secs(100_000));
1058
1059        // Create a mock provider that returns credentials expiring in 30 seconds
1060        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1061            30_000, // Expires in 30 seconds
1062        )));
1063
1064        // Create credential provider with default 60 second refresh offset
1065        // This means credentials should be refreshed when they have less than 60 seconds left
1066        let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1067
1068        // First call should fetch credentials from provider (no initial cache)
1069        // Credentials expire in 30 seconds, which is less than our 60 second refresh offset,
1070        // so they should be considered "needs refresh" immediately
1071        let cred = provider.get_credential().await.unwrap();
1072        assert_eq!(cred.key_id, "AKID_1");
1073        assert_eq!(mock.get_call_count().await, 1);
1074
1075        // Second call should trigger refresh because credentials expire in 30 seconds
1076        // but our refresh lead time is 60 seconds (now + 60sec > expires_at)
1077        // The mock will return new credentials (AKID_2) with the same expiration
1078        let cred = provider.get_credential().await.unwrap();
1079        assert_eq!(cred.key_id, "AKID_2");
1080        assert_eq!(mock.get_call_count().await, 2);
1081    }
1082
1083    #[tokio::test]
1084    async fn test_dynamic_credential_provider_no_initial_cache() {
1085        MockClock::set_system_time(Duration::from_secs(100_000));
1086
1087        // Create a mock provider that returns credentials expiring in 2 minutes
1088        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1089            120_000, // Expires in 2 minutes
1090        )));
1091
1092        // Create credential provider without initial cache, using default 60 second refresh offset
1093        let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1094
1095        // First call should fetch from provider (call count = 1)
1096        let cred = provider.get_credential().await.unwrap();
1097        assert_eq!(cred.key_id, "AKID_1");
1098        assert_eq!(cred.secret_key, "SECRET_1");
1099        assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1100        assert_eq!(mock.get_call_count().await, 1);
1101
1102        // Second call should use cached credentials (not expired yet, still > 60 seconds remaining)
1103        let cred = provider.get_credential().await.unwrap();
1104        assert_eq!(cred.key_id, "AKID_1");
1105        assert_eq!(mock.get_call_count().await, 1); // Still 1, didn't fetch again
1106
1107        // Advance time to 90 seconds - should trigger refresh (within 60 sec refresh offset)
1108        // At this point, credentials expire in 30 seconds (< 60 sec offset)
1109        MockClock::set_system_time(Duration::from_secs(100_000 + 90));
1110        let cred = provider.get_credential().await.unwrap();
1111        assert_eq!(cred.key_id, "AKID_2");
1112        assert_eq!(cred.secret_key, "SECRET_2");
1113        assert_eq!(cred.token, Some("TOKEN_2".to_string()));
1114        assert_eq!(mock.get_call_count().await, 2);
1115
1116        // Advance time to 210 seconds total (90 + 120) - should trigger another refresh
1117        MockClock::set_system_time(Duration::from_secs(100_000 + 210));
1118        let cred = provider.get_credential().await.unwrap();
1119        assert_eq!(cred.key_id, "AKID_3");
1120        assert_eq!(cred.secret_key, "SECRET_3");
1121        assert_eq!(mock.get_call_count().await, 3);
1122    }
1123
1124    #[tokio::test]
1125    async fn test_dynamic_credential_provider_with_initial_options() {
1126        MockClock::set_system_time(Duration::from_secs(100_000));
1127
1128        let now_ms = MockClock::system_time().as_millis() as u64;
1129
1130        // Create a mock provider that returns credentials expiring in 10 minutes
1131        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1132            600_000, // Expires in 10 minutes
1133        )));
1134
1135        // Create initial options with expiration in 10 minutes
1136        let expires_at = now_ms + 600_000; // 10 minutes from now
1137        let initial_options = HashMap::from([
1138            ("aws_access_key_id".to_string(), "AKID_INITIAL".to_string()),
1139            (
1140                "aws_secret_access_key".to_string(),
1141                "SECRET_INITIAL".to_string(),
1142            ),
1143            ("aws_session_token".to_string(), "TOKEN_INITIAL".to_string()),
1144            ("expires_at_millis".to_string(), expires_at.to_string()),
1145            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1146        ]);
1147
1148        // Create credential provider with initial options
1149        let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1150            mock.clone(),
1151            initial_options,
1152        );
1153
1154        // First call should use the initial credential (not expired yet)
1155        let cred = provider.get_credential().await.unwrap();
1156        assert_eq!(cred.key_id, "AKID_INITIAL");
1157        assert_eq!(cred.secret_key, "SECRET_INITIAL");
1158        assert_eq!(cred.token, Some("TOKEN_INITIAL".to_string()));
1159
1160        // Should not have called the provider yet
1161        assert_eq!(mock.get_call_count().await, 0);
1162
1163        // Advance time to 6 minutes - this should trigger a refresh
1164        // (5 minute refresh offset means we refresh 5 minutes before expiration)
1165        MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1166        let cred = provider.get_credential().await.unwrap();
1167        assert_eq!(cred.key_id, "AKID_1");
1168        assert_eq!(cred.secret_key, "SECRET_1");
1169        assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1170
1171        // Should have called the provider once
1172        assert_eq!(mock.get_call_count().await, 1);
1173
1174        // Advance time to 11 minutes total - this should trigger another refresh
1175        MockClock::set_system_time(Duration::from_secs(100_000 + 660));
1176        let cred = provider.get_credential().await.unwrap();
1177        assert_eq!(cred.key_id, "AKID_2");
1178        assert_eq!(cred.secret_key, "SECRET_2");
1179        assert_eq!(cred.token, Some("TOKEN_2".to_string()));
1180
1181        // Should have called the provider twice
1182        assert_eq!(mock.get_call_count().await, 2);
1183
1184        // Advance time to 16 minutes total - this should trigger yet another refresh
1185        MockClock::set_system_time(Duration::from_secs(100_000 + 960));
1186        let cred = provider.get_credential().await.unwrap();
1187        assert_eq!(cred.key_id, "AKID_3");
1188        assert_eq!(cred.secret_key, "SECRET_3");
1189        assert_eq!(cred.token, Some("TOKEN_3".to_string()));
1190
1191        // Should have called the provider three times
1192        assert_eq!(mock.get_call_count().await, 3);
1193    }
1194
1195    #[tokio::test]
1196    async fn test_dynamic_credential_provider_concurrent_access() {
1197        // Create a mock provider with far future expiration
1198        let mock = Arc::new(MockStorageOptionsProvider::new(Some(9999999999999)));
1199
1200        let provider = Arc::new(DynamicStorageOptionsCredentialProvider::from_provider(
1201            mock.clone(),
1202        ));
1203
1204        // Spawn 10 concurrent tasks that all try to get credentials at the same time
1205        let mut handles = vec![];
1206        for i in 0..10 {
1207            let provider = provider.clone();
1208            let handle = tokio::spawn(async move {
1209                let cred = provider.get_credential().await.unwrap();
1210                // Verify we got the correct credentials (should all be AKID_1 from first fetch)
1211                assert_eq!(cred.key_id, "AKID_1");
1212                assert_eq!(cred.secret_key, "SECRET_1");
1213                assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1214                i // Return task number for verification
1215            });
1216            handles.push(handle);
1217        }
1218
1219        // Wait for all tasks to complete
1220        let results: Vec<_> = futures::future::join_all(handles)
1221            .await
1222            .into_iter()
1223            .map(|r| r.unwrap())
1224            .collect();
1225
1226        // Verify all 10 tasks completed successfully
1227        assert_eq!(results.len(), 10);
1228        for i in 0..10 {
1229            assert!(results.contains(&i));
1230        }
1231
1232        // The provider should have been called exactly once (first request triggers fetch,
1233        // subsequent requests use cache)
1234        let call_count = mock.get_call_count().await;
1235        assert_eq!(
1236            call_count, 1,
1237            "Provider should be called exactly once despite concurrent access"
1238        );
1239    }
1240
1241    #[tokio::test]
1242    async fn test_dynamic_credential_provider_concurrent_refresh() {
1243        MockClock::set_system_time(Duration::from_secs(100_000));
1244
1245        let now_ms = MockClock::system_time().as_millis() as u64;
1246
1247        // Create initial options with credentials that expired in the past (1000 seconds ago)
1248        let expires_at = now_ms - 1_000_000;
1249        let initial_options = HashMap::from([
1250            ("aws_access_key_id".to_string(), "AKID_OLD".to_string()),
1251            (
1252                "aws_secret_access_key".to_string(),
1253                "SECRET_OLD".to_string(),
1254            ),
1255            ("aws_session_token".to_string(), "TOKEN_OLD".to_string()),
1256            ("expires_at_millis".to_string(), expires_at.to_string()),
1257            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1258        ]);
1259
1260        // Mock will return credentials expiring in 1 hour
1261        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1262            3_600_000, // Expires in 1 hour
1263        )));
1264
1265        let provider = Arc::new(
1266            DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1267                mock.clone(),
1268                initial_options,
1269            ),
1270        );
1271
1272        // Spawn 20 concurrent tasks that all try to get credentials at the same time
1273        // Since the initial credential is expired, they'll all try to refresh
1274        let mut handles = vec![];
1275        for i in 0..20 {
1276            let provider = provider.clone();
1277            let handle = tokio::spawn(async move {
1278                let cred = provider.get_credential().await.unwrap();
1279                // All should get the new credentials (AKID_1 from first fetch)
1280                assert_eq!(cred.key_id, "AKID_1");
1281                assert_eq!(cred.secret_key, "SECRET_1");
1282                assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1283                i
1284            });
1285            handles.push(handle);
1286        }
1287
1288        // Wait for all tasks to complete
1289        let results: Vec<_> = futures::future::join_all(handles)
1290            .await
1291            .into_iter()
1292            .map(|r| r.unwrap())
1293            .collect();
1294
1295        // Verify all 20 tasks completed successfully
1296        assert_eq!(results.len(), 20);
1297
1298        // The provider should have been called at least once, but possibly more times
1299        // due to the try_write mechanism and race conditions
1300        let call_count = mock.get_call_count().await;
1301        assert!(
1302            call_count >= 1,
1303            "Provider should be called at least once, was called {} times",
1304            call_count
1305        );
1306
1307        // It shouldn't be called 20 times though - the lock should prevent most concurrent fetches
1308        assert!(
1309            call_count < 10,
1310            "Provider should not be called too many times due to lock contention, was called {} times",
1311            call_count
1312        );
1313    }
1314
1315    #[tokio::test]
1316    async fn test_explicit_aws_credentials_takes_precedence_over_accessor() {
1317        // Create a mock storage options provider that should NOT be called
1318        let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1319
1320        // Create an accessor with the mock provider
1321        let accessor = Arc::new(StorageOptionsAccessor::with_provider(
1322            mock_storage_provider.clone(),
1323        ));
1324
1325        // Create an explicit AWS credentials provider
1326        let explicit_cred_provider = Arc::new(MockAwsCredentialsProvider::default());
1327
1328        // Build credentials with both aws_credentials AND accessor
1329        // The explicit aws_credentials should take precedence
1330        let (result, _region) = build_aws_credential(
1331            Duration::from_secs(300),
1332            Some(explicit_cred_provider.clone() as AwsCredentialProvider),
1333            None, // no storage_options
1334            Some("us-west-2".to_string()),
1335            Some(accessor),
1336            None,
1337        )
1338        .await
1339        .unwrap();
1340
1341        // Get credential from the result
1342        let cred = result.get_credential().await.unwrap();
1343
1344        // The explicit provider should have been called (it returns empty strings)
1345        assert!(explicit_cred_provider.called.load(Ordering::Relaxed));
1346
1347        // The storage options provider should NOT have been called
1348        assert_eq!(
1349            mock_storage_provider.get_call_count().await,
1350            0,
1351            "Storage options provider should not be called when explicit aws_credentials is provided"
1352        );
1353
1354        // Verify we got credentials from the explicit provider (empty strings)
1355        assert_eq!(cred.key_id, "");
1356        assert_eq!(cred.secret_key, "");
1357    }
1358
1359    #[tokio::test]
1360    async fn test_accessor_used_when_no_explicit_aws_credentials() {
1361        MockClock::set_system_time(Duration::from_secs(100_000));
1362
1363        let now_ms = MockClock::system_time().as_millis() as u64;
1364
1365        // Create a mock storage options provider
1366        let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1367
1368        // Create initial options
1369        let expires_at = now_ms + 600_000; // 10 minutes from now
1370        let initial_options = HashMap::from([
1371            (
1372                "aws_access_key_id".to_string(),
1373                "AKID_FROM_ACCESSOR".to_string(),
1374            ),
1375            (
1376                "aws_secret_access_key".to_string(),
1377                "SECRET_FROM_ACCESSOR".to_string(),
1378            ),
1379            (
1380                "aws_session_token".to_string(),
1381                "TOKEN_FROM_ACCESSOR".to_string(),
1382            ),
1383            ("expires_at_millis".to_string(), expires_at.to_string()),
1384            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1385        ]);
1386
1387        // Create an accessor with initial options and provider
1388        let accessor = Arc::new(StorageOptionsAccessor::with_initial_and_provider(
1389            initial_options,
1390            mock_storage_provider.clone(),
1391        ));
1392
1393        // Build credentials with accessor but NO explicit aws_credentials
1394        let (result, _region) = build_aws_credential(
1395            Duration::from_secs(300),
1396            None, // no explicit aws_credentials
1397            None, // no storage_options
1398            Some("us-west-2".to_string()),
1399            Some(accessor),
1400            None,
1401        )
1402        .await
1403        .unwrap();
1404
1405        // Get credential - should use the initial accessor credentials
1406        let cred = result.get_credential().await.unwrap();
1407        assert_eq!(cred.key_id, "AKID_FROM_ACCESSOR");
1408        assert_eq!(cred.secret_key, "SECRET_FROM_ACCESSOR");
1409
1410        // Storage options provider should NOT have been called yet (using cached initial creds)
1411        assert_eq!(mock_storage_provider.get_call_count().await, 0);
1412
1413        // Advance time to trigger refresh (past the 5 minute refresh offset)
1414        MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1415
1416        // Get credential again - should now fetch from provider
1417        let cred = result.get_credential().await.unwrap();
1418        assert_eq!(cred.key_id, "AKID_1");
1419        assert_eq!(cred.secret_key, "SECRET_1");
1420
1421        // Storage options provider should have been called once
1422        assert_eq!(mock_storage_provider.get_call_count().await, 1);
1423    }
1424
1425    // Test that aws_provider_scheme=token selects static credentials.
1426    #[tokio::test]
1427    async fn test_provider_scheme_token() {
1428        let opts = HashMap::from([
1429            (AmazonS3ConfigKey::AccessKeyId, "AKID".to_string()),
1430            (AmazonS3ConfigKey::SecretAccessKey, "SECRET".to_string()),
1431        ]);
1432
1433        let (provider, _) = build_aws_credential(
1434            Duration::from_secs(300),
1435            None,
1436            Some(&opts),
1437            Some("us-east-1".to_string()),
1438            None,
1439            Some(AwsProviderScheme::Token),
1440        )
1441        .await
1442        .unwrap();
1443
1444        let cred = provider.get_credential().await.unwrap();
1445        assert_eq!(cred.key_id, "AKID");
1446        assert_eq!(cred.secret_key, "SECRET");
1447    }
1448
1449    // Test that aws_provider_scheme=token errors when no static credentials are present.
1450    #[tokio::test]
1451    async fn test_provider_scheme_token_errors_without_credentials() {
1452        let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1453
1454        let result = build_aws_credential(
1455            Duration::from_secs(300),
1456            None,
1457            Some(&opts),
1458            Some("us-east-1".to_string()),
1459            None,
1460            Some(AwsProviderScheme::Token),
1461        )
1462        .await;
1463        assert!(result.is_err());
1464        assert!(
1465            result
1466                .unwrap_err()
1467                .to_string()
1468                .contains("aws_provider_scheme=token"),
1469            "error should mention aws_provider_scheme=token"
1470        );
1471    }
1472
1473    // Test that aws_provider_scheme=ecs builds a provider without error.
1474    // The ECS provider itself reads from env vars lazily; construction always succeeds.
1475    #[tokio::test]
1476    async fn test_provider_scheme_ecs() {
1477        let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1478
1479        let result = build_aws_credential(
1480            Duration::from_secs(300),
1481            None,
1482            Some(&opts),
1483            Some("us-east-1".to_string()),
1484            None,
1485            Some(AwsProviderScheme::Ecs),
1486        )
1487        .await;
1488        assert!(result.is_ok(), "ECS provider should build without error");
1489    }
1490
1491    // Test that aws_provider_scheme=irsa builds a provider and attempts credential
1492    // retrieval (which fails with a provider error, not a config error like
1493    // "Missing Region" — confirming the region is wired through to the STS client).
1494    #[tokio::test]
1495    async fn test_provider_scheme_irsa() {
1496        let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1497
1498        let (provider, _) = build_aws_credential(
1499            Duration::from_secs(300),
1500            None,
1501            Some(&opts),
1502            Some("us-east-1".to_string()),
1503            None,
1504            Some(AwsProviderScheme::Irsa),
1505        )
1506        .await
1507        .unwrap();
1508
1509        // Credential retrieval must fail with a provider error (missing env vars or
1510        // network), NOT a configuration error like "Invalid Configuration: Missing Region".
1511        let err = provider.get_credential().await.unwrap_err();
1512        assert!(
1513            !err.to_string().contains("Missing Region"),
1514            "should not fail with Missing Region; region was provided. got: {err}"
1515        );
1516    }
1517
1518    // Test that an invalid aws_provider_scheme value produces a clear error.
1519    #[test]
1520    fn test_provider_scheme_invalid_value() {
1521        let opts = StorageOptions::new(HashMap::from([(
1522            "aws_provider_scheme".to_string(),
1523            "magic".to_string(),
1524        )]));
1525        let result = opts.aws_provider_scheme();
1526        assert!(result.is_err());
1527        assert!(result.unwrap_err().to_string().contains("magic"));
1528    }
1529
1530    // Test that no aws_provider_scheme falls through to DefaultCredentialsChain without error.
1531    #[tokio::test]
1532    async fn test_no_provider_scheme_uses_default_chain() {
1533        let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1534
1535        let result = build_aws_credential(
1536            Duration::from_secs(300),
1537            None,
1538            Some(&opts),
1539            Some("us-east-1".to_string()),
1540            None,
1541            None,
1542        )
1543        .await;
1544        assert!(result.is_ok());
1545    }
1546}