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::list::PaginatedListStore;
14use opendal::{Operator, services::S3};
15
16use aws_config::default_provider::credentials::DefaultCredentialsChain;
17use aws_config::ecs::EcsCredentialsProvider;
18use aws_config::provider_config::ProviderConfig;
19use aws_config::web_identity_token::WebIdentityTokenCredentialsProvider;
20use aws_config::{BehaviorVersion, Region, SdkConfig};
21use aws_credential_types::provider::ProvideCredentials;
22use object_store::{
23    ClientOptions, CredentialProvider, Result as ObjectStoreResult, RetryConfig,
24    StaticCredentialProvider,
25    aws::{
26        AmazonS3, AmazonS3Builder, AmazonS3ConfigKey, AwsCredential as ObjectStoreAwsCredential,
27        AwsCredentialProvider,
28    },
29};
30use tokio::sync::{Mutex, RwLock};
31use url::Url;
32
33use crate::object_store::opendal_store::OpendalStore;
34use crate::object_store::{
35    DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
36    ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
37    dynamic_credentials::{NamespaceCredentialsProvider, build_dynamic_credential_provider},
38    throttle::{AimdThrottleState, cloud_http_connector, shared_throttle_state, with_throttling},
39};
40use lance_core::error::{Error, Result};
41use lance_core::utils::parse::str_is_truthy;
42
43#[derive(Default, Debug)]
44pub struct AwsStoreProvider;
45
46struct ResolvedS3StorageOptions {
47    options: HashMap<AmazonS3ConfigKey, String>,
48    profile_region: Option<String>,
49}
50
51impl ResolvedS3StorageOptions {
52    fn new(
53        mut options: HashMap<AmazonS3ConfigKey, String>,
54        profile_config: Option<&SdkConfig>,
55    ) -> Self {
56        if effective_s3_endpoint(&options).is_none()
57            && let Some(endpoint) = profile_config.and_then(SdkConfig::endpoint_url)
58        {
59            options.insert(AmazonS3ConfigKey::Endpoint, endpoint.to_string());
60        }
61        let profile_region = profile_config
62            .and_then(SdkConfig::region)
63            .map(|region| region.as_ref().to_string());
64        Self {
65            options,
66            profile_region,
67        }
68    }
69
70    fn effective_endpoint(&self) -> Option<&str> {
71        effective_s3_endpoint(&self.options)
72    }
73
74    fn requires_constant_size_upload_parts(&self) -> bool {
75        self.effective_endpoint()
76            .is_some_and(|endpoint| endpoint.contains("r2.cloudflarestorage.com"))
77    }
78}
79
80impl AwsStoreProvider {
81    async fn build_amazon_s3_store(
82        &self,
83        base_path: &mut Url,
84        params: &ObjectStoreParams,
85        storage_options: &StorageOptions,
86        mut resolved_s3_options: ResolvedS3StorageOptions,
87        is_s3_express: bool,
88        throttle_state: Option<&AimdThrottleState>,
89        // Concrete rather than `dyn`, so the caller keeps the handle a paginated listing
90        // needs: `PaginatedListStore` is a separate trait from `ObjectStore`.
91    ) -> Result<Arc<AmazonS3>> {
92        // Use a low retry count since the AIMD throttle layer handles
93        // throttle recovery with its own retry loop.
94        let retry_config = RetryConfig {
95            backoff: Default::default(),
96            max_retries: storage_options.client_max_retries(),
97            retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()),
98        };
99
100        let region = resolve_s3_region(base_path, &resolved_s3_options).await?;
101
102        // Get accessor from params
103        let accessor = params.get_accessor();
104
105        let provider_scheme = storage_options.aws_provider_scheme()?;
106
107        let (aws_creds, region) = build_aws_credential(
108            params.s3_credentials_refresh_offset,
109            params.aws_credentials.clone(),
110            Some(&resolved_s3_options.options),
111            region,
112            accessor,
113            provider_scheme,
114        )
115        .await?;
116
117        // Published object_store requires an explicit mechanism for conditional
118        // S3 copies. Multipart completion uses If-None-Match without replacing
119        // an existing destination; preserve provider-specific overrides.
120        resolved_s3_options
121            .options
122            .entry(AmazonS3ConfigKey::CopyIfNotExists)
123            .or_insert_with(|| "multipart".to_string());
124
125        // Set S3Express flag if detected
126        if is_s3_express {
127            resolved_s3_options
128                .options
129                .insert(AmazonS3ConfigKey::S3Express, true.to_string());
130        }
131
132        // Compute the metrics label before rewriting the URL below so it
133        // matches the prefix the registry uses to key this store.
134        let store_prefix =
135            self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
136        let metrics_base = crate::object_store::metrics_base(&store_prefix, base_path);
137
138        // before creating the OSObjectStore we need to rewrite the url to drop ddb related parts
139        base_path.set_scheme("s3").unwrap();
140        base_path.set_query(None);
141
142        // we can't use parse_url_opts here because we need to manually set the credentials provider
143        let mut builder =
144            AmazonS3Builder::new().with_client_options(storage_options.client_options()?);
145        for (key, value) in resolved_s3_options.options {
146            builder = builder.with_config(key, value);
147        }
148        builder = builder
149            .with_url(base_path.as_ref())
150            .with_credentials(aws_creds)
151            .with_retry(retry_config)
152            .with_region(region);
153
154        builder = builder.with_http_connector(cloud_http_connector(throttle_state, metrics_base));
155
156        Ok(Arc::new(builder.build()?))
157    }
158
159    async fn build_opendal_s3_store(
160        &self,
161        base_path: &Url,
162        storage_options: &StorageOptions,
163        aws_credentials: Option<&AwsCredentialProvider>,
164    ) -> Result<Arc<dyn OSObjectStore>> {
165        let bucket = base_path
166            .host_str()
167            .ok_or_else(|| Error::invalid_input("S3 URL must contain bucket name"))?
168            .to_string();
169
170        let prefix = base_path.path().trim_start_matches('/').to_string();
171
172        // Start with all storage options as the config map
173        // OpenDAL will handle environment variables through its default credentials chain
174        let mut config_map: HashMap<String, String> = storage_options.0.clone();
175
176        if let Some(provider_scheme) = storage_options.aws_provider_scheme()? {
177            return Result::Err(Error::not_supported(format!(
178                "OpendalStore does not currently support an explicit provider_scheme (currently set to {:?})",
179                provider_scheme
180            )));
181        }
182
183        // The provider reaches the native store through the params, which this
184        // arm does not pass on, so a request here would be signed with whatever
185        // credentials OpenDAL resolves for itself instead.
186        if aws_credentials.is_some() {
187            return Result::Err(Error::not_supported(
188                "OpendalStore does not currently support an explicit aws_credentials provider"
189                    .to_string(),
190            ));
191        }
192
193        // Set required OpenDAL configuration
194        config_map.insert("bucket".to_string(), bucket);
195
196        if !prefix.is_empty() {
197            config_map.insert("root".to_string(), "/".to_string());
198        }
199
200        let operator = Operator::from_iter::<S3>(config_map)
201            .map_err(|e| Error::invalid_input(format!("Failed to create S3 operator: {:?}", e)))?;
202
203        Ok(Arc::new(OpendalStore::new(operator)) as Arc<dyn OSObjectStore>)
204    }
205}
206
207#[async_trait::async_trait]
208impl ObjectStoreProvider for AwsStoreProvider {
209    async fn new_store(
210        &self,
211        mut base_path: Url,
212        params: &ObjectStoreParams,
213    ) -> Result<ObjectStore> {
214        let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
215        let mut storage_options =
216            StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
217        storage_options.with_env_s3();
218        let download_retry_count = storage_options.download_retry_count();
219
220        let use_opendal = storage_options
221            .0
222            .get("use_opendal")
223            .map(|v| str_is_truthy(v))
224            .unwrap_or(false);
225
226        let profile_config = if std::env::var_os("AWS_PROFILE").is_some() {
227            Some(aws_config::load_defaults(BehaviorVersion::latest()).await)
228        } else {
229            None
230        };
231        let resolved_s3_options =
232            ResolvedS3StorageOptions::new(storage_options.as_s3_options(), profile_config.as_ref());
233
234        // Determine S3 Express and constant size upload parts before building the store
235        let is_s3_express = check_s3_express(&base_path, &storage_options);
236
237        let use_constant_size_upload_parts =
238            resolved_s3_options.requires_constant_size_upload_parts();
239
240        // Keyed like the registry cache so per-dataset stores share the bucket's budget.
241        let store_prefix =
242            self.calculate_object_store_prefix(&base_path, params.storage_options())?;
243        let throttle_state = shared_throttle_state(&store_prefix, params)?;
244
245        let (inner, paginated_lister) = if use_opendal {
246            // Use OpenDAL implementation
247            // Listed in full: no paginated lister covers OpenDAL yet.
248            (
249                self.build_opendal_s3_store(
250                    &base_path,
251                    &storage_options,
252                    params.aws_credentials.as_ref(),
253                )
254                .await?,
255                None,
256            )
257        } else {
258            // Use default Amazon S3 implementation
259            let store = self
260                .build_amazon_s3_store(
261                    &mut base_path,
262                    params,
263                    &storage_options,
264                    resolved_s3_options,
265                    is_s3_express,
266                    throttle_state.as_ref(),
267                )
268                .await?;
269            (
270                store.clone() as Arc<dyn OSObjectStore>,
271                Some(store as Arc<dyn PaginatedListStore>),
272            )
273        };
274        let (inner, paginated_lister) =
275            with_throttling(throttle_state, !use_opendal, inner, paginated_lister);
276
277        Ok(ObjectStore {
278            inner,
279            local_dir_operations: None,
280            scheme: String::from(base_path.scheme()),
281            block_size,
282            max_iop_size: *DEFAULT_MAX_IOP_SIZE,
283            use_constant_size_upload_parts,
284            list_is_lexically_ordered: !is_s3_express,
285            io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
286            download_retry_count,
287            io_tracker: Default::default(),
288            store_prefix: self
289                .calculate_object_store_prefix(&base_path, params.storage_options())?,
290            paginated_lister,
291        })
292    }
293}
294
295/// Check if the storage is S3 Express
296fn check_s3_express(url: &Url, storage_options: &StorageOptions) -> bool {
297    storage_options
298        .0
299        .get("s3_express")
300        .map(|v| str_is_truthy(v))
301        .unwrap_or(false)
302        || url.authority().ends_with("--x-s3")
303}
304
305fn effective_s3_endpoint(storage_options: &HashMap<AmazonS3ConfigKey, String>) -> Option<&str> {
306    storage_options
307        .get(&AmazonS3ConfigKey::S3Endpoint)
308        .or_else(|| storage_options.get(&AmazonS3ConfigKey::Endpoint))
309        .map(String::as_str)
310}
311
312/// Figure out the S3 region of the bucket.
313///
314/// This resolves in order of precedence:
315/// 1. The region provided in the storage options
316/// 2. The selected AWS profile's region when a custom endpoint is configured
317/// 3. (If endpoint is not set), the region returned by the S3 API for the bucket
318///
319/// It can return None if no region is provided and the endpoint is set.
320async fn resolve_s3_region(
321    url: &Url,
322    resolved_s3_options: &ResolvedS3StorageOptions,
323) -> Result<Option<String>> {
324    let storage_options = &resolved_s3_options.options;
325    if let Some(region) = storage_options.get(&AmazonS3ConfigKey::Region) {
326        Ok(Some(region.clone()))
327    } else if resolved_s3_options.effective_endpoint().is_none() {
328        // If no endpoint is set, we can assume this is AWS S3 and the region
329        // can be resolved from the bucket.
330        let bucket = url.host_str().ok_or_else(|| {
331            Error::invalid_input(format!("Could not parse bucket from url: {}", url))
332        })?;
333
334        let mut client_options = ClientOptions::default();
335        for (key, value) in storage_options {
336            if let AmazonS3ConfigKey::Client(client_key) = key {
337                client_options = client_options.with_config(*client_key, value.clone());
338            }
339        }
340
341        let bucket_region =
342            object_store::aws::resolve_bucket_region(bucket, &client_options).await?;
343        Ok(Some(bucket_region))
344    } else {
345        Ok(resolved_s3_options.profile_region.clone())
346    }
347}
348
349/// Selects which AWS credential provider to use for a dataset.
350///
351/// When set, overrides automatic credential resolution for everything except an
352/// explicitly-supplied `credentials` provider or `storage_options_accessor`.
353#[derive(Debug, Clone, PartialEq)]
354pub enum AwsProviderScheme {
355    /// Require static access-key credentials (`aws_access_key_id` +
356    /// `aws_secret_access_key`). Returns an error if they are absent.
357    Token,
358    /// Use the ECS/Pod Identity container credential endpoint.
359    /// The endpoint URI is read from the `AWS_CONTAINER_CREDENTIALS_FULL_URI`
360    /// or `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` environment variables.
361    Ecs,
362    /// Use IRSA (IAM Roles for Service Accounts) web identity token credentials.
363    /// The token file and role ARN are read from the `AWS_WEB_IDENTITY_TOKEN_FILE`
364    /// and `AWS_ROLE_ARN` environment variables.
365    Irsa,
366}
367
368/// Build AWS credentials
369///
370/// This resolves credentials from the following sources in order:
371/// 1. An explicit `credentials` provider
372/// 2. An explicit `storage_options_accessor` with a provider
373/// 3. If `provider_scheme` is set:
374///    - [`AwsProviderScheme::Token`]: static access-key credentials (error if absent)
375///    - [`AwsProviderScheme::Ecs`]: ECS container credential provider
376///    - [`AwsProviderScheme::Irsa`]: web identity token (IRSA) provider
377/// 4. Static access-key credentials from `storage_options`, if present
378/// 5. The default AWS credential provider chain
379///
380/// # Storage Options Accessor
381///
382/// When `storage_options_accessor` is provided and has a dynamic provider,
383/// credentials are fetched and cached by the accessor with automatic refresh
384/// before expiration.
385///
386/// `credentials_refresh_offset` is the amount of time before expiry to refresh credentials.
387pub async fn build_aws_credential(
388    credentials_refresh_offset: Duration,
389    credentials: Option<AwsCredentialProvider>,
390    storage_options: Option<&HashMap<AmazonS3ConfigKey, String>>,
391    region: Option<String>,
392    storage_options_accessor: Option<Arc<StorageOptionsAccessor>>,
393    provider_scheme: Option<AwsProviderScheme>,
394) -> Result<(AwsCredentialProvider, String)> {
395    use aws_config::meta::region::RegionProviderChain;
396    const DEFAULT_REGION: &str = "us-west-2";
397
398    let region = if let Some(region) = region {
399        region
400    } else {
401        RegionProviderChain::default_provider()
402            .or_else(DEFAULT_REGION)
403            .region()
404            .await
405            .map(|r| r.as_ref().to_string())
406            .unwrap_or(DEFAULT_REGION.to_string())
407    };
408
409    // If the user supplied their own credential provider that takes top priority
410    if let Some(creds) = credentials {
411        return Ok((creds, region));
412    }
413
414    // Otherwise, if the user provided a storage_options_accessor, try and use that
415    if let Some(dynamic_creds) = build_dynamic_credential_provider::<ObjectStoreAwsCredential>(
416        storage_options_accessor.clone(),
417    )
418    .await?
419    {
420        return Ok((dynamic_creds, region));
421    }
422
423    // If the user provided a storage_options_accessor, then it must not have matched AWS.
424    // Log a message and ignore it.
425    if storage_options_accessor
426        .as_ref()
427        .is_some_and(|a| a.has_provider())
428    {
429        log::debug!(
430            "Storage options from provider do not contain explicit AWS credentials, \
431             falling back to default AWS credentials chain."
432        );
433    }
434
435    // If the caller specified an explicit provider scheme, use only that provider.
436    if let Some(scheme) = provider_scheme {
437        return match scheme {
438            AwsProviderScheme::Token => {
439                let creds = storage_options
440                    .and_then(extract_static_s3_credentials)
441                    .ok_or_else(|| {
442                        Error::invalid_input(
443                            "aws_provider_scheme=token requires aws_access_key_id \
444                             and aws_secret_access_key to be set",
445                        )
446                    })?;
447                Ok((Arc::new(creds), region))
448            }
449            AwsProviderScheme::Ecs => {
450                let provider = EcsCredentialsProvider::builder().build();
451                Ok((
452                    Arc::new(AwsCredentialAdapter::new(
453                        Arc::new(provider),
454                        credentials_refresh_offset,
455                    )),
456                    region,
457                ))
458            }
459            AwsProviderScheme::Irsa => {
460                let conf = ProviderConfig::default().with_region(Some(Region::new(region.clone())));
461                let provider = WebIdentityTokenCredentialsProvider::builder()
462                    .configure(&conf)
463                    .build();
464                Ok((
465                    Arc::new(AwsCredentialAdapter::new(
466                        Arc::new(provider),
467                        credentials_refresh_offset,
468                    )),
469                    region,
470                ))
471            }
472        };
473    }
474
475    if let Some(opts) = storage_options {
476        // Check for static credentials (access key & secret)
477        if let Some(creds) = extract_static_s3_credentials(opts) {
478            return Ok((Arc::new(creds), region));
479        }
480    }
481
482    let credentials_provider = DefaultCredentialsChain::builder().build().await;
483    Ok((
484        Arc::new(AwsCredentialAdapter::new(
485            Arc::new(credentials_provider),
486            credentials_refresh_offset,
487        )),
488        region,
489    ))
490}
491
492fn extract_static_s3_credentials(
493    options: &HashMap<AmazonS3ConfigKey, String>,
494) -> Option<StaticCredentialProvider<ObjectStoreAwsCredential>> {
495    let key_id = options.get(&AmazonS3ConfigKey::AccessKeyId).cloned();
496    let secret_key = options.get(&AmazonS3ConfigKey::SecretAccessKey).cloned();
497    let token = options.get(&AmazonS3ConfigKey::Token).cloned();
498    match (key_id, secret_key, token) {
499        (Some(key_id), Some(secret_key), token) => {
500            Some(StaticCredentialProvider::new(ObjectStoreAwsCredential {
501                key_id,
502                secret_key,
503                token,
504            }))
505        }
506        _ => None,
507    }
508}
509
510/// Adapt an AWS SDK cred into object_store credentials
511#[derive(Debug)]
512pub struct AwsCredentialAdapter {
513    pub inner: Arc<dyn ProvideCredentials>,
514
515    // The cached credential, if any.
516    credentials: RwLock<Option<Arc<aws_credential_types::Credentials>>>,
517
518    // Serialize refreshes without holding the cache lock during network requests.
519    refresh_lock: Mutex<()>,
520
521    // The amount of time before expiry to refresh credentials
522    credentials_refresh_offset: Duration,
523}
524
525impl AwsCredentialAdapter {
526    pub fn new(
527        provider: Arc<dyn ProvideCredentials>,
528        credentials_refresh_offset: Duration,
529    ) -> Self {
530        Self {
531            inner: provider,
532            credentials: RwLock::new(None),
533            refresh_lock: Mutex::new(()),
534            credentials_refresh_offset,
535        }
536    }
537}
538
539/// Convert std::time::SystemTime from AWS SDK to our mockable SystemTime
540fn to_system_time(time: std::time::SystemTime) -> SystemTime {
541    let duration_since_epoch = time
542        .duration_since(std::time::UNIX_EPOCH)
543        .expect("time should be after UNIX_EPOCH");
544    UNIX_EPOCH + duration_since_epoch
545}
546
547#[async_trait::async_trait]
548impl CredentialProvider for AwsCredentialAdapter {
549    type Credential = ObjectStoreAwsCredential;
550
551    async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
552        let cached = self.credentials.read().await.clone();
553        let credentials = match cached {
554            None => self.must_refresh().await?,
555            Some(credentials) => {
556                let (is_expired, is_stale) = self.check_staleness(&credentials);
557                if is_expired {
558                    self.must_refresh().await?
559                } else if is_stale {
560                    self.maybe_refresh(credentials).await?
561                } else {
562                    credentials
563                }
564            }
565        };
566        Ok(Arc::new(Self::Credential {
567            key_id: credentials.access_key_id().to_string(),
568            secret_key: credentials.secret_access_key().to_string(),
569            token: credentials.session_token().map(|s| s.to_string()),
570        }))
571    }
572}
573
574impl AwsCredentialAdapter {
575    // Return a tuple of (credential expired, credential stale).
576    fn check_staleness(&self, credentials: &aws_credential_types::Credentials) -> (bool, bool) {
577        match credentials.expiry() {
578            None => (false, false),
579            Some(expiry) => {
580                let remaining = to_system_time(expiry).duration_since(SystemTime::now());
581                match remaining {
582                    Ok(remaining) => (
583                        remaining.is_zero(),
584                        remaining <= self.credentials_refresh_offset,
585                    ),
586                    Err(_) => (true, true),
587                }
588            }
589        }
590    }
591
592    /// Wait for an in-flight refresh, or fetch credentials ourselves.
593    async fn must_refresh(&self) -> ObjectStoreResult<Arc<aws_credential_types::Credentials>> {
594        let _lock = self.refresh_lock.lock().await;
595        if let Some(credentials) = self.credentials.read().await.clone()
596            && !self.check_staleness(&credentials).0
597        {
598            return Ok(credentials);
599        }
600        self.do_refresh().await
601    }
602
603    /// Let concurrent requests use valid cached credentials during a refresh.
604    async fn maybe_refresh(
605        &self,
606        old_credentials: Arc<aws_credential_types::Credentials>,
607    ) -> ObjectStoreResult<Arc<aws_credential_types::Credentials>> {
608        let Ok(_lock) = self.refresh_lock.try_lock() else {
609            return Ok(old_credentials);
610        };
611        if let Some(credentials) = self.credentials.read().await.clone()
612            && !Arc::ptr_eq(&credentials, &old_credentials)
613            && !self.check_staleness(&credentials).0
614        {
615            return Ok(credentials);
616        }
617        match self.do_refresh().await {
618            Ok(credentials) => Ok(credentials),
619            // The cached credentials may have expired while the request was in flight.
620            Err(_) if !self.check_staleness(&old_credentials).0 => Ok(old_credentials),
621            Err(error) => Err(error),
622        }
623    }
624
625    async fn do_refresh(&self) -> ObjectStoreResult<Arc<aws_credential_types::Credentials>> {
626        let credentials = Arc::new(
627            self.inner
628                .provide_credentials()
629                .await
630                .map_err(|e| Error::io(format!("Failed to get AWS credentials: {:?}", e)))?,
631        );
632        *self.credentials.write().await = Some(credentials.clone());
633        Ok(credentials)
634    }
635}
636
637impl StorageOptions {
638    /// Add values from the environment to storage options.
639    ///
640    /// Explicitly-set options (including aliases, case variants, and empty-string
641    /// sentinels) always take precedence over env vars.
642    pub fn with_env_s3(&mut self) {
643        self.merge_env_options(std::env::vars_os(), |key| {
644            AmazonS3ConfigKey::from_str(key)
645                .ok()
646                .map(|key| key.as_ref().to_string())
647        });
648    }
649
650    /// Return options recognized by [`AmazonS3ConfigKey`], ignoring unknown keys.
651    ///
652    /// When aliases conflict, the exact canonical name (such as `aws_region`)
653    /// wins, followed by case variants of that name. Remaining ties use the
654    /// lexicographically smallest original key. Empty values are preserved.
655    ///
656    /// ```
657    /// use std::collections::HashMap;
658    /// use lance_io::object_store::StorageOptions;
659    /// use object_store::aws::AmazonS3ConfigKey;
660    ///
661    /// let options = StorageOptions(HashMap::from([
662    ///     ("region".into(), "us-east-1".into()),
663    ///     ("aws_region".into(), "eu-west-1".into()),
664    /// ]));
665    /// assert_eq!(options.as_s3_options()[&AmazonS3ConfigKey::Region], "eu-west-1");
666    /// ```
667    pub fn as_s3_options(&self) -> HashMap<AmazonS3ConfigKey, String> {
668        self.as_cloud_options()
669    }
670
671    /// Parse the `aws_provider_scheme` storage option, if set.
672    pub fn aws_provider_scheme(&self) -> Result<Option<AwsProviderScheme>> {
673        match self.0.get("aws_provider_scheme").map(|s| s.as_str()) {
674            None | Some("") => Ok(None),
675            Some("token") => Ok(Some(AwsProviderScheme::Token)),
676            Some("ecs") => Ok(Some(AwsProviderScheme::Ecs)),
677            Some("irsa") => Ok(Some(AwsProviderScheme::Irsa)),
678            Some(other) => Err(Error::invalid_input(format!(
679                "Invalid aws_provider_scheme '{}'. Valid values are: token, ecs, irsa",
680                other
681            ))),
682        }
683    }
684}
685
686impl ObjectStoreParams {
687    /// Create a new instance of [`ObjectStoreParams`] based on the AWS credentials.
688    pub fn with_aws_credentials(
689        aws_credentials: Option<AwsCredentialProvider>,
690        region: Option<String>,
691    ) -> Self {
692        let storage_options_accessor = region.map(|region| {
693            let opts: HashMap<String, String> =
694                [("region".into(), region)].iter().cloned().collect();
695            Arc::new(StorageOptionsAccessor::with_static_options(opts))
696        });
697        Self {
698            aws_credentials,
699            storage_options_accessor,
700            ..Default::default()
701        }
702    }
703}
704
705pub type DynamicStorageOptionsCredentialProvider =
706    NamespaceCredentialsProvider<ObjectStoreAwsCredential>;
707
708#[cfg(test)]
709mod tests {
710    use crate::object_store::ObjectStoreRegistry;
711    use crate::object_store::StorageOptionsProvider;
712    #[allow(deprecated)]
713    use aws_config::profile::profile_file::{ProfileFileKind, ProfileFiles};
714    use aws_credential_types::provider::error::CredentialsError;
715    use mock_instant::thread_local::MockClock;
716    use object_store::{ObjectStoreExt, path::Path};
717    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
718    use wiremock::matchers::{header, method, path, query_param};
719    use wiremock::{Mock, MockServer, ResponseTemplate};
720
721    use super::*;
722
723    #[rstest::rstest]
724    #[case::default(None, false)]
725    #[case::explicit_multipart(Some("multipart"), false)]
726    #[case::existing_destination(None, true)]
727    #[tokio::test]
728    async fn test_s3_conditional_copy(
729        #[case] copy_option: Option<&str>,
730        #[case] destination_exists: bool,
731    ) {
732        let server = MockServer::start().await;
733        let metadata = ResponseTemplate::new(200)
734            .insert_header("content-length", "3")
735            .insert_header("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT")
736            .insert_header("etag", "\"source\"");
737        Mock::given(method("HEAD"))
738            .and(path("/bucket/staging.lance"))
739            .respond_with(metadata.clone())
740            .expect(1)
741            .mount(&server)
742            .await;
743        Mock::given(method("POST"))
744            .and(path("/bucket/final.lance"))
745            .and(query_param("uploads", ""))
746            .respond_with(ResponseTemplate::new(200).set_body_string(
747                "<InitiateMultipartUploadResult><UploadId>upload-1</UploadId></InitiateMultipartUploadResult>",
748            ))
749            .expect(1)
750            .mount(&server)
751            .await;
752        Mock::given(method("PUT"))
753            .and(query_param("uploadId", "upload-1"))
754            .and(query_param("partNumber", "1"))
755            .and(header("x-amz-copy-source", "bucket/staging.lance"))
756            .respond_with(
757                ResponseTemplate::new(200)
758                    .set_body_string("<CopyPartResult><ETag>\"part\"</ETag></CopyPartResult>"),
759            )
760            .expect(1)
761            .mount(&server)
762            .await;
763        let completion = if destination_exists {
764            ResponseTemplate::new(412).set_body_string(
765                "<Error><Code>PreconditionFailed</Code><Message>exists</Message></Error>",
766            )
767        } else {
768            ResponseTemplate::new(200).set_body_string(
769                "<CompleteMultipartUploadResult><ETag>\"destination\"</ETag></CompleteMultipartUploadResult>",
770            )
771        };
772        Mock::given(method("POST"))
773            .and(query_param("uploadId", "upload-1"))
774            .and(header("if-none-match", "*"))
775            .respond_with(completion)
776            .expect(1)
777            .mount(&server)
778            .await;
779        Mock::given(method("HEAD"))
780            .and(path("/bucket/final.lance"))
781            .respond_with(metadata)
782            .expect(u64::from(destination_exists))
783            .mount(&server)
784            .await;
785        Mock::given(method("DELETE"))
786            .and(query_param("uploadId", "upload-1"))
787            .respond_with(ResponseTemplate::new(204))
788            .expect(u64::from(destination_exists))
789            .mount(&server)
790            .await;
791
792        let mut options = HashMap::from([
793            ("aws_access_key_id".into(), "test".into()),
794            ("aws_secret_access_key".into(), "test".into()),
795            ("aws_region".into(), "us-east-1".into()),
796            ("aws_endpoint".into(), server.uri()),
797            ("aws_allow_http".into(), "true".into()),
798        ]);
799        if let Some(value) = copy_option {
800            options.insert("aws_copy_if_not_exists".into(), value.into());
801        }
802        let params = ObjectStoreParams {
803            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
804                options,
805            ))),
806            ..Default::default()
807        };
808        let (store, _) = ObjectStore::from_uri_and_params(
809            Arc::new(ObjectStoreRegistry::default()),
810            "s3://bucket",
811            &params,
812        )
813        .await
814        .unwrap();
815        let result = store
816            .copy_if_not_exists(&Path::from("staging.lance"), &Path::from("final.lance"))
817            .await
818            .unwrap();
819        assert_eq!(result.size, 3);
820    }
821
822    #[tokio::test]
823    async fn test_s3_conditional_copy_preserves_explicit_header() {
824        let server = MockServer::start().await;
825        Mock::given(method("PUT"))
826            .and(path("/bucket/final.lance"))
827            .and(header("x-amz-copy-source", "bucket/staging.lance"))
828            .and(header("custom-condition", "absent"))
829            .respond_with(ResponseTemplate::new(200).set_body_string(
830                "<CopyObjectResult><ETag>\"destination\"</ETag></CopyObjectResult>",
831            ))
832            .expect(1)
833            .mount(&server)
834            .await;
835        let options = StorageOptions(HashMap::from([
836            ("aws_access_key_id".into(), "test".into()),
837            ("aws_secret_access_key".into(), "test".into()),
838            ("aws_region".into(), "us-east-1".into()),
839            ("aws_endpoint".into(), server.uri()),
840            ("aws_allow_http".into(), "true".into()),
841            (
842                "aws_copy_if_not_exists".into(),
843                "header:custom-condition:absent".into(),
844            ),
845        ]));
846        let store = AwsStoreProvider
847            .build_amazon_s3_store(
848                &mut Url::parse("s3://bucket").unwrap(),
849                &ObjectStoreParams::default(),
850                &options,
851                ResolvedS3StorageOptions::new(options.as_s3_options(), None),
852                false,
853                None,
854            )
855            .await
856            .unwrap();
857        store
858            .copy_if_not_exists(&Path::from("staging.lance"), &Path::from("final.lance"))
859            .await
860            .unwrap();
861    }
862
863    #[rstest::rstest]
864    #[case::canonical("aws_region", "region")]
865    #[case::canonical_over_case_variant("aws_region", "AWS_REGION")]
866    #[case::canonical_case_tie("AWS_REGION", "Aws_Region")]
867    #[case::case_variant_over_alias("AWS_REGION", "region")]
868    #[case::lexical_alias("REGION", "region")]
869    fn test_storage_options_alias_precedence(
870        #[case] preferred_key: &str,
871        #[case] conflicting_key: &str,
872        #[values("preferred", "")] preferred_value: &str,
873        #[values(false, true)] is_reversed: bool,
874    ) {
875        // Exercise fresh hash seeds as well as both insertion orders.
876        for _ in 0..16 {
877            let mut entries = [
878                (preferred_key.to_string(), preferred_value.to_string()),
879                (conflicting_key.to_string(), "conflicting".to_string()),
880                ("lance_custom_option".to_string(), "ignored".to_string()),
881            ];
882            if is_reversed {
883                entries.reverse();
884            }
885            let options = StorageOptions(HashMap::from(entries));
886            assert_eq!(
887                options.as_s3_options(),
888                HashMap::from([(AmazonS3ConfigKey::Region, preferred_value.to_string())])
889            );
890        }
891    }
892
893    #[derive(Debug, Default)]
894    struct MockAwsCredentialsProvider {
895        called: AtomicBool,
896    }
897
898    #[async_trait::async_trait]
899    impl CredentialProvider for MockAwsCredentialsProvider {
900        type Credential = ObjectStoreAwsCredential;
901
902        async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
903            self.called.store(true, Ordering::Relaxed);
904            Ok(Arc::new(Self::Credential {
905                key_id: "".to_string(),
906                secret_key: "".to_string(),
907                token: None,
908            }))
909        }
910    }
911
912    #[allow(deprecated)]
913    async fn load_test_profile(config: &str) -> SdkConfig {
914        let profile_files = ProfileFiles::builder()
915            .with_contents(ProfileFileKind::Config, config)
916            .build();
917        aws_config::defaults(BehaviorVersion::latest())
918            .profile_name("selected")
919            .profile_files(profile_files)
920            .load()
921            .await
922    }
923
924    #[derive(Debug)]
925    struct FailingAwsCredentialsProvider;
926
927    impl ProvideCredentials for FailingAwsCredentialsProvider {
928        fn provide_credentials<'a>(
929            &'a self,
930        ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
931        where
932            Self: 'a,
933        {
934            aws_credential_types::provider::future::ProvideCredentials::new(async {
935                Err(CredentialsError::provider_error(Box::new(
936                    std::io::Error::other("Glue credential endpoint unavailable"),
937                )))
938            })
939        }
940    }
941
942    #[tokio::test]
943    async fn test_aws_credential_failure_is_io_error() {
944        let provider = AwsCredentialAdapter::new(
945            Arc::new(FailingAwsCredentialsProvider),
946            Duration::from_secs(60),
947        );
948
949        let error = provider.get_credential().await.unwrap_err();
950        let object_store::Error::Generic { source, .. } = &error else {
951            panic!("expected a generic object store error, got {error}");
952        };
953        assert!(matches!(
954            source.downcast_ref::<Error>(),
955            Some(Error::IO { .. })
956        ));
957
958        let message = error.to_string();
959        assert!(message.contains("Failed to get AWS credentials"));
960        assert!(message.contains("Glue credential endpoint unavailable"));
961        assert!(!message.contains("Encountered internal error"));
962    }
963
964    #[derive(Debug, Default)]
965    struct RefreshingAwsCredentialsProvider {
966        calls: AtomicUsize,
967        fail: AtomicBool,
968        advance_seconds: u64,
969    }
970
971    impl ProvideCredentials for RefreshingAwsCredentialsProvider {
972        fn provide_credentials<'a>(
973            &'a self,
974        ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
975        where
976            Self: 'a,
977        {
978            aws_credential_types::provider::future::ProvideCredentials::new(async {
979                let call = self.calls.fetch_add(1, Ordering::Relaxed);
980                tokio::task::yield_now().await;
981                MockClock::advance_system_time(Duration::from_secs(self.advance_seconds));
982                if self.fail.load(Ordering::Relaxed) {
983                    return Err(CredentialsError::provider_error(std::io::Error::other(
984                        "STS unavailable",
985                    )));
986                }
987                Ok(aws_credential_types::Credentials::new(
988                    format!("key-{call}"),
989                    "secret",
990                    Some("token".to_string()),
991                    Some(
992                        std::time::UNIX_EPOCH + MockClock::system_time() + Duration::from_secs(120),
993                    ),
994                    "test",
995                ))
996            })
997        }
998    }
999
1000    #[rstest::rstest]
1001    #[case::fresh(Some(120), 0, true, 0)]
1002    #[case::no_expiry(None, 0, true, 0)]
1003    #[case::refresh_boundary(Some(60), 0, true, 1)]
1004    #[case::stale(Some(30), 0, true, 1)]
1005    #[case::expires_during_refresh(Some(30), 30, false, 1)]
1006    #[case::expiry_boundary(Some(0), 0, false, 1)]
1007    #[case::expired(Some(-1), 0, false, 1)]
1008    #[tokio::test]
1009    async fn test_aws_credential_refresh_failure(
1010        #[case] expires_in: Option<i64>,
1011        #[case] advance_seconds: u64,
1012        #[case] succeeds: bool,
1013        #[case] expected_calls: usize,
1014    ) {
1015        MockClock::set_system_time(Duration::from_secs(100_000));
1016        let inner = Arc::new(RefreshingAwsCredentialsProvider {
1017            fail: AtomicBool::new(true),
1018            advance_seconds,
1019            ..Default::default()
1020        });
1021        let provider = AwsCredentialAdapter::new(inner.clone(), Duration::from_secs(60));
1022        *provider.credentials.write().await =
1023            Some(Arc::new(aws_credential_types::Credentials::new(
1024                "cached-key",
1025                "cached-secret",
1026                Some("cached-token".to_string()),
1027                expires_in.map(|seconds| {
1028                    std::time::UNIX_EPOCH + Duration::from_secs((100_000 + seconds) as u64)
1029                }),
1030                "test",
1031            )));
1032
1033        let result = provider.get_credential().await;
1034        if succeeds {
1035            let credentials = result.unwrap();
1036            assert_eq!(credentials.key_id, "cached-key");
1037            assert_eq!(credentials.secret_key, "cached-secret");
1038            assert_eq!(credentials.token.as_deref(), Some("cached-token"));
1039        } else {
1040            let error = result.unwrap_err();
1041            assert!(matches!(error, object_store::Error::Generic { .. }));
1042            assert!(error.to_string().contains("STS unavailable"));
1043        }
1044        assert_eq!(inner.calls.load(Ordering::Relaxed), expected_calls);
1045    }
1046
1047    #[tokio::test]
1048    async fn test_aws_credential_concurrent_refresh() {
1049        MockClock::set_system_time(Duration::from_secs(100_000));
1050        let inner = Arc::new(RefreshingAwsCredentialsProvider::default());
1051        let provider = AwsCredentialAdapter::new(inner.clone(), Duration::from_secs(60));
1052
1053        let (first, second) = tokio::join!(provider.get_credential(), provider.get_credential());
1054        assert_eq!(first.unwrap().key_id, "key-0");
1055        assert_eq!(second.unwrap().key_id, "key-0");
1056        assert_eq!(inner.calls.load(Ordering::Relaxed), 1);
1057
1058        MockClock::advance_system_time(Duration::from_secs(60));
1059        let (refreshed, cached) =
1060            tokio::join!(provider.get_credential(), provider.get_credential());
1061        assert_eq!(refreshed.unwrap().key_id, "key-1");
1062        assert_eq!(cached.unwrap().key_id, "key-0");
1063        assert_eq!(provider.get_credential().await.unwrap().key_id, "key-1");
1064        assert_eq!(inner.calls.load(Ordering::Relaxed), 2);
1065
1066        MockClock::advance_system_time(Duration::from_secs(120));
1067        let (first, second) = tokio::join!(provider.get_credential(), provider.get_credential());
1068        assert_eq!(first.unwrap().key_id, "key-2");
1069        assert_eq!(second.unwrap().key_id, "key-2");
1070        assert_eq!(inner.calls.load(Ordering::Relaxed), 3);
1071    }
1072
1073    #[tokio::test]
1074    async fn test_injected_aws_creds_option_is_used() {
1075        let mock_provider = Arc::new(MockAwsCredentialsProvider::default());
1076        let registry = Arc::new(ObjectStoreRegistry::default());
1077
1078        let params = ObjectStoreParams {
1079            aws_credentials: Some(mock_provider.clone() as AwsCredentialProvider),
1080            ..ObjectStoreParams::default()
1081        };
1082
1083        // Not called yet
1084        assert!(!mock_provider.called.load(Ordering::Relaxed));
1085
1086        let (store, _) = ObjectStore::from_uri_and_params(registry, "s3://not-a-bucket", &params)
1087            .await
1088            .unwrap();
1089
1090        // fails, but we don't care
1091        let _ = store
1092            .open(&Path::parse("/").unwrap())
1093            .await
1094            .unwrap()
1095            .get_range(0..1)
1096            .await;
1097
1098        // Not called yet
1099        assert!(mock_provider.called.load(Ordering::Relaxed));
1100    }
1101
1102    #[tokio::test]
1103    async fn test_resolve_s3_region_from_aws_profile() {
1104        let profile_config = load_test_profile(
1105            "[profile selected]\n\
1106             region = us-west-004\n\
1107             endpoint_url = https://s3.us-west-004.backblazeb2.com\n\
1108             aws_access_key_id = test-key\n\
1109             aws_secret_access_key = test-secret",
1110        )
1111        .await;
1112        let url = Url::parse("s3://test-bucket/path").unwrap();
1113
1114        let resolved_s3_options =
1115            ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
1116        let region = resolve_s3_region(&url, &resolved_s3_options).await.unwrap();
1117
1118        assert_eq!(region.as_deref(), Some("us-west-004"));
1119        assert_eq!(
1120            resolved_s3_options
1121                .options
1122                .get(&AmazonS3ConfigKey::Endpoint),
1123            Some(&"https://s3.us-west-004.backblazeb2.com".to_string())
1124        );
1125
1126        let explicit_options = HashMap::from([
1127            (AmazonS3ConfigKey::Region, "explicit-region".to_string()),
1128            (
1129                AmazonS3ConfigKey::Endpoint,
1130                "https://explicit.example.com".to_string(),
1131            ),
1132        ]);
1133        let resolved_s3_options =
1134            ResolvedS3StorageOptions::new(explicit_options, Some(&profile_config));
1135        let region = resolve_s3_region(&url, &resolved_s3_options).await.unwrap();
1136
1137        assert_eq!(region.as_deref(), Some("explicit-region"));
1138        assert_eq!(
1139            resolved_s3_options
1140                .options
1141                .get(&AmazonS3ConfigKey::Endpoint),
1142            Some(&"https://explicit.example.com".to_string())
1143        );
1144    }
1145
1146    #[tokio::test]
1147    async fn test_region_only_aws_profile_preserves_bucket_discovery() {
1148        let profile_config = load_test_profile("[profile selected]\nregion = us-east-1").await;
1149        let resolved_s3_options =
1150            ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
1151
1152        let url = Url::parse("s3:///path").unwrap();
1153        let error = resolve_s3_region(&url, &resolved_s3_options)
1154            .await
1155            .unwrap_err();
1156
1157        assert!(matches!(error, Error::InvalidInput { .. }));
1158        assert!(error.to_string().contains("Could not parse bucket"));
1159    }
1160
1161    #[tokio::test]
1162    async fn test_r2_aws_profile_requires_constant_size_upload_parts() {
1163        let profile_config = load_test_profile(
1164            "[profile selected]\n\
1165             region = auto\n\
1166             endpoint_url = https://account.r2.cloudflarestorage.com",
1167        )
1168        .await;
1169        let resolved_s3_options =
1170            ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
1171
1172        assert!(resolved_s3_options.requires_constant_size_upload_parts());
1173    }
1174
1175    #[test]
1176    fn test_s3_path_parsing() {
1177        let provider = AwsStoreProvider;
1178
1179        let cases = [
1180            ("s3://bucket/path/to/file", "path/to/file"),
1181            // for non ASCII string tests: the URL encodes them, extract_path must decode back
1182            ("s3://bucket/测试path/to/file", "测试path/to/file"),
1183            ("s3://bucket/path/&to/file", "path/&to/file"),
1184            ("s3://bucket/path/=to/file", "path/=to/file"),
1185            (
1186                "s3+ddb://bucket/path/to/file?ddbTableName=test",
1187                "path/to/file",
1188            ),
1189        ];
1190
1191        for (uri, expected_path) in cases {
1192            let url = Url::parse(uri).unwrap();
1193            let path = provider.extract_path(&url).unwrap();
1194            // extract_path decodes url.path(), so the Path stores the raw (decoded)
1195            // string. Path::parse keeps its input verbatim, matching that, whereas
1196            // Path::from would percent-encode non-ASCII bytes and not match.
1197            let expected_path = Path::parse(expected_path).unwrap();
1198            assert_eq!(path, expected_path)
1199        }
1200    }
1201
1202    // Regression test for https://github.com/lance-format/lance/issues/6643
1203    // extract_path must NOT double-encode paths that contain non-ASCII characters.
1204    // url.path() returns a percent-encoded string; we must decode it back to raw
1205    // UTF-8 before storing it in a Path, so the object store HTTP client can apply
1206    // a single, correct percent-encoding when building the request URL.
1207    #[test]
1208    fn test_s3_non_ascii_path_no_double_encoding() {
1209        let provider = AwsStoreProvider;
1210
1211        // "s3://bucket/中文路径" → url.path() == "/%E4%B8%AD%E6%96%87%E8%B7%AF%E5%BE%84".
1212        // The buggy Path::parse(url.path()) stored "%E4%B8%AD..." verbatim; the S3
1213        // client then percent-encodes the '%' again, yielding "%25E4%25B8%25AD...".
1214        // With Path::from_url_path the Path stores the decoded UTF-8 instead.
1215        let url = Url::parse("s3://bucket/中文路径").unwrap();
1216        let path = provider.extract_path(&url).unwrap();
1217
1218        // The Path must hold the decoded UTF-8, not the percent-encoded form.
1219        assert_eq!(path.as_ref(), "中文路径");
1220    }
1221
1222    #[test]
1223    fn test_is_s3_express() {
1224        let cases = [
1225            (
1226                "s3://bucket/path/to/file",
1227                HashMap::from([("s3_express".to_string(), "true".to_string())]),
1228                true,
1229            ),
1230            (
1231                "s3://bucket/path/to/file",
1232                HashMap::from([("s3_express".to_string(), "false".to_string())]),
1233                false,
1234            ),
1235            ("s3://bucket/path/to/file", HashMap::from([]), false),
1236            (
1237                "s3://bucket--x-s3/path/to/file",
1238                HashMap::from([("s3_express".to_string(), "true".to_string())]),
1239                true,
1240            ),
1241            (
1242                "s3://bucket--x-s3/path/to/file",
1243                HashMap::from([("s3_express".to_string(), "false".to_string())]),
1244                true, // URL takes precedence
1245            ),
1246            ("s3://bucket--x-s3/path/to/file", HashMap::from([]), true),
1247        ];
1248
1249        for (uri, storage_map, expected) in cases {
1250            let url = Url::parse(uri).unwrap();
1251            let storage_options = StorageOptions(storage_map);
1252            let is_s3_express = check_s3_express(&url, &storage_options);
1253            assert_eq!(is_s3_express, expected);
1254        }
1255    }
1256
1257    #[tokio::test]
1258    async fn test_use_opendal_flag() {
1259        use crate::object_store::StorageOptionsAccessor;
1260        let provider = AwsStoreProvider;
1261        let url = Url::parse("s3://test-bucket/path").unwrap();
1262        let params_with_flag = ObjectStoreParams {
1263            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
1264                HashMap::from([
1265                    ("use_opendal".to_string(), "true".to_string()),
1266                    ("region".to_string(), "us-west-2".to_string()),
1267                ]),
1268            ))),
1269            ..Default::default()
1270        };
1271
1272        let store = provider
1273            .new_store(url.clone(), &params_with_flag)
1274            .await
1275            .unwrap();
1276        assert_eq!(store.scheme, "s3");
1277    }
1278
1279    /// A credential provider handed to the OpenDAL arm would be dropped, and
1280    /// the requests signed from the environment instead.
1281    #[tokio::test]
1282    async fn test_use_opendal_rejects_an_explicit_credential_provider() {
1283        use crate::object_store::StorageOptionsAccessor;
1284        let provider = AwsStoreProvider;
1285        let url = Url::parse("s3://test-bucket/path").unwrap();
1286        let params = ObjectStoreParams {
1287            aws_credentials: Some(
1288                Arc::new(MockAwsCredentialsProvider::default()) as AwsCredentialProvider
1289            ),
1290            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
1291                HashMap::from([
1292                    ("use_opendal".to_string(), "true".to_string()),
1293                    ("region".to_string(), "us-west-2".to_string()),
1294                ]),
1295            ))),
1296            ..Default::default()
1297        };
1298
1299        let err = provider.new_store(url, &params).await.unwrap_err();
1300        assert!(
1301            matches!(err, Error::NotSupported { .. }),
1302            "got: {err:?}, expected NotSupported"
1303        );
1304        assert!(err.to_string().contains("aws_credentials"), "got: {err}");
1305    }
1306
1307    /// S3 Express ignores `start-after` and does not list in key order, but it does hand back
1308    /// continuation tokens, which is all the native store resumes from — so an Express bucket
1309    /// pages like any other. The OpenDAL arm has no lister to page with at all.
1310    #[rstest::rstest]
1311    #[case::native("false", true)]
1312    #[case::opendal("true", false)]
1313    #[tokio::test]
1314    async fn test_s3_express_is_paged_by_continuation_token(
1315        #[case] use_opendal: &str,
1316        #[case] paginated: bool,
1317    ) {
1318        let provider = AwsStoreProvider;
1319        // Express bucket names carry their availability zone, which the S3 client validates.
1320        let url = Url::parse("s3://test-bucket--use1-az4--x-s3/path").unwrap();
1321        let params = ObjectStoreParams {
1322            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
1323                HashMap::from([
1324                    ("use_opendal".to_string(), use_opendal.to_string()),
1325                    ("region".to_string(), "us-east-1".to_string()),
1326                ]),
1327            ))),
1328            ..Default::default()
1329        };
1330
1331        let store = provider.new_store(url, &params).await.unwrap();
1332
1333        assert!(!store.list_is_lexically_ordered);
1334        assert_eq!(store.paginated_lister.is_some(), paginated);
1335    }
1336
1337    #[derive(Debug)]
1338    struct MockStorageOptionsProvider {
1339        call_count: Arc<RwLock<usize>>,
1340        expires_in_millis: Option<u64>,
1341    }
1342
1343    impl MockStorageOptionsProvider {
1344        fn new(expires_in_millis: Option<u64>) -> Self {
1345            Self {
1346                call_count: Arc::new(RwLock::new(0)),
1347                expires_in_millis,
1348            }
1349        }
1350
1351        async fn get_call_count(&self) -> usize {
1352            *self.call_count.read().await
1353        }
1354    }
1355
1356    #[async_trait::async_trait]
1357    impl StorageOptionsProvider for MockStorageOptionsProvider {
1358        async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
1359            let count = {
1360                let mut c = self.call_count.write().await;
1361                *c += 1;
1362                *c
1363            };
1364
1365            let mut options = HashMap::from([
1366                ("aws_access_key_id".to_string(), format!("AKID_{}", count)),
1367                (
1368                    "aws_secret_access_key".to_string(),
1369                    format!("SECRET_{}", count),
1370                ),
1371                ("aws_session_token".to_string(), format!("TOKEN_{}", count)),
1372            ]);
1373
1374            if let Some(expires_in) = self.expires_in_millis {
1375                let now_ms = SystemTime::now()
1376                    .duration_since(UNIX_EPOCH)
1377                    .unwrap()
1378                    .as_millis() as u64;
1379                let expires_at = now_ms + expires_in;
1380                options.insert("expires_at_millis".to_string(), expires_at.to_string());
1381            }
1382
1383            Ok(Some(options))
1384        }
1385
1386        fn provider_id(&self) -> String {
1387            let ptr = Arc::as_ptr(&self.call_count) as usize;
1388            format!("MockStorageOptionsProvider {{ id: {} }}", ptr)
1389        }
1390    }
1391
1392    #[tokio::test]
1393    async fn test_dynamic_credential_provider_with_initial_cache() {
1394        MockClock::set_system_time(Duration::from_secs(100_000));
1395
1396        let now_ms = MockClock::system_time().as_millis() as u64;
1397
1398        // Create a mock provider that returns credentials expiring in 10 minutes
1399        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1400            600_000, // Expires in 10 minutes
1401        )));
1402
1403        // Create initial options with cached credentials that expire in 10 minutes
1404        let expires_at = now_ms + 600_000; // 10 minutes from now
1405        let initial_options = HashMap::from([
1406            ("aws_access_key_id".to_string(), "AKID_CACHED".to_string()),
1407            (
1408                "aws_secret_access_key".to_string(),
1409                "SECRET_CACHED".to_string(),
1410            ),
1411            ("aws_session_token".to_string(), "TOKEN_CACHED".to_string()),
1412            ("expires_at_millis".to_string(), expires_at.to_string()),
1413            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1414        ]);
1415
1416        let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1417            mock.clone(),
1418            initial_options,
1419        );
1420
1421        // First call should use cached credentials (not expired yet)
1422        let cred = provider.get_credential().await.unwrap();
1423        assert_eq!(cred.key_id, "AKID_CACHED");
1424        assert_eq!(cred.secret_key, "SECRET_CACHED");
1425        assert_eq!(cred.token, Some("TOKEN_CACHED".to_string()));
1426
1427        // Should not have called the provider yet
1428        assert_eq!(mock.get_call_count().await, 0);
1429    }
1430
1431    #[tokio::test]
1432    async fn test_dynamic_credential_provider_with_expired_cache() {
1433        MockClock::set_system_time(Duration::from_secs(100_000));
1434
1435        let now_ms = MockClock::system_time().as_millis() as u64;
1436
1437        // Create a mock provider that returns credentials expiring in 10 minutes
1438        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1439            600_000, // Expires in 10 minutes
1440        )));
1441
1442        // Create initial options with credentials that expired 1 second ago
1443        let expired_time = now_ms - 1_000; // 1 second ago
1444        let initial_options = HashMap::from([
1445            ("aws_access_key_id".to_string(), "AKID_EXPIRED".to_string()),
1446            (
1447                "aws_secret_access_key".to_string(),
1448                "SECRET_EXPIRED".to_string(),
1449            ),
1450            ("expires_at_millis".to_string(), expired_time.to_string()),
1451            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1452        ]);
1453
1454        let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1455            mock.clone(),
1456            initial_options,
1457        );
1458
1459        // First call should fetch new credentials because cached ones are expired
1460        let cred = provider.get_credential().await.unwrap();
1461        assert_eq!(cred.key_id, "AKID_1");
1462        assert_eq!(cred.secret_key, "SECRET_1");
1463        assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1464
1465        // Should have called the provider once
1466        assert_eq!(mock.get_call_count().await, 1);
1467    }
1468
1469    #[tokio::test]
1470    async fn test_dynamic_credential_provider_refresh_lead_time() {
1471        MockClock::set_system_time(Duration::from_secs(100_000));
1472
1473        // Create a mock provider that returns credentials expiring in 30 seconds
1474        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1475            30_000, // Expires in 30 seconds
1476        )));
1477
1478        // Create credential provider with default 60 second refresh offset
1479        // This means credentials should be refreshed when they have less than 60 seconds left
1480        let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1481
1482        // First call should fetch credentials from provider (no initial cache)
1483        // Credentials expire in 30 seconds, which is less than our 60 second refresh offset,
1484        // so they should be considered "needs refresh" immediately
1485        let cred = provider.get_credential().await.unwrap();
1486        assert_eq!(cred.key_id, "AKID_1");
1487        assert_eq!(mock.get_call_count().await, 1);
1488
1489        // Second call should trigger refresh because credentials expire in 30 seconds
1490        // but our refresh lead time is 60 seconds (now + 60sec > expires_at)
1491        // The mock will return new credentials (AKID_2) with the same expiration
1492        let cred = provider.get_credential().await.unwrap();
1493        assert_eq!(cred.key_id, "AKID_2");
1494        assert_eq!(mock.get_call_count().await, 2);
1495    }
1496
1497    #[tokio::test]
1498    async fn test_dynamic_credential_provider_no_initial_cache() {
1499        MockClock::set_system_time(Duration::from_secs(100_000));
1500
1501        // Create a mock provider that returns credentials expiring in 2 minutes
1502        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1503            120_000, // Expires in 2 minutes
1504        )));
1505
1506        // Create credential provider without initial cache, using default 60 second refresh offset
1507        let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1508
1509        // First call should fetch from provider (call count = 1)
1510        let cred = provider.get_credential().await.unwrap();
1511        assert_eq!(cred.key_id, "AKID_1");
1512        assert_eq!(cred.secret_key, "SECRET_1");
1513        assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1514        assert_eq!(mock.get_call_count().await, 1);
1515
1516        // Second call should use cached credentials (not expired yet, still > 60 seconds remaining)
1517        let cred = provider.get_credential().await.unwrap();
1518        assert_eq!(cred.key_id, "AKID_1");
1519        assert_eq!(mock.get_call_count().await, 1); // Still 1, didn't fetch again
1520
1521        // Advance time to 90 seconds - should trigger refresh (within 60 sec refresh offset)
1522        // At this point, credentials expire in 30 seconds (< 60 sec offset)
1523        MockClock::set_system_time(Duration::from_secs(100_000 + 90));
1524        let cred = provider.get_credential().await.unwrap();
1525        assert_eq!(cred.key_id, "AKID_2");
1526        assert_eq!(cred.secret_key, "SECRET_2");
1527        assert_eq!(cred.token, Some("TOKEN_2".to_string()));
1528        assert_eq!(mock.get_call_count().await, 2);
1529
1530        // Advance time to 210 seconds total (90 + 120) - should trigger another refresh
1531        MockClock::set_system_time(Duration::from_secs(100_000 + 210));
1532        let cred = provider.get_credential().await.unwrap();
1533        assert_eq!(cred.key_id, "AKID_3");
1534        assert_eq!(cred.secret_key, "SECRET_3");
1535        assert_eq!(mock.get_call_count().await, 3);
1536    }
1537
1538    #[tokio::test]
1539    async fn test_dynamic_credential_provider_with_initial_options() {
1540        MockClock::set_system_time(Duration::from_secs(100_000));
1541
1542        let now_ms = MockClock::system_time().as_millis() as u64;
1543
1544        // Create a mock provider that returns credentials expiring in 10 minutes
1545        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1546            600_000, // Expires in 10 minutes
1547        )));
1548
1549        // Create initial options with expiration in 10 minutes
1550        let expires_at = now_ms + 600_000; // 10 minutes from now
1551        let initial_options = HashMap::from([
1552            ("aws_access_key_id".to_string(), "AKID_INITIAL".to_string()),
1553            (
1554                "aws_secret_access_key".to_string(),
1555                "SECRET_INITIAL".to_string(),
1556            ),
1557            ("aws_session_token".to_string(), "TOKEN_INITIAL".to_string()),
1558            ("expires_at_millis".to_string(), expires_at.to_string()),
1559            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1560        ]);
1561
1562        // Create credential provider with initial options
1563        let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1564            mock.clone(),
1565            initial_options,
1566        );
1567
1568        // First call should use the initial credential (not expired yet)
1569        let cred = provider.get_credential().await.unwrap();
1570        assert_eq!(cred.key_id, "AKID_INITIAL");
1571        assert_eq!(cred.secret_key, "SECRET_INITIAL");
1572        assert_eq!(cred.token, Some("TOKEN_INITIAL".to_string()));
1573
1574        // Should not have called the provider yet
1575        assert_eq!(mock.get_call_count().await, 0);
1576
1577        // Advance time to 6 minutes - this should trigger a refresh
1578        // (5 minute refresh offset means we refresh 5 minutes before expiration)
1579        MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1580        let cred = provider.get_credential().await.unwrap();
1581        assert_eq!(cred.key_id, "AKID_1");
1582        assert_eq!(cred.secret_key, "SECRET_1");
1583        assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1584
1585        // Should have called the provider once
1586        assert_eq!(mock.get_call_count().await, 1);
1587
1588        // Advance time to 11 minutes total - this should trigger another refresh
1589        MockClock::set_system_time(Duration::from_secs(100_000 + 660));
1590        let cred = provider.get_credential().await.unwrap();
1591        assert_eq!(cred.key_id, "AKID_2");
1592        assert_eq!(cred.secret_key, "SECRET_2");
1593        assert_eq!(cred.token, Some("TOKEN_2".to_string()));
1594
1595        // Should have called the provider twice
1596        assert_eq!(mock.get_call_count().await, 2);
1597
1598        // Advance time to 16 minutes total - this should trigger yet another refresh
1599        MockClock::set_system_time(Duration::from_secs(100_000 + 960));
1600        let cred = provider.get_credential().await.unwrap();
1601        assert_eq!(cred.key_id, "AKID_3");
1602        assert_eq!(cred.secret_key, "SECRET_3");
1603        assert_eq!(cred.token, Some("TOKEN_3".to_string()));
1604
1605        // Should have called the provider three times
1606        assert_eq!(mock.get_call_count().await, 3);
1607    }
1608
1609    #[tokio::test]
1610    async fn test_dynamic_credential_provider_concurrent_access() {
1611        // Create a mock provider with far future expiration
1612        let mock = Arc::new(MockStorageOptionsProvider::new(Some(9999999999999)));
1613
1614        let provider = Arc::new(DynamicStorageOptionsCredentialProvider::from_provider(
1615            mock.clone(),
1616        ));
1617
1618        // Spawn 10 concurrent tasks that all try to get credentials at the same time
1619        let mut handles = vec![];
1620        for i in 0..10 {
1621            let provider = provider.clone();
1622            let handle = tokio::spawn(async move {
1623                let cred = provider.get_credential().await.unwrap();
1624                // Verify we got the correct credentials (should all be AKID_1 from first fetch)
1625                assert_eq!(cred.key_id, "AKID_1");
1626                assert_eq!(cred.secret_key, "SECRET_1");
1627                assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1628                i // Return task number for verification
1629            });
1630            handles.push(handle);
1631        }
1632
1633        // Wait for all tasks to complete
1634        let results: Vec<_> = futures::future::join_all(handles)
1635            .await
1636            .into_iter()
1637            .map(|r| r.unwrap())
1638            .collect();
1639
1640        // Verify all 10 tasks completed successfully
1641        assert_eq!(results.len(), 10);
1642        for i in 0..10 {
1643            assert!(results.contains(&i));
1644        }
1645
1646        // The provider should have been called exactly once (first request triggers fetch,
1647        // subsequent requests use cache)
1648        let call_count = mock.get_call_count().await;
1649        assert_eq!(
1650            call_count, 1,
1651            "Provider should be called exactly once despite concurrent access"
1652        );
1653    }
1654
1655    #[tokio::test]
1656    async fn test_dynamic_credential_provider_concurrent_refresh() {
1657        MockClock::set_system_time(Duration::from_secs(100_000));
1658
1659        let now_ms = MockClock::system_time().as_millis() as u64;
1660
1661        // Create initial options with credentials that expired in the past (1000 seconds ago)
1662        let expires_at = now_ms - 1_000_000;
1663        let initial_options = HashMap::from([
1664            ("aws_access_key_id".to_string(), "AKID_OLD".to_string()),
1665            (
1666                "aws_secret_access_key".to_string(),
1667                "SECRET_OLD".to_string(),
1668            ),
1669            ("aws_session_token".to_string(), "TOKEN_OLD".to_string()),
1670            ("expires_at_millis".to_string(), expires_at.to_string()),
1671            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1672        ]);
1673
1674        // Mock will return credentials expiring in 1 hour
1675        let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1676            3_600_000, // Expires in 1 hour
1677        )));
1678
1679        let provider = Arc::new(
1680            DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1681                mock.clone(),
1682                initial_options,
1683            ),
1684        );
1685
1686        // Spawn 20 concurrent tasks that all try to get credentials at the same time
1687        // Since the initial credential is expired, they'll all try to refresh
1688        let mut handles = vec![];
1689        for i in 0..20 {
1690            let provider = provider.clone();
1691            let handle = tokio::spawn(async move {
1692                let cred = provider.get_credential().await.unwrap();
1693                // All should get the new credentials (AKID_1 from first fetch)
1694                assert_eq!(cred.key_id, "AKID_1");
1695                assert_eq!(cred.secret_key, "SECRET_1");
1696                assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1697                i
1698            });
1699            handles.push(handle);
1700        }
1701
1702        // Wait for all tasks to complete
1703        let results: Vec<_> = futures::future::join_all(handles)
1704            .await
1705            .into_iter()
1706            .map(|r| r.unwrap())
1707            .collect();
1708
1709        // Verify all 20 tasks completed successfully
1710        assert_eq!(results.len(), 20);
1711
1712        // The provider should have been called at least once, but possibly more times
1713        // due to the try_write mechanism and race conditions
1714        let call_count = mock.get_call_count().await;
1715        assert!(
1716            call_count >= 1,
1717            "Provider should be called at least once, was called {} times",
1718            call_count
1719        );
1720
1721        // It shouldn't be called 20 times though - the lock should prevent most concurrent fetches
1722        assert!(
1723            call_count < 10,
1724            "Provider should not be called too many times due to lock contention, was called {} times",
1725            call_count
1726        );
1727    }
1728
1729    #[tokio::test]
1730    async fn test_explicit_aws_credentials_takes_precedence_over_accessor() {
1731        // Create a mock storage options provider that should NOT be called
1732        let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1733
1734        // Create an accessor with the mock provider
1735        let accessor = Arc::new(StorageOptionsAccessor::with_provider(
1736            mock_storage_provider.clone(),
1737        ));
1738
1739        // Create an explicit AWS credentials provider
1740        let explicit_cred_provider = Arc::new(MockAwsCredentialsProvider::default());
1741
1742        // Build credentials with both aws_credentials AND accessor
1743        // The explicit aws_credentials should take precedence
1744        let (result, _region) = build_aws_credential(
1745            Duration::from_secs(300),
1746            Some(explicit_cred_provider.clone() as AwsCredentialProvider),
1747            None, // no storage_options
1748            Some("us-west-2".to_string()),
1749            Some(accessor),
1750            None,
1751        )
1752        .await
1753        .unwrap();
1754
1755        // Get credential from the result
1756        let cred = result.get_credential().await.unwrap();
1757
1758        // The explicit provider should have been called (it returns empty strings)
1759        assert!(explicit_cred_provider.called.load(Ordering::Relaxed));
1760
1761        // The storage options provider should NOT have been called
1762        assert_eq!(
1763            mock_storage_provider.get_call_count().await,
1764            0,
1765            "Storage options provider should not be called when explicit aws_credentials is provided"
1766        );
1767
1768        // Verify we got credentials from the explicit provider (empty strings)
1769        assert_eq!(cred.key_id, "");
1770        assert_eq!(cred.secret_key, "");
1771    }
1772
1773    #[tokio::test]
1774    async fn test_accessor_used_when_no_explicit_aws_credentials() {
1775        MockClock::set_system_time(Duration::from_secs(100_000));
1776
1777        let now_ms = MockClock::system_time().as_millis() as u64;
1778
1779        // Create a mock storage options provider
1780        let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1781
1782        // Create initial options
1783        let expires_at = now_ms + 600_000; // 10 minutes from now
1784        let initial_options = HashMap::from([
1785            (
1786                "aws_access_key_id".to_string(),
1787                "AKID_FROM_ACCESSOR".to_string(),
1788            ),
1789            (
1790                "aws_secret_access_key".to_string(),
1791                "SECRET_FROM_ACCESSOR".to_string(),
1792            ),
1793            (
1794                "aws_session_token".to_string(),
1795                "TOKEN_FROM_ACCESSOR".to_string(),
1796            ),
1797            ("expires_at_millis".to_string(), expires_at.to_string()),
1798            ("refresh_offset_millis".to_string(), "300000".to_string()), // 5 minute refresh offset
1799        ]);
1800
1801        // Create an accessor with initial options and provider
1802        let accessor = Arc::new(StorageOptionsAccessor::with_initial_and_provider(
1803            initial_options,
1804            mock_storage_provider.clone(),
1805        ));
1806
1807        // Build credentials with accessor but NO explicit aws_credentials
1808        let (result, _region) = build_aws_credential(
1809            Duration::from_secs(300),
1810            None, // no explicit aws_credentials
1811            None, // no storage_options
1812            Some("us-west-2".to_string()),
1813            Some(accessor),
1814            None,
1815        )
1816        .await
1817        .unwrap();
1818
1819        // Get credential - should use the initial accessor credentials
1820        let cred = result.get_credential().await.unwrap();
1821        assert_eq!(cred.key_id, "AKID_FROM_ACCESSOR");
1822        assert_eq!(cred.secret_key, "SECRET_FROM_ACCESSOR");
1823
1824        // Storage options provider should NOT have been called yet (using cached initial creds)
1825        assert_eq!(mock_storage_provider.get_call_count().await, 0);
1826
1827        // Advance time to trigger refresh (past the 5 minute refresh offset)
1828        MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1829
1830        // Get credential again - should now fetch from provider
1831        let cred = result.get_credential().await.unwrap();
1832        assert_eq!(cred.key_id, "AKID_1");
1833        assert_eq!(cred.secret_key, "SECRET_1");
1834
1835        // Storage options provider should have been called once
1836        assert_eq!(mock_storage_provider.get_call_count().await, 1);
1837    }
1838
1839    // Test that aws_provider_scheme=token selects static credentials.
1840    #[tokio::test]
1841    async fn test_provider_scheme_token() {
1842        let opts = HashMap::from([
1843            (AmazonS3ConfigKey::AccessKeyId, "AKID".to_string()),
1844            (AmazonS3ConfigKey::SecretAccessKey, "SECRET".to_string()),
1845        ]);
1846
1847        let (provider, _) = build_aws_credential(
1848            Duration::from_secs(300),
1849            None,
1850            Some(&opts),
1851            Some("us-east-1".to_string()),
1852            None,
1853            Some(AwsProviderScheme::Token),
1854        )
1855        .await
1856        .unwrap();
1857
1858        let cred = provider.get_credential().await.unwrap();
1859        assert_eq!(cred.key_id, "AKID");
1860        assert_eq!(cred.secret_key, "SECRET");
1861    }
1862
1863    // Test that aws_provider_scheme=token errors when no static credentials are present.
1864    #[tokio::test]
1865    async fn test_provider_scheme_token_errors_without_credentials() {
1866        let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1867
1868        let result = build_aws_credential(
1869            Duration::from_secs(300),
1870            None,
1871            Some(&opts),
1872            Some("us-east-1".to_string()),
1873            None,
1874            Some(AwsProviderScheme::Token),
1875        )
1876        .await;
1877        assert!(result.is_err());
1878        assert!(
1879            result
1880                .unwrap_err()
1881                .to_string()
1882                .contains("aws_provider_scheme=token"),
1883            "error should mention aws_provider_scheme=token"
1884        );
1885    }
1886
1887    // Test that aws_provider_scheme=ecs builds a provider without error.
1888    // The ECS provider itself reads from env vars lazily; construction always succeeds.
1889    #[tokio::test]
1890    async fn test_provider_scheme_ecs() {
1891        let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1892
1893        let result = build_aws_credential(
1894            Duration::from_secs(300),
1895            None,
1896            Some(&opts),
1897            Some("us-east-1".to_string()),
1898            None,
1899            Some(AwsProviderScheme::Ecs),
1900        )
1901        .await;
1902        assert!(result.is_ok(), "ECS provider should build without error");
1903    }
1904
1905    // Test that aws_provider_scheme=irsa builds a provider and attempts credential
1906    // retrieval (which fails with a provider error, not a config error like
1907    // "Missing Region" — confirming the region is wired through to the STS client).
1908    #[tokio::test]
1909    async fn test_provider_scheme_irsa() {
1910        let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1911
1912        let (provider, _) = build_aws_credential(
1913            Duration::from_secs(300),
1914            None,
1915            Some(&opts),
1916            Some("us-east-1".to_string()),
1917            None,
1918            Some(AwsProviderScheme::Irsa),
1919        )
1920        .await
1921        .unwrap();
1922
1923        // Credential retrieval must fail with a provider error (missing env vars or
1924        // network), NOT a configuration error like "Invalid Configuration: Missing Region".
1925        let err = provider.get_credential().await.unwrap_err();
1926        assert!(
1927            !err.to_string().contains("Missing Region"),
1928            "should not fail with Missing Region; region was provided. got: {err}"
1929        );
1930    }
1931
1932    // Test that an invalid aws_provider_scheme value produces a clear error.
1933    #[test]
1934    fn test_provider_scheme_invalid_value() {
1935        let opts = StorageOptions::new(HashMap::from([(
1936            "aws_provider_scheme".to_string(),
1937            "magic".to_string(),
1938        )]));
1939        let result = opts.aws_provider_scheme();
1940        assert!(result.is_err());
1941        assert!(result.unwrap_err().to_string().contains("magic"));
1942    }
1943
1944    // Test that no aws_provider_scheme falls through to DefaultCredentialsChain without error.
1945    #[tokio::test]
1946    async fn test_no_provider_scheme_uses_default_chain() {
1947        let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1948
1949        let result = build_aws_credential(
1950            Duration::from_secs(300),
1951            None,
1952            Some(&opts),
1953            Some("us-east-1".to_string()),
1954            None,
1955            None,
1956        )
1957        .await;
1958        assert!(result.is_ok());
1959    }
1960}