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 resolved_s3_options
121 .options
122 .entry(AmazonS3ConfigKey::CopyIfNotExists)
123 .or_insert_with(|| "multipart".to_string());
124
125 if is_s3_express {
127 resolved_s3_options
128 .options
129 .insert(AmazonS3ConfigKey::S3Express, true.to_string());
130 }
131
132 let store_prefix =
135 self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
136 let metrics_base = crate::object_store::metrics_base(&store_prefix, base_path);
137
138 base_path.set_scheme("s3").unwrap();
140 base_path.set_query(None);
141
142 let mut builder =
144 AmazonS3Builder::new().with_client_options(storage_options.client_options()?);
145 for (key, value) in resolved_s3_options.options {
146 builder = builder.with_config(key, value);
147 }
148 builder = builder
149 .with_url(base_path.as_ref())
150 .with_credentials(aws_creds)
151 .with_retry(retry_config)
152 .with_region(region);
153
154 builder = builder.with_http_connector(cloud_http_connector(throttle_state, metrics_base));
155
156 Ok(Arc::new(builder.build()?))
157 }
158
159 async fn build_opendal_s3_store(
160 &self,
161 base_path: &Url,
162 storage_options: &StorageOptions,
163 aws_credentials: Option<&AwsCredentialProvider>,
164 ) -> Result<Arc<dyn OSObjectStore>> {
165 let bucket = base_path
166 .host_str()
167 .ok_or_else(|| Error::invalid_input("S3 URL must contain bucket name"))?
168 .to_string();
169
170 let prefix = base_path.path().trim_start_matches('/').to_string();
171
172 let mut config_map: HashMap<String, String> = storage_options.0.clone();
175
176 if let Some(provider_scheme) = storage_options.aws_provider_scheme()? {
177 return Result::Err(Error::not_supported(format!(
178 "OpendalStore does not currently support an explicit provider_scheme (currently set to {:?})",
179 provider_scheme
180 )));
181 }
182
183 if aws_credentials.is_some() {
187 return Result::Err(Error::not_supported(
188 "OpendalStore does not currently support an explicit aws_credentials provider"
189 .to_string(),
190 ));
191 }
192
193 config_map.insert("bucket".to_string(), bucket);
195
196 if !prefix.is_empty() {
197 config_map.insert("root".to_string(), "/".to_string());
198 }
199
200 let operator = Operator::from_iter::<S3>(config_map)
201 .map_err(|e| Error::invalid_input(format!("Failed to create S3 operator: {:?}", e)))?;
202
203 Ok(Arc::new(OpendalStore::new(operator)) as Arc<dyn OSObjectStore>)
204 }
205}
206
207#[async_trait::async_trait]
208impl ObjectStoreProvider for AwsStoreProvider {
209 async fn new_store(
210 &self,
211 mut base_path: Url,
212 params: &ObjectStoreParams,
213 ) -> Result<ObjectStore> {
214 let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
215 let mut storage_options =
216 StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
217 storage_options.with_env_s3();
218 let download_retry_count = storage_options.download_retry_count();
219
220 let use_opendal = storage_options
221 .0
222 .get("use_opendal")
223 .map(|v| str_is_truthy(v))
224 .unwrap_or(false);
225
226 let profile_config = if std::env::var_os("AWS_PROFILE").is_some() {
227 Some(aws_config::load_defaults(BehaviorVersion::latest()).await)
228 } else {
229 None
230 };
231 let resolved_s3_options =
232 ResolvedS3StorageOptions::new(storage_options.as_s3_options(), profile_config.as_ref());
233
234 let is_s3_express = check_s3_express(&base_path, &storage_options);
236
237 let use_constant_size_upload_parts =
238 resolved_s3_options.requires_constant_size_upload_parts();
239
240 let store_prefix =
242 self.calculate_object_store_prefix(&base_path, params.storage_options())?;
243 let throttle_state = shared_throttle_state(&store_prefix, params)?;
244
245 let (inner, paginated_lister) = if use_opendal {
246 (
249 self.build_opendal_s3_store(
250 &base_path,
251 &storage_options,
252 params.aws_credentials.as_ref(),
253 )
254 .await?,
255 None,
256 )
257 } else {
258 let store = self
260 .build_amazon_s3_store(
261 &mut base_path,
262 params,
263 &storage_options,
264 resolved_s3_options,
265 is_s3_express,
266 throttle_state.as_ref(),
267 )
268 .await?;
269 (
270 store.clone() as Arc<dyn OSObjectStore>,
271 Some(store as Arc<dyn PaginatedListStore>),
272 )
273 };
274 let (inner, paginated_lister) =
275 with_throttling(throttle_state, !use_opendal, inner, paginated_lister);
276
277 Ok(ObjectStore {
278 inner,
279 local_dir_operations: None,
280 scheme: String::from(base_path.scheme()),
281 block_size,
282 max_iop_size: *DEFAULT_MAX_IOP_SIZE,
283 use_constant_size_upload_parts,
284 list_is_lexically_ordered: !is_s3_express,
285 io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
286 download_retry_count,
287 io_tracker: Default::default(),
288 store_prefix: self
289 .calculate_object_store_prefix(&base_path, params.storage_options())?,
290 paginated_lister,
291 })
292 }
293}
294
295fn check_s3_express(url: &Url, storage_options: &StorageOptions) -> bool {
297 storage_options
298 .0
299 .get("s3_express")
300 .map(|v| str_is_truthy(v))
301 .unwrap_or(false)
302 || url.authority().ends_with("--x-s3")
303}
304
305fn effective_s3_endpoint(storage_options: &HashMap<AmazonS3ConfigKey, String>) -> Option<&str> {
306 storage_options
307 .get(&AmazonS3ConfigKey::S3Endpoint)
308 .or_else(|| storage_options.get(&AmazonS3ConfigKey::Endpoint))
309 .map(String::as_str)
310}
311
312async fn resolve_s3_region(
321 url: &Url,
322 resolved_s3_options: &ResolvedS3StorageOptions,
323) -> Result<Option<String>> {
324 let storage_options = &resolved_s3_options.options;
325 if let Some(region) = storage_options.get(&AmazonS3ConfigKey::Region) {
326 Ok(Some(region.clone()))
327 } else if resolved_s3_options.effective_endpoint().is_none() {
328 let bucket = url.host_str().ok_or_else(|| {
331 Error::invalid_input(format!("Could not parse bucket from url: {}", url))
332 })?;
333
334 let mut client_options = ClientOptions::default();
335 for (key, value) in storage_options {
336 if let AmazonS3ConfigKey::Client(client_key) = key {
337 client_options = client_options.with_config(*client_key, value.clone());
338 }
339 }
340
341 let bucket_region =
342 object_store::aws::resolve_bucket_region(bucket, &client_options).await?;
343 Ok(Some(bucket_region))
344 } else {
345 Ok(resolved_s3_options.profile_region.clone())
346 }
347}
348
349#[derive(Debug, Clone, PartialEq)]
354pub enum AwsProviderScheme {
355 Token,
358 Ecs,
362 Irsa,
366}
367
368pub async fn build_aws_credential(
388 credentials_refresh_offset: Duration,
389 credentials: Option<AwsCredentialProvider>,
390 storage_options: Option<&HashMap<AmazonS3ConfigKey, String>>,
391 region: Option<String>,
392 storage_options_accessor: Option<Arc<StorageOptionsAccessor>>,
393 provider_scheme: Option<AwsProviderScheme>,
394) -> Result<(AwsCredentialProvider, String)> {
395 use aws_config::meta::region::RegionProviderChain;
396 const DEFAULT_REGION: &str = "us-west-2";
397
398 let region = if let Some(region) = region {
399 region
400 } else {
401 RegionProviderChain::default_provider()
402 .or_else(DEFAULT_REGION)
403 .region()
404 .await
405 .map(|r| r.as_ref().to_string())
406 .unwrap_or(DEFAULT_REGION.to_string())
407 };
408
409 if let Some(creds) = credentials {
411 return Ok((creds, region));
412 }
413
414 if let Some(dynamic_creds) = build_dynamic_credential_provider::<ObjectStoreAwsCredential>(
416 storage_options_accessor.clone(),
417 )
418 .await?
419 {
420 return Ok((dynamic_creds, region));
421 }
422
423 if storage_options_accessor
426 .as_ref()
427 .is_some_and(|a| a.has_provider())
428 {
429 log::debug!(
430 "Storage options from provider do not contain explicit AWS credentials, \
431 falling back to default AWS credentials chain."
432 );
433 }
434
435 if let Some(scheme) = provider_scheme {
437 return match scheme {
438 AwsProviderScheme::Token => {
439 let creds = storage_options
440 .and_then(extract_static_s3_credentials)
441 .ok_or_else(|| {
442 Error::invalid_input(
443 "aws_provider_scheme=token requires aws_access_key_id \
444 and aws_secret_access_key to be set",
445 )
446 })?;
447 Ok((Arc::new(creds), region))
448 }
449 AwsProviderScheme::Ecs => {
450 let provider = EcsCredentialsProvider::builder().build();
451 Ok((
452 Arc::new(AwsCredentialAdapter::new(
453 Arc::new(provider),
454 credentials_refresh_offset,
455 )),
456 region,
457 ))
458 }
459 AwsProviderScheme::Irsa => {
460 let conf = ProviderConfig::default().with_region(Some(Region::new(region.clone())));
461 let provider = WebIdentityTokenCredentialsProvider::builder()
462 .configure(&conf)
463 .build();
464 Ok((
465 Arc::new(AwsCredentialAdapter::new(
466 Arc::new(provider),
467 credentials_refresh_offset,
468 )),
469 region,
470 ))
471 }
472 };
473 }
474
475 if let Some(opts) = storage_options {
476 if let Some(creds) = extract_static_s3_credentials(opts) {
478 return Ok((Arc::new(creds), region));
479 }
480 }
481
482 let credentials_provider = DefaultCredentialsChain::builder().build().await;
483 Ok((
484 Arc::new(AwsCredentialAdapter::new(
485 Arc::new(credentials_provider),
486 credentials_refresh_offset,
487 )),
488 region,
489 ))
490}
491
492fn extract_static_s3_credentials(
493 options: &HashMap<AmazonS3ConfigKey, String>,
494) -> Option<StaticCredentialProvider<ObjectStoreAwsCredential>> {
495 let key_id = options.get(&AmazonS3ConfigKey::AccessKeyId).cloned();
496 let secret_key = options.get(&AmazonS3ConfigKey::SecretAccessKey).cloned();
497 let token = options.get(&AmazonS3ConfigKey::Token).cloned();
498 match (key_id, secret_key, token) {
499 (Some(key_id), Some(secret_key), token) => {
500 Some(StaticCredentialProvider::new(ObjectStoreAwsCredential {
501 key_id,
502 secret_key,
503 token,
504 }))
505 }
506 _ => None,
507 }
508}
509
510#[derive(Debug)]
512pub struct AwsCredentialAdapter {
513 pub inner: Arc<dyn ProvideCredentials>,
514
515 credentials: RwLock<Option<Arc<aws_credential_types::Credentials>>>,
517
518 refresh_lock: Mutex<()>,
520
521 credentials_refresh_offset: Duration,
523}
524
525impl AwsCredentialAdapter {
526 pub fn new(
527 provider: Arc<dyn ProvideCredentials>,
528 credentials_refresh_offset: Duration,
529 ) -> Self {
530 Self {
531 inner: provider,
532 credentials: RwLock::new(None),
533 refresh_lock: Mutex::new(()),
534 credentials_refresh_offset,
535 }
536 }
537}
538
539fn to_system_time(time: std::time::SystemTime) -> SystemTime {
541 let duration_since_epoch = time
542 .duration_since(std::time::UNIX_EPOCH)
543 .expect("time should be after UNIX_EPOCH");
544 UNIX_EPOCH + duration_since_epoch
545}
546
547#[async_trait::async_trait]
548impl CredentialProvider for AwsCredentialAdapter {
549 type Credential = ObjectStoreAwsCredential;
550
551 async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
552 let cached = self.credentials.read().await.clone();
553 let credentials = match cached {
554 None => self.must_refresh().await?,
555 Some(credentials) => {
556 let (is_expired, is_stale) = self.check_staleness(&credentials);
557 if is_expired {
558 self.must_refresh().await?
559 } else if is_stale {
560 self.maybe_refresh(credentials).await?
561 } else {
562 credentials
563 }
564 }
565 };
566 Ok(Arc::new(Self::Credential {
567 key_id: credentials.access_key_id().to_string(),
568 secret_key: credentials.secret_access_key().to_string(),
569 token: credentials.session_token().map(|s| s.to_string()),
570 }))
571 }
572}
573
574impl AwsCredentialAdapter {
575 fn check_staleness(&self, credentials: &aws_credential_types::Credentials) -> (bool, bool) {
577 match credentials.expiry() {
578 None => (false, false),
579 Some(expiry) => {
580 let remaining = to_system_time(expiry).duration_since(SystemTime::now());
581 match remaining {
582 Ok(remaining) => (
583 remaining.is_zero(),
584 remaining <= self.credentials_refresh_offset,
585 ),
586 Err(_) => (true, true),
587 }
588 }
589 }
590 }
591
592 async fn must_refresh(&self) -> ObjectStoreResult<Arc<aws_credential_types::Credentials>> {
594 let _lock = self.refresh_lock.lock().await;
595 if let Some(credentials) = self.credentials.read().await.clone()
596 && !self.check_staleness(&credentials).0
597 {
598 return Ok(credentials);
599 }
600 self.do_refresh().await
601 }
602
603 async fn maybe_refresh(
605 &self,
606 old_credentials: Arc<aws_credential_types::Credentials>,
607 ) -> ObjectStoreResult<Arc<aws_credential_types::Credentials>> {
608 let Ok(_lock) = self.refresh_lock.try_lock() else {
609 return Ok(old_credentials);
610 };
611 if let Some(credentials) = self.credentials.read().await.clone()
612 && !Arc::ptr_eq(&credentials, &old_credentials)
613 && !self.check_staleness(&credentials).0
614 {
615 return Ok(credentials);
616 }
617 match self.do_refresh().await {
618 Ok(credentials) => Ok(credentials),
619 Err(_) if !self.check_staleness(&old_credentials).0 => Ok(old_credentials),
621 Err(error) => Err(error),
622 }
623 }
624
625 async fn do_refresh(&self) -> ObjectStoreResult<Arc<aws_credential_types::Credentials>> {
626 let credentials = Arc::new(
627 self.inner
628 .provide_credentials()
629 .await
630 .map_err(|e| Error::io(format!("Failed to get AWS credentials: {:?}", e)))?,
631 );
632 *self.credentials.write().await = Some(credentials.clone());
633 Ok(credentials)
634 }
635}
636
637impl StorageOptions {
638 pub fn with_env_s3(&mut self) {
643 self.merge_env_options(std::env::vars_os(), |key| {
644 AmazonS3ConfigKey::from_str(key)
645 .ok()
646 .map(|key| key.as_ref().to_string())
647 });
648 }
649
650 pub fn as_s3_options(&self) -> HashMap<AmazonS3ConfigKey, String> {
668 self.as_cloud_options()
669 }
670
671 pub fn aws_provider_scheme(&self) -> Result<Option<AwsProviderScheme>> {
673 match self.0.get("aws_provider_scheme").map(|s| s.as_str()) {
674 None | Some("") => Ok(None),
675 Some("token") => Ok(Some(AwsProviderScheme::Token)),
676 Some("ecs") => Ok(Some(AwsProviderScheme::Ecs)),
677 Some("irsa") => Ok(Some(AwsProviderScheme::Irsa)),
678 Some(other) => Err(Error::invalid_input(format!(
679 "Invalid aws_provider_scheme '{}'. Valid values are: token, ecs, irsa",
680 other
681 ))),
682 }
683 }
684}
685
686impl ObjectStoreParams {
687 pub fn with_aws_credentials(
689 aws_credentials: Option<AwsCredentialProvider>,
690 region: Option<String>,
691 ) -> Self {
692 let storage_options_accessor = region.map(|region| {
693 let opts: HashMap<String, String> =
694 [("region".into(), region)].iter().cloned().collect();
695 Arc::new(StorageOptionsAccessor::with_static_options(opts))
696 });
697 Self {
698 aws_credentials,
699 storage_options_accessor,
700 ..Default::default()
701 }
702 }
703}
704
705pub type DynamicStorageOptionsCredentialProvider =
706 NamespaceCredentialsProvider<ObjectStoreAwsCredential>;
707
708#[cfg(test)]
709mod tests {
710 use crate::object_store::ObjectStoreRegistry;
711 use crate::object_store::StorageOptionsProvider;
712 #[allow(deprecated)]
713 use aws_config::profile::profile_file::{ProfileFileKind, ProfileFiles};
714 use aws_credential_types::provider::error::CredentialsError;
715 use mock_instant::thread_local::MockClock;
716 use object_store::{ObjectStoreExt, path::Path};
717 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
718 use wiremock::matchers::{header, method, path, query_param};
719 use wiremock::{Mock, MockServer, ResponseTemplate};
720
721 use super::*;
722
723 #[rstest::rstest]
724 #[case::default(None, false)]
725 #[case::explicit_multipart(Some("multipart"), false)]
726 #[case::existing_destination(None, true)]
727 #[tokio::test]
728 async fn test_s3_conditional_copy(
729 #[case] copy_option: Option<&str>,
730 #[case] destination_exists: bool,
731 ) {
732 let server = MockServer::start().await;
733 let metadata = ResponseTemplate::new(200)
734 .insert_header("content-length", "3")
735 .insert_header("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT")
736 .insert_header("etag", "\"source\"");
737 Mock::given(method("HEAD"))
738 .and(path("/bucket/staging.lance"))
739 .respond_with(metadata.clone())
740 .expect(1)
741 .mount(&server)
742 .await;
743 Mock::given(method("POST"))
744 .and(path("/bucket/final.lance"))
745 .and(query_param("uploads", ""))
746 .respond_with(ResponseTemplate::new(200).set_body_string(
747 "<InitiateMultipartUploadResult><UploadId>upload-1</UploadId></InitiateMultipartUploadResult>",
748 ))
749 .expect(1)
750 .mount(&server)
751 .await;
752 Mock::given(method("PUT"))
753 .and(query_param("uploadId", "upload-1"))
754 .and(query_param("partNumber", "1"))
755 .and(header("x-amz-copy-source", "bucket/staging.lance"))
756 .respond_with(
757 ResponseTemplate::new(200)
758 .set_body_string("<CopyPartResult><ETag>\"part\"</ETag></CopyPartResult>"),
759 )
760 .expect(1)
761 .mount(&server)
762 .await;
763 let completion = if destination_exists {
764 ResponseTemplate::new(412).set_body_string(
765 "<Error><Code>PreconditionFailed</Code><Message>exists</Message></Error>",
766 )
767 } else {
768 ResponseTemplate::new(200).set_body_string(
769 "<CompleteMultipartUploadResult><ETag>\"destination\"</ETag></CompleteMultipartUploadResult>",
770 )
771 };
772 Mock::given(method("POST"))
773 .and(query_param("uploadId", "upload-1"))
774 .and(header("if-none-match", "*"))
775 .respond_with(completion)
776 .expect(1)
777 .mount(&server)
778 .await;
779 Mock::given(method("HEAD"))
780 .and(path("/bucket/final.lance"))
781 .respond_with(metadata)
782 .expect(u64::from(destination_exists))
783 .mount(&server)
784 .await;
785 Mock::given(method("DELETE"))
786 .and(query_param("uploadId", "upload-1"))
787 .respond_with(ResponseTemplate::new(204))
788 .expect(u64::from(destination_exists))
789 .mount(&server)
790 .await;
791
792 let mut options = HashMap::from([
793 ("aws_access_key_id".into(), "test".into()),
794 ("aws_secret_access_key".into(), "test".into()),
795 ("aws_region".into(), "us-east-1".into()),
796 ("aws_endpoint".into(), server.uri()),
797 ("aws_allow_http".into(), "true".into()),
798 ]);
799 if let Some(value) = copy_option {
800 options.insert("aws_copy_if_not_exists".into(), value.into());
801 }
802 let params = ObjectStoreParams {
803 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
804 options,
805 ))),
806 ..Default::default()
807 };
808 let (store, _) = ObjectStore::from_uri_and_params(
809 Arc::new(ObjectStoreRegistry::default()),
810 "s3://bucket",
811 ¶ms,
812 )
813 .await
814 .unwrap();
815 let result = store
816 .copy_if_not_exists(&Path::from("staging.lance"), &Path::from("final.lance"))
817 .await
818 .unwrap();
819 assert_eq!(result.size, 3);
820 }
821
822 #[tokio::test]
823 async fn test_s3_conditional_copy_preserves_explicit_header() {
824 let server = MockServer::start().await;
825 Mock::given(method("PUT"))
826 .and(path("/bucket/final.lance"))
827 .and(header("x-amz-copy-source", "bucket/staging.lance"))
828 .and(header("custom-condition", "absent"))
829 .respond_with(ResponseTemplate::new(200).set_body_string(
830 "<CopyObjectResult><ETag>\"destination\"</ETag></CopyObjectResult>",
831 ))
832 .expect(1)
833 .mount(&server)
834 .await;
835 let options = StorageOptions(HashMap::from([
836 ("aws_access_key_id".into(), "test".into()),
837 ("aws_secret_access_key".into(), "test".into()),
838 ("aws_region".into(), "us-east-1".into()),
839 ("aws_endpoint".into(), server.uri()),
840 ("aws_allow_http".into(), "true".into()),
841 (
842 "aws_copy_if_not_exists".into(),
843 "header:custom-condition:absent".into(),
844 ),
845 ]));
846 let store = AwsStoreProvider
847 .build_amazon_s3_store(
848 &mut Url::parse("s3://bucket").unwrap(),
849 &ObjectStoreParams::default(),
850 &options,
851 ResolvedS3StorageOptions::new(options.as_s3_options(), None),
852 false,
853 None,
854 )
855 .await
856 .unwrap();
857 store
858 .copy_if_not_exists(&Path::from("staging.lance"), &Path::from("final.lance"))
859 .await
860 .unwrap();
861 }
862
863 #[rstest::rstest]
864 #[case::canonical("aws_region", "region")]
865 #[case::canonical_over_case_variant("aws_region", "AWS_REGION")]
866 #[case::canonical_case_tie("AWS_REGION", "Aws_Region")]
867 #[case::case_variant_over_alias("AWS_REGION", "region")]
868 #[case::lexical_alias("REGION", "region")]
869 fn test_storage_options_alias_precedence(
870 #[case] preferred_key: &str,
871 #[case] conflicting_key: &str,
872 #[values("preferred", "")] preferred_value: &str,
873 #[values(false, true)] is_reversed: bool,
874 ) {
875 for _ in 0..16 {
877 let mut entries = [
878 (preferred_key.to_string(), preferred_value.to_string()),
879 (conflicting_key.to_string(), "conflicting".to_string()),
880 ("lance_custom_option".to_string(), "ignored".to_string()),
881 ];
882 if is_reversed {
883 entries.reverse();
884 }
885 let options = StorageOptions(HashMap::from(entries));
886 assert_eq!(
887 options.as_s3_options(),
888 HashMap::from([(AmazonS3ConfigKey::Region, preferred_value.to_string())])
889 );
890 }
891 }
892
893 #[derive(Debug, Default)]
894 struct MockAwsCredentialsProvider {
895 called: AtomicBool,
896 }
897
898 #[async_trait::async_trait]
899 impl CredentialProvider for MockAwsCredentialsProvider {
900 type Credential = ObjectStoreAwsCredential;
901
902 async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
903 self.called.store(true, Ordering::Relaxed);
904 Ok(Arc::new(Self::Credential {
905 key_id: "".to_string(),
906 secret_key: "".to_string(),
907 token: None,
908 }))
909 }
910 }
911
912 #[allow(deprecated)]
913 async fn load_test_profile(config: &str) -> SdkConfig {
914 let profile_files = ProfileFiles::builder()
915 .with_contents(ProfileFileKind::Config, config)
916 .build();
917 aws_config::defaults(BehaviorVersion::latest())
918 .profile_name("selected")
919 .profile_files(profile_files)
920 .load()
921 .await
922 }
923
924 #[derive(Debug)]
925 struct FailingAwsCredentialsProvider;
926
927 impl ProvideCredentials for FailingAwsCredentialsProvider {
928 fn provide_credentials<'a>(
929 &'a self,
930 ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
931 where
932 Self: 'a,
933 {
934 aws_credential_types::provider::future::ProvideCredentials::new(async {
935 Err(CredentialsError::provider_error(Box::new(
936 std::io::Error::other("Glue credential endpoint unavailable"),
937 )))
938 })
939 }
940 }
941
942 #[tokio::test]
943 async fn test_aws_credential_failure_is_io_error() {
944 let provider = AwsCredentialAdapter::new(
945 Arc::new(FailingAwsCredentialsProvider),
946 Duration::from_secs(60),
947 );
948
949 let error = provider.get_credential().await.unwrap_err();
950 let object_store::Error::Generic { source, .. } = &error else {
951 panic!("expected a generic object store error, got {error}");
952 };
953 assert!(matches!(
954 source.downcast_ref::<Error>(),
955 Some(Error::IO { .. })
956 ));
957
958 let message = error.to_string();
959 assert!(message.contains("Failed to get AWS credentials"));
960 assert!(message.contains("Glue credential endpoint unavailable"));
961 assert!(!message.contains("Encountered internal error"));
962 }
963
964 #[derive(Debug, Default)]
965 struct RefreshingAwsCredentialsProvider {
966 calls: AtomicUsize,
967 fail: AtomicBool,
968 advance_seconds: u64,
969 }
970
971 impl ProvideCredentials for RefreshingAwsCredentialsProvider {
972 fn provide_credentials<'a>(
973 &'a self,
974 ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
975 where
976 Self: 'a,
977 {
978 aws_credential_types::provider::future::ProvideCredentials::new(async {
979 let call = self.calls.fetch_add(1, Ordering::Relaxed);
980 tokio::task::yield_now().await;
981 MockClock::advance_system_time(Duration::from_secs(self.advance_seconds));
982 if self.fail.load(Ordering::Relaxed) {
983 return Err(CredentialsError::provider_error(std::io::Error::other(
984 "STS unavailable",
985 )));
986 }
987 Ok(aws_credential_types::Credentials::new(
988 format!("key-{call}"),
989 "secret",
990 Some("token".to_string()),
991 Some(
992 std::time::UNIX_EPOCH + MockClock::system_time() + Duration::from_secs(120),
993 ),
994 "test",
995 ))
996 })
997 }
998 }
999
1000 #[rstest::rstest]
1001 #[case::fresh(Some(120), 0, true, 0)]
1002 #[case::no_expiry(None, 0, true, 0)]
1003 #[case::refresh_boundary(Some(60), 0, true, 1)]
1004 #[case::stale(Some(30), 0, true, 1)]
1005 #[case::expires_during_refresh(Some(30), 30, false, 1)]
1006 #[case::expiry_boundary(Some(0), 0, false, 1)]
1007 #[case::expired(Some(-1), 0, false, 1)]
1008 #[tokio::test]
1009 async fn test_aws_credential_refresh_failure(
1010 #[case] expires_in: Option<i64>,
1011 #[case] advance_seconds: u64,
1012 #[case] succeeds: bool,
1013 #[case] expected_calls: usize,
1014 ) {
1015 MockClock::set_system_time(Duration::from_secs(100_000));
1016 let inner = Arc::new(RefreshingAwsCredentialsProvider {
1017 fail: AtomicBool::new(true),
1018 advance_seconds,
1019 ..Default::default()
1020 });
1021 let provider = AwsCredentialAdapter::new(inner.clone(), Duration::from_secs(60));
1022 *provider.credentials.write().await =
1023 Some(Arc::new(aws_credential_types::Credentials::new(
1024 "cached-key",
1025 "cached-secret",
1026 Some("cached-token".to_string()),
1027 expires_in.map(|seconds| {
1028 std::time::UNIX_EPOCH + Duration::from_secs((100_000 + seconds) as u64)
1029 }),
1030 "test",
1031 )));
1032
1033 let result = provider.get_credential().await;
1034 if succeeds {
1035 let credentials = result.unwrap();
1036 assert_eq!(credentials.key_id, "cached-key");
1037 assert_eq!(credentials.secret_key, "cached-secret");
1038 assert_eq!(credentials.token.as_deref(), Some("cached-token"));
1039 } else {
1040 let error = result.unwrap_err();
1041 assert!(matches!(error, object_store::Error::Generic { .. }));
1042 assert!(error.to_string().contains("STS unavailable"));
1043 }
1044 assert_eq!(inner.calls.load(Ordering::Relaxed), expected_calls);
1045 }
1046
1047 #[tokio::test]
1048 async fn test_aws_credential_concurrent_refresh() {
1049 MockClock::set_system_time(Duration::from_secs(100_000));
1050 let inner = Arc::new(RefreshingAwsCredentialsProvider::default());
1051 let provider = AwsCredentialAdapter::new(inner.clone(), Duration::from_secs(60));
1052
1053 let (first, second) = tokio::join!(provider.get_credential(), provider.get_credential());
1054 assert_eq!(first.unwrap().key_id, "key-0");
1055 assert_eq!(second.unwrap().key_id, "key-0");
1056 assert_eq!(inner.calls.load(Ordering::Relaxed), 1);
1057
1058 MockClock::advance_system_time(Duration::from_secs(60));
1059 let (refreshed, cached) =
1060 tokio::join!(provider.get_credential(), provider.get_credential());
1061 assert_eq!(refreshed.unwrap().key_id, "key-1");
1062 assert_eq!(cached.unwrap().key_id, "key-0");
1063 assert_eq!(provider.get_credential().await.unwrap().key_id, "key-1");
1064 assert_eq!(inner.calls.load(Ordering::Relaxed), 2);
1065
1066 MockClock::advance_system_time(Duration::from_secs(120));
1067 let (first, second) = tokio::join!(provider.get_credential(), provider.get_credential());
1068 assert_eq!(first.unwrap().key_id, "key-2");
1069 assert_eq!(second.unwrap().key_id, "key-2");
1070 assert_eq!(inner.calls.load(Ordering::Relaxed), 3);
1071 }
1072
1073 #[tokio::test]
1074 async fn test_injected_aws_creds_option_is_used() {
1075 let mock_provider = Arc::new(MockAwsCredentialsProvider::default());
1076 let registry = Arc::new(ObjectStoreRegistry::default());
1077
1078 let params = ObjectStoreParams {
1079 aws_credentials: Some(mock_provider.clone() as AwsCredentialProvider),
1080 ..ObjectStoreParams::default()
1081 };
1082
1083 assert!(!mock_provider.called.load(Ordering::Relaxed));
1085
1086 let (store, _) = ObjectStore::from_uri_and_params(registry, "s3://not-a-bucket", ¶ms)
1087 .await
1088 .unwrap();
1089
1090 let _ = store
1092 .open(&Path::parse("/").unwrap())
1093 .await
1094 .unwrap()
1095 .get_range(0..1)
1096 .await;
1097
1098 assert!(mock_provider.called.load(Ordering::Relaxed));
1100 }
1101
1102 #[tokio::test]
1103 async fn test_resolve_s3_region_from_aws_profile() {
1104 let profile_config = load_test_profile(
1105 "[profile selected]\n\
1106 region = us-west-004\n\
1107 endpoint_url = https://s3.us-west-004.backblazeb2.com\n\
1108 aws_access_key_id = test-key\n\
1109 aws_secret_access_key = test-secret",
1110 )
1111 .await;
1112 let url = Url::parse("s3://test-bucket/path").unwrap();
1113
1114 let resolved_s3_options =
1115 ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
1116 let region = resolve_s3_region(&url, &resolved_s3_options).await.unwrap();
1117
1118 assert_eq!(region.as_deref(), Some("us-west-004"));
1119 assert_eq!(
1120 resolved_s3_options
1121 .options
1122 .get(&AmazonS3ConfigKey::Endpoint),
1123 Some(&"https://s3.us-west-004.backblazeb2.com".to_string())
1124 );
1125
1126 let explicit_options = HashMap::from([
1127 (AmazonS3ConfigKey::Region, "explicit-region".to_string()),
1128 (
1129 AmazonS3ConfigKey::Endpoint,
1130 "https://explicit.example.com".to_string(),
1131 ),
1132 ]);
1133 let resolved_s3_options =
1134 ResolvedS3StorageOptions::new(explicit_options, Some(&profile_config));
1135 let region = resolve_s3_region(&url, &resolved_s3_options).await.unwrap();
1136
1137 assert_eq!(region.as_deref(), Some("explicit-region"));
1138 assert_eq!(
1139 resolved_s3_options
1140 .options
1141 .get(&AmazonS3ConfigKey::Endpoint),
1142 Some(&"https://explicit.example.com".to_string())
1143 );
1144 }
1145
1146 #[tokio::test]
1147 async fn test_region_only_aws_profile_preserves_bucket_discovery() {
1148 let profile_config = load_test_profile("[profile selected]\nregion = us-east-1").await;
1149 let resolved_s3_options =
1150 ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
1151
1152 let url = Url::parse("s3:///path").unwrap();
1153 let error = resolve_s3_region(&url, &resolved_s3_options)
1154 .await
1155 .unwrap_err();
1156
1157 assert!(matches!(error, Error::InvalidInput { .. }));
1158 assert!(error.to_string().contains("Could not parse bucket"));
1159 }
1160
1161 #[tokio::test]
1162 async fn test_r2_aws_profile_requires_constant_size_upload_parts() {
1163 let profile_config = load_test_profile(
1164 "[profile selected]\n\
1165 region = auto\n\
1166 endpoint_url = https://account.r2.cloudflarestorage.com",
1167 )
1168 .await;
1169 let resolved_s3_options =
1170 ResolvedS3StorageOptions::new(HashMap::new(), Some(&profile_config));
1171
1172 assert!(resolved_s3_options.requires_constant_size_upload_parts());
1173 }
1174
1175 #[test]
1176 fn test_s3_path_parsing() {
1177 let provider = AwsStoreProvider;
1178
1179 let cases = [
1180 ("s3://bucket/path/to/file", "path/to/file"),
1181 ("s3://bucket/测试path/to/file", "测试path/to/file"),
1183 ("s3://bucket/path/&to/file", "path/&to/file"),
1184 ("s3://bucket/path/=to/file", "path/=to/file"),
1185 (
1186 "s3+ddb://bucket/path/to/file?ddbTableName=test",
1187 "path/to/file",
1188 ),
1189 ];
1190
1191 for (uri, expected_path) in cases {
1192 let url = Url::parse(uri).unwrap();
1193 let path = provider.extract_path(&url).unwrap();
1194 let expected_path = Path::parse(expected_path).unwrap();
1198 assert_eq!(path, expected_path)
1199 }
1200 }
1201
1202 #[test]
1208 fn test_s3_non_ascii_path_no_double_encoding() {
1209 let provider = AwsStoreProvider;
1210
1211 let url = Url::parse("s3://bucket/中文路径").unwrap();
1216 let path = provider.extract_path(&url).unwrap();
1217
1218 assert_eq!(path.as_ref(), "中文路径");
1220 }
1221
1222 #[test]
1223 fn test_is_s3_express() {
1224 let cases = [
1225 (
1226 "s3://bucket/path/to/file",
1227 HashMap::from([("s3_express".to_string(), "true".to_string())]),
1228 true,
1229 ),
1230 (
1231 "s3://bucket/path/to/file",
1232 HashMap::from([("s3_express".to_string(), "false".to_string())]),
1233 false,
1234 ),
1235 ("s3://bucket/path/to/file", HashMap::from([]), false),
1236 (
1237 "s3://bucket--x-s3/path/to/file",
1238 HashMap::from([("s3_express".to_string(), "true".to_string())]),
1239 true,
1240 ),
1241 (
1242 "s3://bucket--x-s3/path/to/file",
1243 HashMap::from([("s3_express".to_string(), "false".to_string())]),
1244 true, ),
1246 ("s3://bucket--x-s3/path/to/file", HashMap::from([]), true),
1247 ];
1248
1249 for (uri, storage_map, expected) in cases {
1250 let url = Url::parse(uri).unwrap();
1251 let storage_options = StorageOptions(storage_map);
1252 let is_s3_express = check_s3_express(&url, &storage_options);
1253 assert_eq!(is_s3_express, expected);
1254 }
1255 }
1256
1257 #[tokio::test]
1258 async fn test_use_opendal_flag() {
1259 use crate::object_store::StorageOptionsAccessor;
1260 let provider = AwsStoreProvider;
1261 let url = Url::parse("s3://test-bucket/path").unwrap();
1262 let params_with_flag = ObjectStoreParams {
1263 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
1264 HashMap::from([
1265 ("use_opendal".to_string(), "true".to_string()),
1266 ("region".to_string(), "us-west-2".to_string()),
1267 ]),
1268 ))),
1269 ..Default::default()
1270 };
1271
1272 let store = provider
1273 .new_store(url.clone(), ¶ms_with_flag)
1274 .await
1275 .unwrap();
1276 assert_eq!(store.scheme, "s3");
1277 }
1278
1279 #[tokio::test]
1282 async fn test_use_opendal_rejects_an_explicit_credential_provider() {
1283 use crate::object_store::StorageOptionsAccessor;
1284 let provider = AwsStoreProvider;
1285 let url = Url::parse("s3://test-bucket/path").unwrap();
1286 let params = ObjectStoreParams {
1287 aws_credentials: Some(
1288 Arc::new(MockAwsCredentialsProvider::default()) as AwsCredentialProvider
1289 ),
1290 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
1291 HashMap::from([
1292 ("use_opendal".to_string(), "true".to_string()),
1293 ("region".to_string(), "us-west-2".to_string()),
1294 ]),
1295 ))),
1296 ..Default::default()
1297 };
1298
1299 let err = provider.new_store(url, ¶ms).await.unwrap_err();
1300 assert!(
1301 matches!(err, Error::NotSupported { .. }),
1302 "got: {err:?}, expected NotSupported"
1303 );
1304 assert!(err.to_string().contains("aws_credentials"), "got: {err}");
1305 }
1306
1307 #[rstest::rstest]
1311 #[case::native("false", true)]
1312 #[case::opendal("true", false)]
1313 #[tokio::test]
1314 async fn test_s3_express_is_paged_by_continuation_token(
1315 #[case] use_opendal: &str,
1316 #[case] paginated: bool,
1317 ) {
1318 let provider = AwsStoreProvider;
1319 let url = Url::parse("s3://test-bucket--use1-az4--x-s3/path").unwrap();
1321 let params = ObjectStoreParams {
1322 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
1323 HashMap::from([
1324 ("use_opendal".to_string(), use_opendal.to_string()),
1325 ("region".to_string(), "us-east-1".to_string()),
1326 ]),
1327 ))),
1328 ..Default::default()
1329 };
1330
1331 let store = provider.new_store(url, ¶ms).await.unwrap();
1332
1333 assert!(!store.list_is_lexically_ordered);
1334 assert_eq!(store.paginated_lister.is_some(), paginated);
1335 }
1336
1337 #[derive(Debug)]
1338 struct MockStorageOptionsProvider {
1339 call_count: Arc<RwLock<usize>>,
1340 expires_in_millis: Option<u64>,
1341 }
1342
1343 impl MockStorageOptionsProvider {
1344 fn new(expires_in_millis: Option<u64>) -> Self {
1345 Self {
1346 call_count: Arc::new(RwLock::new(0)),
1347 expires_in_millis,
1348 }
1349 }
1350
1351 async fn get_call_count(&self) -> usize {
1352 *self.call_count.read().await
1353 }
1354 }
1355
1356 #[async_trait::async_trait]
1357 impl StorageOptionsProvider for MockStorageOptionsProvider {
1358 async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
1359 let count = {
1360 let mut c = self.call_count.write().await;
1361 *c += 1;
1362 *c
1363 };
1364
1365 let mut options = HashMap::from([
1366 ("aws_access_key_id".to_string(), format!("AKID_{}", count)),
1367 (
1368 "aws_secret_access_key".to_string(),
1369 format!("SECRET_{}", count),
1370 ),
1371 ("aws_session_token".to_string(), format!("TOKEN_{}", count)),
1372 ]);
1373
1374 if let Some(expires_in) = self.expires_in_millis {
1375 let now_ms = SystemTime::now()
1376 .duration_since(UNIX_EPOCH)
1377 .unwrap()
1378 .as_millis() as u64;
1379 let expires_at = now_ms + expires_in;
1380 options.insert("expires_at_millis".to_string(), expires_at.to_string());
1381 }
1382
1383 Ok(Some(options))
1384 }
1385
1386 fn provider_id(&self) -> String {
1387 let ptr = Arc::as_ptr(&self.call_count) as usize;
1388 format!("MockStorageOptionsProvider {{ id: {} }}", ptr)
1389 }
1390 }
1391
1392 #[tokio::test]
1393 async fn test_dynamic_credential_provider_with_initial_cache() {
1394 MockClock::set_system_time(Duration::from_secs(100_000));
1395
1396 let now_ms = MockClock::system_time().as_millis() as u64;
1397
1398 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1400 600_000, )));
1402
1403 let expires_at = now_ms + 600_000; let initial_options = HashMap::from([
1406 ("aws_access_key_id".to_string(), "AKID_CACHED".to_string()),
1407 (
1408 "aws_secret_access_key".to_string(),
1409 "SECRET_CACHED".to_string(),
1410 ),
1411 ("aws_session_token".to_string(), "TOKEN_CACHED".to_string()),
1412 ("expires_at_millis".to_string(), expires_at.to_string()),
1413 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1415
1416 let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1417 mock.clone(),
1418 initial_options,
1419 );
1420
1421 let cred = provider.get_credential().await.unwrap();
1423 assert_eq!(cred.key_id, "AKID_CACHED");
1424 assert_eq!(cred.secret_key, "SECRET_CACHED");
1425 assert_eq!(cred.token, Some("TOKEN_CACHED".to_string()));
1426
1427 assert_eq!(mock.get_call_count().await, 0);
1429 }
1430
1431 #[tokio::test]
1432 async fn test_dynamic_credential_provider_with_expired_cache() {
1433 MockClock::set_system_time(Duration::from_secs(100_000));
1434
1435 let now_ms = MockClock::system_time().as_millis() as u64;
1436
1437 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1439 600_000, )));
1441
1442 let expired_time = now_ms - 1_000; let initial_options = HashMap::from([
1445 ("aws_access_key_id".to_string(), "AKID_EXPIRED".to_string()),
1446 (
1447 "aws_secret_access_key".to_string(),
1448 "SECRET_EXPIRED".to_string(),
1449 ),
1450 ("expires_at_millis".to_string(), expired_time.to_string()),
1451 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1453
1454 let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1455 mock.clone(),
1456 initial_options,
1457 );
1458
1459 let cred = provider.get_credential().await.unwrap();
1461 assert_eq!(cred.key_id, "AKID_1");
1462 assert_eq!(cred.secret_key, "SECRET_1");
1463 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1464
1465 assert_eq!(mock.get_call_count().await, 1);
1467 }
1468
1469 #[tokio::test]
1470 async fn test_dynamic_credential_provider_refresh_lead_time() {
1471 MockClock::set_system_time(Duration::from_secs(100_000));
1472
1473 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1475 30_000, )));
1477
1478 let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1481
1482 let cred = provider.get_credential().await.unwrap();
1486 assert_eq!(cred.key_id, "AKID_1");
1487 assert_eq!(mock.get_call_count().await, 1);
1488
1489 let cred = provider.get_credential().await.unwrap();
1493 assert_eq!(cred.key_id, "AKID_2");
1494 assert_eq!(mock.get_call_count().await, 2);
1495 }
1496
1497 #[tokio::test]
1498 async fn test_dynamic_credential_provider_no_initial_cache() {
1499 MockClock::set_system_time(Duration::from_secs(100_000));
1500
1501 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1503 120_000, )));
1505
1506 let provider = DynamicStorageOptionsCredentialProvider::from_provider(mock.clone());
1508
1509 let cred = provider.get_credential().await.unwrap();
1511 assert_eq!(cred.key_id, "AKID_1");
1512 assert_eq!(cred.secret_key, "SECRET_1");
1513 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1514 assert_eq!(mock.get_call_count().await, 1);
1515
1516 let cred = provider.get_credential().await.unwrap();
1518 assert_eq!(cred.key_id, "AKID_1");
1519 assert_eq!(mock.get_call_count().await, 1); MockClock::set_system_time(Duration::from_secs(100_000 + 90));
1524 let cred = provider.get_credential().await.unwrap();
1525 assert_eq!(cred.key_id, "AKID_2");
1526 assert_eq!(cred.secret_key, "SECRET_2");
1527 assert_eq!(cred.token, Some("TOKEN_2".to_string()));
1528 assert_eq!(mock.get_call_count().await, 2);
1529
1530 MockClock::set_system_time(Duration::from_secs(100_000 + 210));
1532 let cred = provider.get_credential().await.unwrap();
1533 assert_eq!(cred.key_id, "AKID_3");
1534 assert_eq!(cred.secret_key, "SECRET_3");
1535 assert_eq!(mock.get_call_count().await, 3);
1536 }
1537
1538 #[tokio::test]
1539 async fn test_dynamic_credential_provider_with_initial_options() {
1540 MockClock::set_system_time(Duration::from_secs(100_000));
1541
1542 let now_ms = MockClock::system_time().as_millis() as u64;
1543
1544 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1546 600_000, )));
1548
1549 let expires_at = now_ms + 600_000; let initial_options = HashMap::from([
1552 ("aws_access_key_id".to_string(), "AKID_INITIAL".to_string()),
1553 (
1554 "aws_secret_access_key".to_string(),
1555 "SECRET_INITIAL".to_string(),
1556 ),
1557 ("aws_session_token".to_string(), "TOKEN_INITIAL".to_string()),
1558 ("expires_at_millis".to_string(), expires_at.to_string()),
1559 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1561
1562 let provider = DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1564 mock.clone(),
1565 initial_options,
1566 );
1567
1568 let cred = provider.get_credential().await.unwrap();
1570 assert_eq!(cred.key_id, "AKID_INITIAL");
1571 assert_eq!(cred.secret_key, "SECRET_INITIAL");
1572 assert_eq!(cred.token, Some("TOKEN_INITIAL".to_string()));
1573
1574 assert_eq!(mock.get_call_count().await, 0);
1576
1577 MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1580 let cred = provider.get_credential().await.unwrap();
1581 assert_eq!(cred.key_id, "AKID_1");
1582 assert_eq!(cred.secret_key, "SECRET_1");
1583 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1584
1585 assert_eq!(mock.get_call_count().await, 1);
1587
1588 MockClock::set_system_time(Duration::from_secs(100_000 + 660));
1590 let cred = provider.get_credential().await.unwrap();
1591 assert_eq!(cred.key_id, "AKID_2");
1592 assert_eq!(cred.secret_key, "SECRET_2");
1593 assert_eq!(cred.token, Some("TOKEN_2".to_string()));
1594
1595 assert_eq!(mock.get_call_count().await, 2);
1597
1598 MockClock::set_system_time(Duration::from_secs(100_000 + 960));
1600 let cred = provider.get_credential().await.unwrap();
1601 assert_eq!(cred.key_id, "AKID_3");
1602 assert_eq!(cred.secret_key, "SECRET_3");
1603 assert_eq!(cred.token, Some("TOKEN_3".to_string()));
1604
1605 assert_eq!(mock.get_call_count().await, 3);
1607 }
1608
1609 #[tokio::test]
1610 async fn test_dynamic_credential_provider_concurrent_access() {
1611 let mock = Arc::new(MockStorageOptionsProvider::new(Some(9999999999999)));
1613
1614 let provider = Arc::new(DynamicStorageOptionsCredentialProvider::from_provider(
1615 mock.clone(),
1616 ));
1617
1618 let mut handles = vec![];
1620 for i in 0..10 {
1621 let provider = provider.clone();
1622 let handle = tokio::spawn(async move {
1623 let cred = provider.get_credential().await.unwrap();
1624 assert_eq!(cred.key_id, "AKID_1");
1626 assert_eq!(cred.secret_key, "SECRET_1");
1627 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1628 i });
1630 handles.push(handle);
1631 }
1632
1633 let results: Vec<_> = futures::future::join_all(handles)
1635 .await
1636 .into_iter()
1637 .map(|r| r.unwrap())
1638 .collect();
1639
1640 assert_eq!(results.len(), 10);
1642 for i in 0..10 {
1643 assert!(results.contains(&i));
1644 }
1645
1646 let call_count = mock.get_call_count().await;
1649 assert_eq!(
1650 call_count, 1,
1651 "Provider should be called exactly once despite concurrent access"
1652 );
1653 }
1654
1655 #[tokio::test]
1656 async fn test_dynamic_credential_provider_concurrent_refresh() {
1657 MockClock::set_system_time(Duration::from_secs(100_000));
1658
1659 let now_ms = MockClock::system_time().as_millis() as u64;
1660
1661 let expires_at = now_ms - 1_000_000;
1663 let initial_options = HashMap::from([
1664 ("aws_access_key_id".to_string(), "AKID_OLD".to_string()),
1665 (
1666 "aws_secret_access_key".to_string(),
1667 "SECRET_OLD".to_string(),
1668 ),
1669 ("aws_session_token".to_string(), "TOKEN_OLD".to_string()),
1670 ("expires_at_millis".to_string(), expires_at.to_string()),
1671 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1673
1674 let mock = Arc::new(MockStorageOptionsProvider::new(Some(
1676 3_600_000, )));
1678
1679 let provider = Arc::new(
1680 DynamicStorageOptionsCredentialProvider::from_provider_with_initial(
1681 mock.clone(),
1682 initial_options,
1683 ),
1684 );
1685
1686 let mut handles = vec![];
1689 for i in 0..20 {
1690 let provider = provider.clone();
1691 let handle = tokio::spawn(async move {
1692 let cred = provider.get_credential().await.unwrap();
1693 assert_eq!(cred.key_id, "AKID_1");
1695 assert_eq!(cred.secret_key, "SECRET_1");
1696 assert_eq!(cred.token, Some("TOKEN_1".to_string()));
1697 i
1698 });
1699 handles.push(handle);
1700 }
1701
1702 let results: Vec<_> = futures::future::join_all(handles)
1704 .await
1705 .into_iter()
1706 .map(|r| r.unwrap())
1707 .collect();
1708
1709 assert_eq!(results.len(), 20);
1711
1712 let call_count = mock.get_call_count().await;
1715 assert!(
1716 call_count >= 1,
1717 "Provider should be called at least once, was called {} times",
1718 call_count
1719 );
1720
1721 assert!(
1723 call_count < 10,
1724 "Provider should not be called too many times due to lock contention, was called {} times",
1725 call_count
1726 );
1727 }
1728
1729 #[tokio::test]
1730 async fn test_explicit_aws_credentials_takes_precedence_over_accessor() {
1731 let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1733
1734 let accessor = Arc::new(StorageOptionsAccessor::with_provider(
1736 mock_storage_provider.clone(),
1737 ));
1738
1739 let explicit_cred_provider = Arc::new(MockAwsCredentialsProvider::default());
1741
1742 let (result, _region) = build_aws_credential(
1745 Duration::from_secs(300),
1746 Some(explicit_cred_provider.clone() as AwsCredentialProvider),
1747 None, Some("us-west-2".to_string()),
1749 Some(accessor),
1750 None,
1751 )
1752 .await
1753 .unwrap();
1754
1755 let cred = result.get_credential().await.unwrap();
1757
1758 assert!(explicit_cred_provider.called.load(Ordering::Relaxed));
1760
1761 assert_eq!(
1763 mock_storage_provider.get_call_count().await,
1764 0,
1765 "Storage options provider should not be called when explicit aws_credentials is provided"
1766 );
1767
1768 assert_eq!(cred.key_id, "");
1770 assert_eq!(cred.secret_key, "");
1771 }
1772
1773 #[tokio::test]
1774 async fn test_accessor_used_when_no_explicit_aws_credentials() {
1775 MockClock::set_system_time(Duration::from_secs(100_000));
1776
1777 let now_ms = MockClock::system_time().as_millis() as u64;
1778
1779 let mock_storage_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
1781
1782 let expires_at = now_ms + 600_000; let initial_options = HashMap::from([
1785 (
1786 "aws_access_key_id".to_string(),
1787 "AKID_FROM_ACCESSOR".to_string(),
1788 ),
1789 (
1790 "aws_secret_access_key".to_string(),
1791 "SECRET_FROM_ACCESSOR".to_string(),
1792 ),
1793 (
1794 "aws_session_token".to_string(),
1795 "TOKEN_FROM_ACCESSOR".to_string(),
1796 ),
1797 ("expires_at_millis".to_string(), expires_at.to_string()),
1798 ("refresh_offset_millis".to_string(), "300000".to_string()), ]);
1800
1801 let accessor = Arc::new(StorageOptionsAccessor::with_initial_and_provider(
1803 initial_options,
1804 mock_storage_provider.clone(),
1805 ));
1806
1807 let (result, _region) = build_aws_credential(
1809 Duration::from_secs(300),
1810 None, None, Some("us-west-2".to_string()),
1813 Some(accessor),
1814 None,
1815 )
1816 .await
1817 .unwrap();
1818
1819 let cred = result.get_credential().await.unwrap();
1821 assert_eq!(cred.key_id, "AKID_FROM_ACCESSOR");
1822 assert_eq!(cred.secret_key, "SECRET_FROM_ACCESSOR");
1823
1824 assert_eq!(mock_storage_provider.get_call_count().await, 0);
1826
1827 MockClock::set_system_time(Duration::from_secs(100_000 + 360));
1829
1830 let cred = result.get_credential().await.unwrap();
1832 assert_eq!(cred.key_id, "AKID_1");
1833 assert_eq!(cred.secret_key, "SECRET_1");
1834
1835 assert_eq!(mock_storage_provider.get_call_count().await, 1);
1837 }
1838
1839 #[tokio::test]
1841 async fn test_provider_scheme_token() {
1842 let opts = HashMap::from([
1843 (AmazonS3ConfigKey::AccessKeyId, "AKID".to_string()),
1844 (AmazonS3ConfigKey::SecretAccessKey, "SECRET".to_string()),
1845 ]);
1846
1847 let (provider, _) = build_aws_credential(
1848 Duration::from_secs(300),
1849 None,
1850 Some(&opts),
1851 Some("us-east-1".to_string()),
1852 None,
1853 Some(AwsProviderScheme::Token),
1854 )
1855 .await
1856 .unwrap();
1857
1858 let cred = provider.get_credential().await.unwrap();
1859 assert_eq!(cred.key_id, "AKID");
1860 assert_eq!(cred.secret_key, "SECRET");
1861 }
1862
1863 #[tokio::test]
1865 async fn test_provider_scheme_token_errors_without_credentials() {
1866 let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1867
1868 let result = build_aws_credential(
1869 Duration::from_secs(300),
1870 None,
1871 Some(&opts),
1872 Some("us-east-1".to_string()),
1873 None,
1874 Some(AwsProviderScheme::Token),
1875 )
1876 .await;
1877 assert!(result.is_err());
1878 assert!(
1879 result
1880 .unwrap_err()
1881 .to_string()
1882 .contains("aws_provider_scheme=token"),
1883 "error should mention aws_provider_scheme=token"
1884 );
1885 }
1886
1887 #[tokio::test]
1890 async fn test_provider_scheme_ecs() {
1891 let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1892
1893 let result = build_aws_credential(
1894 Duration::from_secs(300),
1895 None,
1896 Some(&opts),
1897 Some("us-east-1".to_string()),
1898 None,
1899 Some(AwsProviderScheme::Ecs),
1900 )
1901 .await;
1902 assert!(result.is_ok(), "ECS provider should build without error");
1903 }
1904
1905 #[tokio::test]
1909 async fn test_provider_scheme_irsa() {
1910 let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1911
1912 let (provider, _) = build_aws_credential(
1913 Duration::from_secs(300),
1914 None,
1915 Some(&opts),
1916 Some("us-east-1".to_string()),
1917 None,
1918 Some(AwsProviderScheme::Irsa),
1919 )
1920 .await
1921 .unwrap();
1922
1923 let err = provider.get_credential().await.unwrap_err();
1926 assert!(
1927 !err.to_string().contains("Missing Region"),
1928 "should not fail with Missing Region; region was provided. got: {err}"
1929 );
1930 }
1931
1932 #[test]
1934 fn test_provider_scheme_invalid_value() {
1935 let opts = StorageOptions::new(HashMap::from([(
1936 "aws_provider_scheme".to_string(),
1937 "magic".to_string(),
1938 )]));
1939 let result = opts.aws_provider_scheme();
1940 assert!(result.is_err());
1941 assert!(result.unwrap_err().to_string().contains("magic"));
1942 }
1943
1944 #[tokio::test]
1946 async fn test_no_provider_scheme_uses_default_chain() {
1947 let opts: HashMap<AmazonS3ConfigKey, String> = HashMap::new();
1948
1949 let result = build_aws_credential(
1950 Duration::from_secs(300),
1951 None,
1952 Some(&opts),
1953 Some("us-east-1".to_string()),
1954 None,
1955 None,
1956 )
1957 .await;
1958 assert!(result.is_ok());
1959 }
1960}