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 opendal::{Operator, services::S3};
14
15use aws_config::default_provider::credentials::DefaultCredentialsChain;
16use aws_config::ecs::EcsCredentialsProvider;
17use aws_config::provider_config::ProviderConfig;
18use aws_config::web_identity_token::WebIdentityTokenCredentialsProvider;
19use aws_config::{BehaviorVersion, Region, SdkConfig};
20use aws_credential_types::provider::ProvideCredentials;
21use object_store::{
22 ClientOptions, CredentialProvider, Result as ObjectStoreResult, RetryConfig,
23 StaticCredentialProvider,
24 aws::{
25 AmazonS3Builder, AmazonS3ConfigKey, AwsCredential as ObjectStoreAwsCredential,
26 AwsCredentialProvider,
27 },
28};
29use tokio::sync::RwLock;
30use url::Url;
31
32use crate::object_store::opendal_store::OpendalStore;
33use crate::object_store::{
34 DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
35 ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
36 dynamic_credentials::{NamespaceCredentialsProvider, build_dynamic_credential_provider},
37 throttle::{AimdThrottleConfig, AimdThrottleState, AimdThrottledStore, cloud_http_connector},
38};
39use lance_core::error::{Error, Result};
40
41#[derive(Default, Debug)]
42pub struct AwsStoreProvider;
43
44struct ResolvedS3StorageOptions {
45 options: HashMap<AmazonS3ConfigKey, String>,
46 profile_region: Option<String>,
47}
48
49impl ResolvedS3StorageOptions {
50 fn new(
51 mut options: HashMap<AmazonS3ConfigKey, String>,
52 profile_config: Option<&SdkConfig>,
53 ) -> Self {
54 if effective_s3_endpoint(&options).is_none()
55 && let Some(endpoint) = profile_config.and_then(SdkConfig::endpoint_url)
56 {
57 options.insert(AmazonS3ConfigKey::Endpoint, endpoint.to_string());
58 }
59 let profile_region = profile_config
60 .and_then(SdkConfig::region)
61 .map(|region| region.as_ref().to_string());
62 Self {
63 options,
64 profile_region,
65 }
66 }
67
68 fn effective_endpoint(&self) -> Option<&str> {
69 effective_s3_endpoint(&self.options)
70 }
71
72 fn requires_constant_size_upload_parts(&self) -> bool {
73 self.effective_endpoint()
74 .is_some_and(|endpoint| endpoint.contains("r2.cloudflarestorage.com"))
75 }
76}
77
78impl AwsStoreProvider {
79 async fn build_amazon_s3_store(
80 &self,
81 base_path: &mut Url,
82 params: &ObjectStoreParams,
83 storage_options: &StorageOptions,
84 mut resolved_s3_options: ResolvedS3StorageOptions,
85 is_s3_express: bool,
86 throttle_state: Option<&AimdThrottleState>,
87 ) -> Result<Arc<dyn OSObjectStore>> {
88 let retry_config = RetryConfig {
91 backoff: Default::default(),
92 max_retries: storage_options.client_max_retries(),
93 retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()),
94 };
95
96 let region = resolve_s3_region(base_path, &resolved_s3_options).await?;
97
98 let accessor = params.get_accessor();
100
101 let provider_scheme = storage_options.aws_provider_scheme()?;
102
103 let (aws_creds, region) = build_aws_credential(
104 params.s3_credentials_refresh_offset,
105 params.aws_credentials.clone(),
106 Some(&resolved_s3_options.options),
107 region,
108 accessor,
109 provider_scheme,
110 )
111 .await?;
112
113 if is_s3_express {
115 resolved_s3_options
116 .options
117 .insert(AmazonS3ConfigKey::S3Express, true.to_string());
118 }
119
120 let store_prefix =
123 self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
124
125 base_path.set_scheme("s3").unwrap();
127 base_path.set_query(None);
128
129 let mut builder =
131 AmazonS3Builder::new().with_client_options(storage_options.client_options()?);
132 for (key, value) in resolved_s3_options.options {
133 builder = builder.with_config(key, value);
134 }
135 builder = builder
136 .with_url(base_path.as_ref())
137 .with_credentials(aws_creds)
138 .with_retry(retry_config)
139 .with_region(region);
140
141 builder = builder.with_http_connector(cloud_http_connector(throttle_state, store_prefix));
142
143 Ok(Arc::new(builder.build()?) as Arc<dyn OSObjectStore>)
144 }
145
146 async fn build_opendal_s3_store(
147 &self,
148 base_path: &Url,
149 storage_options: &StorageOptions,
150 ) -> Result<Arc<dyn OSObjectStore>> {
151 let bucket = base_path
152 .host_str()
153 .ok_or_else(|| Error::invalid_input("S3 URL must contain bucket name"))?
154 .to_string();
155
156 let prefix = base_path.path().trim_start_matches('/').to_string();
157
158 let mut config_map: HashMap<String, String> = storage_options.0.clone();
161
162 if let Some(provider_scheme) = storage_options.aws_provider_scheme()? {
163 return Result::Err(Error::not_supported(format!(
164 "OpendalStore does not currently support an explicit provider_scheme (currently set to {:?})",
165 provider_scheme
166 )));
167 }
168
169 config_map.insert("bucket".to_string(), bucket);
171
172 if !prefix.is_empty() {
173 config_map.insert("root".to_string(), "/".to_string());
174 }
175
176 let operator = Operator::from_iter::<S3>(config_map)
177 .map_err(|e| Error::invalid_input(format!("Failed to create S3 operator: {:?}", e)))?;
178
179 Ok(Arc::new(OpendalStore::new(operator)) as Arc<dyn OSObjectStore>)
180 }
181}
182
183#[async_trait::async_trait]
184impl ObjectStoreProvider for AwsStoreProvider {
185 async fn new_store(
186 &self,
187 mut base_path: Url,
188 params: &ObjectStoreParams,
189 ) -> Result<ObjectStore> {
190 let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
191 let mut storage_options =
192 StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
193 storage_options.with_env_s3();
194 let download_retry_count = storage_options.download_retry_count();
195
196 let use_opendal = storage_options
197 .0
198 .get("use_opendal")
199 .map(|v| v == "true")
200 .unwrap_or(false);
201
202 let profile_config = if std::env::var_os("AWS_PROFILE").is_some() {
203 Some(aws_config::load_defaults(BehaviorVersion::latest()).await)
204 } else {
205 None
206 };
207 let resolved_s3_options =
208 ResolvedS3StorageOptions::new(storage_options.as_s3_options(), profile_config.as_ref());
209
210 let is_s3_express = check_s3_express(&base_path, &storage_options);
212
213 let use_constant_size_upload_parts =
214 resolved_s3_options.requires_constant_size_upload_parts();
215
216 let throttle_config = AimdThrottleConfig::from_storage_options(params.storage_options())?;
217 let throttle_state = if throttle_config.is_disabled() {
218 None
219 } else {
220 Some(AimdThrottleState::new(throttle_config)?)
221 };
222
223 let inner = if use_opendal {
224 self.build_opendal_s3_store(&base_path, &storage_options)
226 .await?
227 } else {
228 self.build_amazon_s3_store(
230 &mut base_path,
231 params,
232 &storage_options,
233 resolved_s3_options,
234 is_s3_express,
235 throttle_state.as_ref(),
236 )
237 .await?
238 };
239 let inner = if let Some(throttle_state) = throttle_state {
240 Arc::new(AimdThrottledStore::new_with_state(
241 inner,
242 throttle_state,
243 !use_opendal,
244 )) as Arc<dyn OSObjectStore>
245 } else {
246 inner
247 };
248
249 Ok(ObjectStore {
250 inner,
251 local_dir_operations: None,
252 scheme: String::from(base_path.scheme()),
253 block_size,
254 max_iop_size: *DEFAULT_MAX_IOP_SIZE,
255 use_constant_size_upload_parts,
256 list_is_lexically_ordered: !is_s3_express,
257 io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
258 download_retry_count,
259 io_tracker: Default::default(),
260 store_prefix: self
261 .calculate_object_store_prefix(&base_path, params.storage_options())?,
262 })
263 }
264}
265
266fn check_s3_express(url: &Url, storage_options: &StorageOptions) -> bool {
268 storage_options
269 .0
270 .get("s3_express")
271 .map(|v| v == "true")
272 .unwrap_or(false)
273 || url.authority().ends_with("--x-s3")
274}
275
276fn effective_s3_endpoint(storage_options: &HashMap<AmazonS3ConfigKey, String>) -> Option<&str> {
277 storage_options
278 .get(&AmazonS3ConfigKey::S3Endpoint)
279 .or_else(|| storage_options.get(&AmazonS3ConfigKey::Endpoint))
280 .map(String::as_str)
281}
282
283async fn resolve_s3_region(
292 url: &Url,
293 resolved_s3_options: &ResolvedS3StorageOptions,
294) -> Result<Option<String>> {
295 let storage_options = &resolved_s3_options.options;
296 if let Some(region) = storage_options.get(&AmazonS3ConfigKey::Region) {
297 Ok(Some(region.clone()))
298 } else if resolved_s3_options.effective_endpoint().is_none() {
299 let bucket = url.host_str().ok_or_else(|| {
302 Error::invalid_input(format!("Could not parse bucket from url: {}", url))
303 })?;
304
305 let mut client_options = ClientOptions::default();
306 for (key, value) in storage_options {
307 if let AmazonS3ConfigKey::Client(client_key) = key {
308 client_options = client_options.with_config(*client_key, value.clone());
309 }
310 }
311
312 let bucket_region =
313 object_store::aws::resolve_bucket_region(bucket, &client_options).await?;
314 Ok(Some(bucket_region))
315 } else {
316 Ok(resolved_s3_options.profile_region.clone())
317 }
318}
319
320#[derive(Debug, Clone, PartialEq)]
325pub enum AwsProviderScheme {
326 Token,
329 Ecs,
333 Irsa,
337}
338
339pub async fn build_aws_credential(
359 credentials_refresh_offset: Duration,
360 credentials: Option<AwsCredentialProvider>,
361 storage_options: Option<&HashMap<AmazonS3ConfigKey, String>>,
362 region: Option<String>,
363 storage_options_accessor: Option<Arc<StorageOptionsAccessor>>,
364 provider_scheme: Option<AwsProviderScheme>,
365) -> Result<(AwsCredentialProvider, String)> {
366 use aws_config::meta::region::RegionProviderChain;
367 const DEFAULT_REGION: &str = "us-west-2";
368
369 let region = if let Some(region) = region {
370 region
371 } else {
372 RegionProviderChain::default_provider()
373 .or_else(DEFAULT_REGION)
374 .region()
375 .await
376 .map(|r| r.as_ref().to_string())
377 .unwrap_or(DEFAULT_REGION.to_string())
378 };
379
380 if let Some(creds) = credentials {
382 return Ok((creds, region));
383 }
384
385 if let Some(dynamic_creds) = build_dynamic_credential_provider::<ObjectStoreAwsCredential>(
387 storage_options_accessor.clone(),
388 )
389 .await?
390 {
391 return Ok((dynamic_creds, region));
392 }
393
394 if storage_options_accessor
397 .as_ref()
398 .is_some_and(|a| a.has_provider())
399 {
400 log::debug!(
401 "Storage options from provider do not contain explicit AWS credentials, \
402 falling back to default AWS credentials chain."
403 );
404 }
405
406 if let Some(scheme) = provider_scheme {
408 return match scheme {
409 AwsProviderScheme::Token => {
410 let creds = storage_options
411 .and_then(extract_static_s3_credentials)
412 .ok_or_else(|| {
413 Error::invalid_input(
414 "aws_provider_scheme=token requires aws_access_key_id \
415 and aws_secret_access_key to be set",
416 )
417 })?;
418 Ok((Arc::new(creds), region))
419 }
420 AwsProviderScheme::Ecs => {
421 let provider = EcsCredentialsProvider::builder().build();
422 Ok((
423 Arc::new(AwsCredentialAdapter::new(
424 Arc::new(provider),
425 credentials_refresh_offset,
426 )),
427 region,
428 ))
429 }
430 AwsProviderScheme::Irsa => {
431 let conf = ProviderConfig::default().with_region(Some(Region::new(region.clone())));
432 let provider = WebIdentityTokenCredentialsProvider::builder()
433 .configure(&conf)
434 .build();
435 Ok((
436 Arc::new(AwsCredentialAdapter::new(
437 Arc::new(provider),
438 credentials_refresh_offset,
439 )),
440 region,
441 ))
442 }
443 };
444 }
445
446 if let Some(opts) = storage_options {
447 if let Some(creds) = extract_static_s3_credentials(opts) {
449 return Ok((Arc::new(creds), region));
450 }
451 }
452
453 let credentials_provider = DefaultCredentialsChain::builder().build().await;
454 Ok((
455 Arc::new(AwsCredentialAdapter::new(
456 Arc::new(credentials_provider),
457 credentials_refresh_offset,
458 )),
459 region,
460 ))
461}
462
463fn extract_static_s3_credentials(
464 options: &HashMap<AmazonS3ConfigKey, String>,
465) -> Option<StaticCredentialProvider<ObjectStoreAwsCredential>> {
466 let key_id = options.get(&AmazonS3ConfigKey::AccessKeyId).cloned();
467 let secret_key = options.get(&AmazonS3ConfigKey::SecretAccessKey).cloned();
468 let token = options.get(&AmazonS3ConfigKey::Token).cloned();
469 match (key_id, secret_key, token) {
470 (Some(key_id), Some(secret_key), token) => {
471 Some(StaticCredentialProvider::new(ObjectStoreAwsCredential {
472 key_id,
473 secret_key,
474 token,
475 }))
476 }
477 _ => None,
478 }
479}
480
481#[derive(Debug)]
483pub struct AwsCredentialAdapter {
484 pub inner: Arc<dyn ProvideCredentials>,
485
486 cache: Arc<RwLock<HashMap<String, Arc<aws_credential_types::Credentials>>>>,
488
489 credentials_refresh_offset: Duration,
491}
492
493impl AwsCredentialAdapter {
494 pub fn new(
495 provider: Arc<dyn ProvideCredentials>,
496 credentials_refresh_offset: Duration,
497 ) -> Self {
498 Self {
499 inner: provider,
500 cache: Arc::new(RwLock::new(HashMap::new())),
501 credentials_refresh_offset,
502 }
503 }
504}
505
506const AWS_CREDS_CACHE_KEY: &str = "aws_credentials";
507
508fn to_system_time(time: std::time::SystemTime) -> SystemTime {
510 let duration_since_epoch = time
511 .duration_since(std::time::UNIX_EPOCH)
512 .expect("time should be after UNIX_EPOCH");
513 UNIX_EPOCH + duration_since_epoch
514}
515
516#[async_trait::async_trait]
517impl CredentialProvider for AwsCredentialAdapter {
518 type Credential = ObjectStoreAwsCredential;
519
520 async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
521 let cached_creds = {
522 let cache_value = self.cache.read().await.get(AWS_CREDS_CACHE_KEY).cloned();
523 let expired = cache_value
524 .clone()
525 .map(|cred| {
526 cred.expiry()
527 .map(|exp| {
528 to_system_time(exp)
529 .checked_sub(self.credentials_refresh_offset)
530 .expect("this time should always be valid")
531 < SystemTime::now()
532 })
533 .unwrap_or(false)
535 })
536 .unwrap_or(true); if expired { None } else { cache_value.clone() }
538 };
539
540 if let Some(creds) = cached_creds {
541 Ok(Arc::new(Self::Credential {
542 key_id: creds.access_key_id().to_string(),
543 secret_key: creds.secret_access_key().to_string(),
544 token: creds.session_token().map(|s| s.to_string()),
545 }))
546 } else {
547 let refreshed_creds = Arc::new(
548 self.inner
549 .provide_credentials()
550 .await
551 .map_err(|e| Error::io(format!("Failed to get AWS credentials: {:?}", e)))?,
552 );
553
554 self.cache
555 .write()
556 .await
557 .insert(AWS_CREDS_CACHE_KEY.to_string(), refreshed_creds.clone());
558
559 Ok(Arc::new(Self::Credential {
560 key_id: refreshed_creds.access_key_id().to_string(),
561 secret_key: refreshed_creds.secret_access_key().to_string(),
562 token: refreshed_creds.session_token().map(|s| s.to_string()),
563 }))
564 }
565 }
566}
567
568impl StorageOptions {
569 pub fn with_env_s3(&mut self) {
574 for (os_key, os_value) in std::env::vars_os() {
575 if let (Some(key), Some(value)) = (os_key.to_str(), os_value.to_str())
576 && let Ok(config_key) = AmazonS3ConfigKey::from_str(&key.to_ascii_lowercase())
577 && !self.0.contains_key(config_key.as_ref())
578 {
579 self.0
580 .insert(config_key.as_ref().to_string(), value.to_string());
581 }
582 }
583 }
584
585 pub fn as_s3_options(&self) -> HashMap<AmazonS3ConfigKey, String> {
587 self.0
588 .iter()
589 .filter_map(|(key, value)| {
590 let s3_key = AmazonS3ConfigKey::from_str(&key.to_ascii_lowercase()).ok()?;
591 Some((s3_key, value.clone()))
592 })
593 .collect()
594 }
595
596 pub fn aws_provider_scheme(&self) -> Result<Option<AwsProviderScheme>> {
598 match self.0.get("aws_provider_scheme").map(|s| s.as_str()) {
599 None | Some("") => Ok(None),
600 Some("token") => Ok(Some(AwsProviderScheme::Token)),
601 Some("ecs") => Ok(Some(AwsProviderScheme::Ecs)),
602 Some("irsa") => Ok(Some(AwsProviderScheme::Irsa)),
603 Some(other) => Err(Error::invalid_input(format!(
604 "Invalid aws_provider_scheme '{}'. Valid values are: token, ecs, irsa",
605 other
606 ))),
607 }
608 }
609}
610
611impl ObjectStoreParams {
612 pub fn with_aws_credentials(
614 aws_credentials: Option<AwsCredentialProvider>,
615 region: Option<String>,
616 ) -> Self {
617 let storage_options_accessor = region.map(|region| {
618 let opts: HashMap<String, String> =
619 [("region".into(), region)].iter().cloned().collect();
620 Arc::new(StorageOptionsAccessor::with_static_options(opts))
621 });
622 Self {
623 aws_credentials,
624 storage_options_accessor,
625 ..Default::default()
626 }
627 }
628}
629
630pub type DynamicStorageOptionsCredentialProvider =
631 NamespaceCredentialsProvider<ObjectStoreAwsCredential>;
632
633#[cfg(test)]
634mod tests {
635 use crate::object_store::ObjectStoreRegistry;
636 use crate::object_store::StorageOptionsProvider;
637 #[allow(deprecated)]
638 use aws_config::profile::profile_file::{ProfileFileKind, ProfileFiles};
639 use aws_credential_types::provider::error::CredentialsError;
640 use mock_instant::thread_local::MockClock;
641 use object_store::path::Path;
642 use std::sync::atomic::{AtomicBool, Ordering};
643
644 use super::*;
645
646 #[derive(Debug, Default)]
647 struct MockAwsCredentialsProvider {
648 called: AtomicBool,
649 }
650
651 #[async_trait::async_trait]
652 impl CredentialProvider for MockAwsCredentialsProvider {
653 type Credential = ObjectStoreAwsCredential;
654
655 async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
656 self.called.store(true, Ordering::Relaxed);
657 Ok(Arc::new(Self::Credential {
658 key_id: "".to_string(),
659 secret_key: "".to_string(),
660 token: None,
661 }))
662 }
663 }
664
665 #[allow(deprecated)]
666 async fn load_test_profile(config: &str) -> SdkConfig {
667 let profile_files = ProfileFiles::builder()
668 .with_contents(ProfileFileKind::Config, config)
669 .build();
670 aws_config::defaults(BehaviorVersion::latest())
671 .profile_name("selected")
672 .profile_files(profile_files)
673 .load()
674 .await
675 }
676
677 #[derive(Debug)]
678 struct FailingAwsCredentialsProvider;
679
680 impl ProvideCredentials for FailingAwsCredentialsProvider {
681 fn provide_credentials<'a>(
682 &'a self,
683 ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
684 where
685 Self: 'a,
686 {
687 aws_credential_types::provider::future::ProvideCredentials::new(async {
688 Err(CredentialsError::provider_error(Box::new(
689 std::io::Error::other("Glue credential endpoint unavailable"),
690 )))
691 })
692 }
693 }
694
695 #[tokio::test]
696 async fn test_aws_credential_failure_is_io_error() {
697 let provider = AwsCredentialAdapter::new(
698 Arc::new(FailingAwsCredentialsProvider),
699 Duration::from_secs(60),
700 );
701
702 let error = provider.get_credential().await.unwrap_err();
703 let object_store::Error::Generic { source, .. } = &error else {
704 panic!("expected a generic object store error, got {error}");
705 };
706 assert!(matches!(
707 source.downcast_ref::<Error>(),
708 Some(Error::IO { .. })
709 ));
710
711 let message = error.to_string();
712 assert!(message.contains("Failed to get AWS credentials"));
713 assert!(message.contains("Glue credential endpoint unavailable"));
714 assert!(!message.contains("Encountered internal error"));
715 }
716
717 #[tokio::test]
718 async fn test_injected_aws_creds_option_is_used() {
719 let mock_provider = Arc::new(MockAwsCredentialsProvider::default());
720 let registry = Arc::new(ObjectStoreRegistry::default());
721
722 let params = ObjectStoreParams {
723 aws_credentials: Some(mock_provider.clone() as AwsCredentialProvider),
724 ..ObjectStoreParams::default()
725 };
726
727 assert!(!mock_provider.called.load(Ordering::Relaxed));
729
730 let (store, _) = ObjectStore::from_uri_and_params(registry, "s3://not-a-bucket", ¶ms)
731 .await
732 .unwrap();
733
734 let _ = store
736 .open(&Path::parse("/").unwrap())
737 .await
738 .unwrap()
739 .get_range(0..1)
740 .await;
741
742 assert!(mock_provider.called.load(Ordering::Relaxed));
744 }
745
746 #[tokio::test]
747 async fn test_resolve_s3_region_from_aws_profile() {
748 let profile_config = load_test_profile(
749 "[profile selected]\n\
750 region = us-west-004\n\
751 endpoint_url = https://s3.us-west-004.backblazeb2.com\n\
752 aws_access_key_id = test-key\n\
753 aws_secret_access_key = test-secret",
754 )
755 .await;
756 let url = Url::parse("s3://test-bucket/path").unwrap();
757
758 let resolved_s3_options =
759 ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
760 let region = resolve_s3_region(&url, &resolved_s3_options).await.unwrap();
761
762 assert_eq!(region.as_deref(), Some("us-west-004"));
763 assert_eq!(
764 resolved_s3_options
765 .options
766 .get(&AmazonS3ConfigKey::Endpoint),
767 Some(&"https://s3.us-west-004.backblazeb2.com".to_string())
768 );
769
770 let explicit_options = HashMap::from([
771 (AmazonS3ConfigKey::Region, "explicit-region".to_string()),
772 (
773 AmazonS3ConfigKey::Endpoint,
774 "https://explicit.example.com".to_string(),
775 ),
776 ]);
777 let resolved_s3_options =
778 ResolvedS3StorageOptions::new(explicit_options, Some(&profile_config));
779 let region = resolve_s3_region(&url, &resolved_s3_options).await.unwrap();
780
781 assert_eq!(region.as_deref(), Some("explicit-region"));
782 assert_eq!(
783 resolved_s3_options
784 .options
785 .get(&AmazonS3ConfigKey::Endpoint),
786 Some(&"https://explicit.example.com".to_string())
787 );
788 }
789
790 #[tokio::test]
791 async fn test_region_only_aws_profile_preserves_bucket_discovery() {
792 let profile_config = load_test_profile("[profile selected]\nregion = us-east-1").await;
793 let resolved_s3_options =
794 ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
795
796 let url = Url::parse("s3:///path").unwrap();
797 let error = resolve_s3_region(&url, &resolved_s3_options)
798 .await
799 .unwrap_err();
800
801 assert!(matches!(error, Error::InvalidInput { .. }));
802 assert!(error.to_string().contains("Could not parse bucket"));
803 }
804
805 #[tokio::test]
806 async fn test_r2_aws_profile_requires_constant_size_upload_parts() {
807 let profile_config = load_test_profile(
808 "[profile selected]\n\
809 region = auto\n\
810 endpoint_url = https://account.r2.cloudflarestorage.com",
811 )
812 .await;
813 let resolved_s3_options =
814 ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
815
816 assert!(resolved_s3_options.requires_constant_size_upload_parts());
817 }
818
819 #[test]
820 fn test_s3_path_parsing() {
821 let provider = AwsStoreProvider;
822
823 let cases = [
824 ("s3://bucket/path/to/file", "path/to/file"),
825 ("s3://bucket/测试path/to/file", "测试path/to/file"),
827 ("s3://bucket/path/&to/file", "path/&to/file"),
828 ("s3://bucket/path/=to/file", "path/=to/file"),
829 (
830 "s3+ddb://bucket/path/to/file?ddbTableName=test",
831 "path/to/file",
832 ),
833 ];
834
835 for (uri, expected_path) in cases {
836 let url = Url::parse(uri).unwrap();
837 let path = provider.extract_path(&url).unwrap();
838 let expected_path = Path::parse(expected_path).unwrap();
842 assert_eq!(path, expected_path)
843 }
844 }
845
846 #[test]
852 fn test_s3_non_ascii_path_no_double_encoding() {
853 let provider = AwsStoreProvider;
854
855 let url = Url::parse("s3://bucket/中文路径").unwrap();
860 let path = provider.extract_path(&url).unwrap();
861
862 assert_eq!(path.as_ref(), "中文路径");
864 }
865
866 #[test]
867 fn test_is_s3_express() {
868 let cases = [
869 (
870 "s3://bucket/path/to/file",
871 HashMap::from([("s3_express".to_string(), "true".to_string())]),
872 true,
873 ),
874 (
875 "s3://bucket/path/to/file",
876 HashMap::from([("s3_express".to_string(), "false".to_string())]),
877 false,
878 ),
879 ("s3://bucket/path/to/file", HashMap::from([]), false),
880 (
881 "s3://bucket--x-s3/path/to/file",
882 HashMap::from([("s3_express".to_string(), "true".to_string())]),
883 true,
884 ),
885 (
886 "s3://bucket--x-s3/path/to/file",
887 HashMap::from([("s3_express".to_string(), "false".to_string())]),
888 true, ),
890 ("s3://bucket--x-s3/path/to/file", HashMap::from([]), true),
891 ];
892
893 for (uri, storage_map, expected) in cases {
894 let url = Url::parse(uri).unwrap();
895 let storage_options = StorageOptions(storage_map);
896 let is_s3_express = check_s3_express(&url, &storage_options);
897 assert_eq!(is_s3_express, expected);
898 }
899 }
900
901 #[tokio::test]
902 async fn test_use_opendal_flag() {
903 use crate::object_store::StorageOptionsAccessor;
904 let provider = AwsStoreProvider;
905 let url = Url::parse("s3://test-bucket/path").unwrap();
906 let params_with_flag = ObjectStoreParams {
907 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
908 HashMap::from([
909 ("use_opendal".to_string(), "true".to_string()),
910 ("region".to_string(), "us-west-2".to_string()),
911 ]),
912 ))),
913 ..Default::default()
914 };
915
916 let store = provider
917 .new_store(url.clone(), ¶ms_with_flag)
918 .await
919 .unwrap();
920 assert_eq!(store.scheme, "s3");
921 }
922
923 #[derive(Debug)]
924 struct MockStorageOptionsProvider {
925 call_count: Arc<RwLock<usize>>,
926 expires_in_millis: Option<u64>,
927 }
928
929 impl MockStorageOptionsProvider {
930 fn new(expires_in_millis: Option<u64>) -> Self {
931 Self {
932 call_count: Arc::new(RwLock::new(0)),
933 expires_in_millis,
934 }
935 }
936
937 async fn get_call_count(&self) -> usize {
938 *self.call_count.read().await
939 }
940 }
941
942 #[async_trait::async_trait]
943 impl StorageOptionsProvider for MockStorageOptionsProvider {
944 async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
945 let count = {
946 let mut c = self.call_count.write().await;
947 *c += 1;
948 *c
949 };
950
951 let mut options = HashMap::from([
952 ("aws_access_key_id".to_string(), format!("AKID_{}", count)),
953 (
954 "aws_secret_access_key".to_string(),
955 format!("SECRET_{}", count),
956 ),
957 ("aws_session_token".to_string(), format!("TOKEN_{}", count)),
958 ]);
959
960 if let Some(expires_in) = self.expires_in_millis {
961 let now_ms = SystemTime::now()
962 .duration_since(UNIX_EPOCH)
963 .unwrap()
964 .as_millis() as u64;
965 let expires_at = now_ms + expires_in;
966 options.insert("expires_at_millis".to_string(), expires_at.to_string());
967 }
968
969 Ok(Some(options))
970 }
971
972 fn provider_id(&self) -> String {
973 let ptr = Arc::as_ptr(&self.call_count) as usize;
974 format!("MockStorageOptionsProvider {{ id: {} }}", ptr)
975 }
976 }
977
978 #[tokio::test]
979 async fn test_dynamic_credential_provider_with_initial_cache() {
980 MockClock::set_system_time(Duration::from_secs(100_000));
981
982 let now_ms = MockClock::system_time().as_millis() as u64;
983
984 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
986 600_000, )));
988
989 let expires_at = now_ms + 600_000; let initial_options = HashMap::from([
992 ("aws_access_key_id".to_string(), "AKID_CACHED".to_string()),
993 (
994 "aws_secret_access_key".to_string(),
995 "SECRET_CACHED".to_string(),
996 ),
997 ("aws_session_token".to_string(), "TOKEN_CACHED".to_string()),
998 ("expires_at_millis".to_string(), expires_at.to_string()),
999 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1001
1002 let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1003 mock.clone(),
1004 initial_options,
1005 );
1006
1007 let cred = provider.get_credential().await.unwrap();
1009 assert_eq!(cred.key_id, "AKID_CACHED");
1010 assert_eq!(cred.secret_key, "SECRET_CACHED");
1011 assert_eq!(cred.token, Some("TOKEN_CACHED".to_string()));
1012
1013 assert_eq!(mock.get_call_count().await, 0);
1015 }
1016
1017 #[tokio::test]
1018 async fn test_dynamic_credential_provider_with_expired_cache() {
1019 MockClock::set_system_time(Duration::from_secs(100_000));
1020
1021 let now_ms = MockClock::system_time().as_millis() as u64;
1022
1023 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1025 600_000, )));
1027
1028 let expired_time = now_ms - 1_000; let initial_options = HashMap::from([
1031 ("aws_access_key_id".to_string(), "AKID_EXPIRED".to_string()),
1032 (
1033 "aws_secret_access_key".to_string(),
1034 "SECRET_EXPIRED".to_string(),
1035 ),
1036 ("expires_at_millis".to_string(), expired_time.to_string()),
1037 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1039
1040 let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1041 mock.clone(),
1042 initial_options,
1043 );
1044
1045 let cred = provider.get_credential().await.unwrap();
1047 assert_eq!(cred.key_id, "AKID_1");
1048 assert_eq!(cred.secret_key, "SECRET_1");
1049 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1050
1051 assert_eq!(mock.get_call_count().await, 1);
1053 }
1054
1055 #[tokio::test]
1056 async fn test_dynamic_credential_provider_refresh_lead_time() {
1057 MockClock::set_system_time(Duration::from_secs(100_000));
1058
1059 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1061 30_000, )));
1063
1064 let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1067
1068 let cred = provider.get_credential().await.unwrap();
1072 assert_eq!(cred.key_id, "AKID_1");
1073 assert_eq!(mock.get_call_count().await, 1);
1074
1075 let cred = provider.get_credential().await.unwrap();
1079 assert_eq!(cred.key_id, "AKID_2");
1080 assert_eq!(mock.get_call_count().await, 2);
1081 }
1082
1083 #[tokio::test]
1084 async fn test_dynamic_credential_provider_no_initial_cache() {
1085 MockClock::set_system_time(Duration::from_secs(100_000));
1086
1087 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1089 120_000, )));
1091
1092 let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1094
1095 let cred = provider.get_credential().await.unwrap();
1097 assert_eq!(cred.key_id, "AKID_1");
1098 assert_eq!(cred.secret_key, "SECRET_1");
1099 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1100 assert_eq!(mock.get_call_count().await, 1);
1101
1102 let cred = provider.get_credential().await.unwrap();
1104 assert_eq!(cred.key_id, "AKID_1");
1105 assert_eq!(mock.get_call_count().await, 1); MockClock::set_system_time(Duration::from_secs(100_000 + 90));
1110 let cred = provider.get_credential().await.unwrap();
1111 assert_eq!(cred.key_id, "AKID_2");
1112 assert_eq!(cred.secret_key, "SECRET_2");
1113 assert_eq!(cred.token, Some("TOKEN_2".to_string()));
1114 assert_eq!(mock.get_call_count().await, 2);
1115
1116 MockClock::set_system_time(Duration::from_secs(100_000 + 210));
1118 let cred = provider.get_credential().await.unwrap();
1119 assert_eq!(cred.key_id, "AKID_3");
1120 assert_eq!(cred.secret_key, "SECRET_3");
1121 assert_eq!(mock.get_call_count().await, 3);
1122 }
1123
1124 #[tokio::test]
1125 async fn test_dynamic_credential_provider_with_initial_options() {
1126 MockClock::set_system_time(Duration::from_secs(100_000));
1127
1128 let now_ms = MockClock::system_time().as_millis() as u64;
1129
1130 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1132 600_000, )));
1134
1135 let expires_at = now_ms + 600_000; let initial_options = HashMap::from([
1138 ("aws_access_key_id".to_string(), "AKID_INITIAL".to_string()),
1139 (
1140 "aws_secret_access_key".to_string(),
1141 "SECRET_INITIAL".to_string(),
1142 ),
1143 ("aws_session_token".to_string(), "TOKEN_INITIAL".to_string()),
1144 ("expires_at_millis".to_string(), expires_at.to_string()),
1145 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1147
1148 let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1150 mock.clone(),
1151 initial_options,
1152 );
1153
1154 let cred = provider.get_credential().await.unwrap();
1156 assert_eq!(cred.key_id, "AKID_INITIAL");
1157 assert_eq!(cred.secret_key, "SECRET_INITIAL");
1158 assert_eq!(cred.token, Some("TOKEN_INITIAL".to_string()));
1159
1160 assert_eq!(mock.get_call_count().await, 0);
1162
1163 MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1166 let cred = provider.get_credential().await.unwrap();
1167 assert_eq!(cred.key_id, "AKID_1");
1168 assert_eq!(cred.secret_key, "SECRET_1");
1169 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1170
1171 assert_eq!(mock.get_call_count().await, 1);
1173
1174 MockClock::set_system_time(Duration::from_secs(100_000 + 660));
1176 let cred = provider.get_credential().await.unwrap();
1177 assert_eq!(cred.key_id, "AKID_2");
1178 assert_eq!(cred.secret_key, "SECRET_2");
1179 assert_eq!(cred.token, Some("TOKEN_2".to_string()));
1180
1181 assert_eq!(mock.get_call_count().await, 2);
1183
1184 MockClock::set_system_time(Duration::from_secs(100_000 + 960));
1186 let cred = provider.get_credential().await.unwrap();
1187 assert_eq!(cred.key_id, "AKID_3");
1188 assert_eq!(cred.secret_key, "SECRET_3");
1189 assert_eq!(cred.token, Some("TOKEN_3".to_string()));
1190
1191 assert_eq!(mock.get_call_count().await, 3);
1193 }
1194
1195 #[tokio::test]
1196 async fn test_dynamic_credential_provider_concurrent_access() {
1197 let mock = Arc::new(MockStorageOptionsProvider::new(Some(9999999999999)));
1199
1200 let provider = Arc::new(DynamicStorageOptionsCredentialProvider::from_provider(
1201 mock.clone(),
1202 ));
1203
1204 let mut handles = vec![];
1206 for i in 0..10 {
1207 let provider = provider.clone();
1208 let handle = tokio::spawn(async move {
1209 let cred = provider.get_credential().await.unwrap();
1210 assert_eq!(cred.key_id, "AKID_1");
1212 assert_eq!(cred.secret_key, "SECRET_1");
1213 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1214 i });
1216 handles.push(handle);
1217 }
1218
1219 let results: Vec<_> = futures::future::join_all(handles)
1221 .await
1222 .into_iter()
1223 .map(|r| r.unwrap())
1224 .collect();
1225
1226 assert_eq!(results.len(), 10);
1228 for i in 0..10 {
1229 assert!(results.contains(&i));
1230 }
1231
1232 let call_count = mock.get_call_count().await;
1235 assert_eq!(
1236 call_count, 1,
1237 "Provider should be called exactly once despite concurrent access"
1238 );
1239 }
1240
1241 #[tokio::test]
1242 async fn test_dynamic_credential_provider_concurrent_refresh() {
1243 MockClock::set_system_time(Duration::from_secs(100_000));
1244
1245 let now_ms = MockClock::system_time().as_millis() as u64;
1246
1247 let expires_at = now_ms - 1_000_000;
1249 let initial_options = HashMap::from([
1250 ("aws_access_key_id".to_string(), "AKID_OLD".to_string()),
1251 (
1252 "aws_secret_access_key".to_string(),
1253 "SECRET_OLD".to_string(),
1254 ),
1255 ("aws_session_token".to_string(), "TOKEN_OLD".to_string()),
1256 ("expires_at_millis".to_string(), expires_at.to_string()),
1257 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1259
1260 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1262 3_600_000, )));
1264
1265 let provider = Arc::new(
1266 DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1267 mock.clone(),
1268 initial_options,
1269 ),
1270 );
1271
1272 let mut handles = vec![];
1275 for i in 0..20 {
1276 let provider = provider.clone();
1277 let handle = tokio::spawn(async move {
1278 let cred = provider.get_credential().await.unwrap();
1279 assert_eq!(cred.key_id, "AKID_1");
1281 assert_eq!(cred.secret_key, "SECRET_1");
1282 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1283 i
1284 });
1285 handles.push(handle);
1286 }
1287
1288 let results: Vec<_> = futures::future::join_all(handles)
1290 .await
1291 .into_iter()
1292 .map(|r| r.unwrap())
1293 .collect();
1294
1295 assert_eq!(results.len(), 20);
1297
1298 let call_count = mock.get_call_count().await;
1301 assert!(
1302 call_count >= 1,
1303 "Provider should be called at least once, was called {} times",
1304 call_count
1305 );
1306
1307 assert!(
1309 call_count < 10,
1310 "Provider should not be called too many times due to lock contention, was called {} times",
1311 call_count
1312 );
1313 }
1314
1315 #[tokio::test]
1316 async fn test_explicit_aws_credentials_takes_precedence_over_accessor() {
1317 let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1319
1320 let accessor = Arc::new(StorageOptionsAccessor::with_provider(
1322 mock_storage_provider.clone(),
1323 ));
1324
1325 let explicit_cred_provider = Arc::new(MockAwsCredentialsProvider::default());
1327
1328 let (result, _region) = build_aws_credential(
1331 Duration::from_secs(300),
1332 Some(explicit_cred_provider.clone() as AwsCredentialProvider),
1333 None, Some("us-west-2".to_string()),
1335 Some(accessor),
1336 None,
1337 )
1338 .await
1339 .unwrap();
1340
1341 let cred = result.get_credential().await.unwrap();
1343
1344 assert!(explicit_cred_provider.called.load(Ordering::Relaxed));
1346
1347 assert_eq!(
1349 mock_storage_provider.get_call_count().await,
1350 0,
1351 "Storage options provider should not be called when explicit aws_credentials is provided"
1352 );
1353
1354 assert_eq!(cred.key_id, "");
1356 assert_eq!(cred.secret_key, "");
1357 }
1358
1359 #[tokio::test]
1360 async fn test_accessor_used_when_no_explicit_aws_credentials() {
1361 MockClock::set_system_time(Duration::from_secs(100_000));
1362
1363 let now_ms = MockClock::system_time().as_millis() as u64;
1364
1365 let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1367
1368 let expires_at = now_ms + 600_000; let initial_options = HashMap::from([
1371 (
1372 "aws_access_key_id".to_string(),
1373 "AKID_FROM_ACCESSOR".to_string(),
1374 ),
1375 (
1376 "aws_secret_access_key".to_string(),
1377 "SECRET_FROM_ACCESSOR".to_string(),
1378 ),
1379 (
1380 "aws_session_token".to_string(),
1381 "TOKEN_FROM_ACCESSOR".to_string(),
1382 ),
1383 ("expires_at_millis".to_string(), expires_at.to_string()),
1384 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1386
1387 let accessor = Arc::new(StorageOptionsAccessor::with_initial_and_provider(
1389 initial_options,
1390 mock_storage_provider.clone(),
1391 ));
1392
1393 let (result, _region) = build_aws_credential(
1395 Duration::from_secs(300),
1396 None, None, Some("us-west-2".to_string()),
1399 Some(accessor),
1400 None,
1401 )
1402 .await
1403 .unwrap();
1404
1405 let cred = result.get_credential().await.unwrap();
1407 assert_eq!(cred.key_id, "AKID_FROM_ACCESSOR");
1408 assert_eq!(cred.secret_key, "SECRET_FROM_ACCESSOR");
1409
1410 assert_eq!(mock_storage_provider.get_call_count().await, 0);
1412
1413 MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1415
1416 let cred = result.get_credential().await.unwrap();
1418 assert_eq!(cred.key_id, "AKID_1");
1419 assert_eq!(cred.secret_key, "SECRET_1");
1420
1421 assert_eq!(mock_storage_provider.get_call_count().await, 1);
1423 }
1424
1425 #[tokio::test]
1427 async fn test_provider_scheme_token() {
1428 let opts = HashMap::from([
1429 (AmazonS3ConfigKey::AccessKeyId, "AKID".to_string()),
1430 (AmazonS3ConfigKey::SecretAccessKey, "SECRET".to_string()),
1431 ]);
1432
1433 let (provider, _) = build_aws_credential(
1434 Duration::from_secs(300),
1435 None,
1436 Some(&opts),
1437 Some("us-east-1".to_string()),
1438 None,
1439 Some(AwsProviderScheme::Token),
1440 )
1441 .await
1442 .unwrap();
1443
1444 let cred = provider.get_credential().await.unwrap();
1445 assert_eq!(cred.key_id, "AKID");
1446 assert_eq!(cred.secret_key, "SECRET");
1447 }
1448
1449 #[tokio::test]
1451 async fn test_provider_scheme_token_errors_without_credentials() {
1452 let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1453
1454 let result = build_aws_credential(
1455 Duration::from_secs(300),
1456 None,
1457 Some(&opts),
1458 Some("us-east-1".to_string()),
1459 None,
1460 Some(AwsProviderScheme::Token),
1461 )
1462 .await;
1463 assert!(result.is_err());
1464 assert!(
1465 result
1466 .unwrap_err()
1467 .to_string()
1468 .contains("aws_provider_scheme=token"),
1469 "error should mention aws_provider_scheme=token"
1470 );
1471 }
1472
1473 #[tokio::test]
1476 async fn test_provider_scheme_ecs() {
1477 let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1478
1479 let result = build_aws_credential(
1480 Duration::from_secs(300),
1481 None,
1482 Some(&opts),
1483 Some("us-east-1".to_string()),
1484 None,
1485 Some(AwsProviderScheme::Ecs),
1486 )
1487 .await;
1488 assert!(result.is_ok(), "ECS provider should build without error");
1489 }
1490
1491 #[tokio::test]
1495 async fn test_provider_scheme_irsa() {
1496 let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1497
1498 let (provider, _) = build_aws_credential(
1499 Duration::from_secs(300),
1500 None,
1501 Some(&opts),
1502 Some("us-east-1".to_string()),
1503 None,
1504 Some(AwsProviderScheme::Irsa),
1505 )
1506 .await
1507 .unwrap();
1508
1509 let err = provider.get_credential().await.unwrap_err();
1512 assert!(
1513 !err.to_string().contains("Missing Region"),
1514 "should not fail with Missing Region; region was provided. got: {err}"
1515 );
1516 }
1517
1518 #[test]
1520 fn test_provider_scheme_invalid_value() {
1521 let opts = StorageOptions::new(HashMap::from([(
1522 "aws_provider_scheme".to_string(),
1523 "magic".to_string(),
1524 )]));
1525 let result = opts.aws_provider_scheme();
1526 assert!(result.is_err());
1527 assert!(result.unwrap_err().to_string().contains("magic"));
1528 }
1529
1530 #[tokio::test]
1532 async fn test_no_provider_scheme_uses_default_chain() {
1533 let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1534
1535 let result = build_aws_credential(
1536 Duration::from_secs(300),
1537 None,
1538 Some(&opts),
1539 Some("us-east-1".to_string()),
1540 None,
1541 None,
1542 )
1543 .await;
1544 assert!(result.is_ok());
1545 }
1546}