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