1use 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 ) -> Result<Arc<AmazonS3>> {
92 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 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 if is_s3_express {
119 resolved_s3_options
120 .options
121 .insert(AmazonS3ConfigKey::S3Express, true.to_string());
122 }
123
124 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 base_path.set_scheme("s3").unwrap();
132 base_path.set_query(None);
133
134 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 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 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 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 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 (
230 self.build_opendal_s3_store(&base_path, &storage_options)
231 .await?,
232 None,
233 )
234 } else {
235 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
272fn 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
289async 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 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#[derive(Debug, Clone, PartialEq)]
331pub enum AwsProviderScheme {
332 Token,
335 Ecs,
339 Irsa,
343}
344
345pub 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 let Some(creds) = credentials {
388 return Ok((creds, region));
389 }
390
391 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 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 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 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#[derive(Debug)]
489pub struct AwsCredentialAdapter {
490 pub inner: Arc<dyn ProvideCredentials>,
491
492 credentials: RwLock<Option<Arc<aws_credential_types::Credentials>>>,
494
495 refresh_lock: Mutex<()>,
497
498 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
516fn 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 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 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 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 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 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 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 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 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 assert!(!mock_provider.called.load(Ordering::Relaxed));
884
885 let (store, _) = ObjectStore::from_uri_and_params(registry, "s3://not-a-bucket", ¶ms)
886 .await
887 .unwrap();
888
889 let _ = store
891 .open(&Path::parse("/").unwrap())
892 .await
893 .unwrap()
894 .get_range(0..1)
895 .await;
896
897 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 ("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 let expected_path = Path::parse(expected_path).unwrap();
997 assert_eq!(path, expected_path)
998 }
999 }
1000
1001 #[test]
1007 fn test_s3_non_ascii_path_no_double_encoding() {
1008 let provider = AwsStoreProvider;
1009
1010 let url = Url::parse("s3://bucket/中文路径").unwrap();
1015 let path = provider.extract_path(&url).unwrap();
1016
1017 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, ),
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(), ¶ms_with_flag)
1073 .await
1074 .unwrap();
1075 assert_eq!(store.scheme, "s3");
1076 }
1077
1078 #[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 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, ¶ms).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 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1171 600_000, )));
1173
1174 let expires_at = now_ms + 600_000; 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()), ]);
1186
1187 let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1188 mock.clone(),
1189 initial_options,
1190 );
1191
1192 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 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 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1210 600_000, )));
1212
1213 let expired_time = now_ms - 1_000; 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()), ]);
1224
1225 let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1226 mock.clone(),
1227 initial_options,
1228 );
1229
1230 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 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 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1246 30_000, )));
1248
1249 let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1252
1253 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 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 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1274 120_000, )));
1276
1277 let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1279
1280 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 let cred = provider.get_credential().await.unwrap();
1289 assert_eq!(cred.key_id, "AKID_1");
1290 assert_eq!(mock.get_call_count().await, 1); 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 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 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1317 600_000, )));
1319
1320 let expires_at = now_ms + 600_000; 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()), ]);
1332
1333 let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1335 mock.clone(),
1336 initial_options,
1337 );
1338
1339 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 assert_eq!(mock.get_call_count().await, 0);
1347
1348 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 assert_eq!(mock.get_call_count().await, 1);
1358
1359 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 assert_eq!(mock.get_call_count().await, 2);
1368
1369 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 assert_eq!(mock.get_call_count().await, 3);
1378 }
1379
1380 #[tokio::test]
1381 async fn test_dynamic_credential_provider_concurrent_access() {
1382 let mock = Arc::new(MockStorageOptionsProvider::new(Some(9999999999999)));
1384
1385 let provider = Arc::new(DynamicStorageOptionsCredentialProvider::from_provider(
1386 mock.clone(),
1387 ));
1388
1389 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 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 });
1401 handles.push(handle);
1402 }
1403
1404 let results: Vec<_> = futures::future::join_all(handles)
1406 .await
1407 .into_iter()
1408 .map(|r| r.unwrap())
1409 .collect();
1410
1411 assert_eq!(results.len(), 10);
1413 for i in 0..10 {
1414 assert!(results.contains(&i));
1415 }
1416
1417 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 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()), ]);
1444
1445 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1447 3_600_000, )));
1449
1450 let provider = Arc::new(
1451 DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1452 mock.clone(),
1453 initial_options,
1454 ),
1455 );
1456
1457 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 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 let results: Vec<_> = futures::future::join_all(handles)
1475 .await
1476 .into_iter()
1477 .map(|r| r.unwrap())
1478 .collect();
1479
1480 assert_eq!(results.len(), 20);
1482
1483 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 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 let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1504
1505 let accessor = Arc::new(StorageOptionsAccessor::with_provider(
1507 mock_storage_provider.clone(),
1508 ));
1509
1510 let explicit_cred_provider = Arc::new(MockAwsCredentialsProvider::default());
1512
1513 let (result, _region) = build_aws_credential(
1516 Duration::from_secs(300),
1517 Some(explicit_cred_provider.clone() as AwsCredentialProvider),
1518 None, Some("us-west-2".to_string()),
1520 Some(accessor),
1521 None,
1522 )
1523 .await
1524 .unwrap();
1525
1526 let cred = result.get_credential().await.unwrap();
1528
1529 assert!(explicit_cred_provider.called.load(Ordering::Relaxed));
1531
1532 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 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 let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1552
1553 let expires_at = now_ms + 600_000; 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()), ]);
1571
1572 let accessor = Arc::new(StorageOptionsAccessor::with_initial_and_provider(
1574 initial_options,
1575 mock_storage_provider.clone(),
1576 ));
1577
1578 let (result, _region) = build_aws_credential(
1580 Duration::from_secs(300),
1581 None, None, Some("us-west-2".to_string()),
1584 Some(accessor),
1585 None,
1586 )
1587 .await
1588 .unwrap();
1589
1590 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 assert_eq!(mock_storage_provider.get_call_count().await, 0);
1597
1598 MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1600
1601 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 assert_eq!(mock_storage_provider.get_call_count().await, 1);
1608 }
1609
1610 #[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 #[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 #[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 #[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 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]
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 #[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}