1use std::{
5 collections::HashMap,
6 str::FromStr,
7 sync::{Arc, LazyLock},
8 time::Duration,
9};
10
11use object_store::ObjectStore as OSObjectStore;
12use object_store::list::PaginatedListStore;
13use opendal::{Operator, services::Azblob, services::Azdls};
14
15use object_store::{
16 RetryConfig,
17 azure::{AzureConfigKey, AzureCredential, MicrosoftAzure, MicrosoftAzureBuilder},
18};
19use url::Url;
20
21use crate::object_store::opendal_store::OpendalStore;
22use crate::object_store::{
23 DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
24 ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
25 dynamic_credentials::build_dynamic_credential_provider,
26 throttle::{AimdThrottleState, cloud_http_connector, shared_throttle_state, with_throttling},
27};
28use lance_core::error::{Error, Result};
29use lance_core::utils::parse::str_is_truthy;
30
31#[derive(Default, Debug)]
32pub struct AzureBlobStoreProvider;
33
34impl AzureBlobStoreProvider {
35 fn normalize_opendal_azure_options(
40 options: &HashMap<String, String>,
41 ) -> HashMap<String, String> {
42 let mut config_map = options.clone();
44
45 let alias_groups: &[(&str, &[&str])] = &[
48 ("account_name", &["azure_storage_account_name"]),
49 ("endpoint", &["azure_storage_endpoint", "azure_endpoint"]),
50 (
51 "account_key",
52 &[
53 "azure_storage_account_key",
54 "azure_storage_access_key",
55 "azure_storage_master_key",
56 "access_key",
57 "master_key",
58 ],
59 ),
60 (
61 "sas_token",
62 &[
63 "azure_storage_sas_token",
64 "azure_storage_sas_key",
65 "sas_key",
66 ],
67 ),
68 ];
69
70 for (canonical, aliases) in alias_groups {
71 if !config_map.contains_key(*canonical) {
72 for alias in *aliases {
73 if let Some(value) = config_map.remove(*alias) {
74 config_map.insert(canonical.to_string(), value);
75 break;
76 }
77 }
78 } else {
79 for alias in *aliases {
81 config_map.remove(*alias);
82 }
83 }
84 }
85
86 config_map
87 }
88
89 fn build_opendal_operator(
90 base_path: &Url,
91 storage_options: &StorageOptions,
92 ) -> Result<Operator> {
93 let mut config_map = Self::normalize_opendal_azure_options(&storage_options.0);
96
97 match base_path.scheme() {
98 "az" => {
99 let container = base_path
100 .host_str()
101 .ok_or_else(|| Error::invalid_input("Azure URL must contain container name"))?
102 .to_string();
103
104 config_map.insert("container".to_string(), container);
105
106 let prefix = base_path.path().trim_start_matches('/');
107 if !prefix.is_empty() {
108 config_map.insert("root".to_string(), format!("/{}", prefix));
109 }
110
111 Operator::from_iter::<Azblob>(config_map).map_err(|e| {
112 Error::invalid_input(format!("Failed to create Azure Blob operator: {:?}", e))
113 })
114 }
115 "abfss" => {
116 let filesystem = base_path.username();
117 if filesystem.is_empty() {
118 return Err(Error::invalid_input(
119 "abfss:// URL must include account: abfss://<filesystem>@<account>.dfs.core.windows.net/path",
120 ));
121 }
122 let host = base_path.host_str().ok_or_else(|| {
123 Error::invalid_input(
124 "abfss:// URL must include account: abfss://<filesystem>@<account>.dfs.core.windows.net/path"
125 )
126 })?;
127
128 config_map.insert("filesystem".to_string(), filesystem.to_string());
129 config_map.insert("endpoint".to_string(), format!("https://{}", host));
130 config_map
131 .entry("account_name".to_string())
132 .or_insert_with(|| host.split('.').next().unwrap_or(host).to_string());
133
134 let root_path = base_path.path().trim_start_matches('/');
135 if !root_path.is_empty() {
136 config_map.insert("root".to_string(), format!("/{}", root_path));
137 }
138
139 Operator::from_iter::<Azdls>(config_map).map_err(|e| {
140 Error::invalid_input(format!(
141 "Failed to create Azure DFS (ADLS Gen2) operator: {:?}",
142 e
143 ))
144 })
145 }
146 _ => Err(Error::invalid_input(format!(
147 "Unsupported Azure scheme: {}",
148 base_path.scheme()
149 ))),
150 }
151 }
152
153 async fn build_opendal_azure_store(
154 &self,
155 base_path: &Url,
156 storage_options: &StorageOptions,
157 ) -> Result<Arc<dyn OSObjectStore>> {
158 let operator = Self::build_opendal_operator(base_path, storage_options)?;
159 Ok(Arc::new(OpendalStore::new(operator)))
160 }
161
162 async fn build_microsoft_azure_store(
163 &self,
164 base_path: &Url,
165 storage_options: &StorageOptions,
166 accessor: Option<Arc<StorageOptionsAccessor>>,
167 throttle_state: Option<&AimdThrottleState>,
168 ) -> Result<Arc<MicrosoftAzure>> {
171 let retry_config = RetryConfig {
174 backoff: Default::default(),
175 max_retries: storage_options.client_max_retries(),
176 retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()),
177 };
178
179 let mut builder = MicrosoftAzureBuilder::new()
180 .with_url(base_path.as_ref())
181 .with_retry(retry_config)
182 .with_client_options(storage_options.client_options()?);
183 for (key, value) in storage_options.as_azure_options() {
184 builder = builder.with_config(key, value);
185 }
186
187 if let Some(credentials) =
188 build_dynamic_credential_provider::<AzureCredential>(accessor).await?
189 {
190 builder = builder.with_credentials(credentials);
191 }
192
193 let store_prefix =
194 self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
195 builder = builder.with_http_connector(cloud_http_connector(
196 throttle_state,
197 crate::object_store::metrics_base(&store_prefix, base_path),
198 ));
199
200 Ok(Arc::new(builder.build()?))
201 }
202
203 fn calculate_object_store_prefix_with_env(
204 url: &Url,
205 storage_options: Option<&HashMap<String, String>>,
206 env_options: &HashMap<String, String>,
207 ) -> Result<String> {
208 let authority = url.authority();
209 let (container, account) = match authority.find("@") {
210 Some(at_index) => {
211 let container = &authority[..at_index];
216 let account = &authority[at_index + 1..];
217 (
218 container,
219 account.split(".").next().unwrap_or_default().to_string(),
220 )
221 }
222 None => {
223 let mut account = match storage_options {
226 Some(opts) => StorageOptions::find_configured_storage_account(opts),
227 None => None,
228 };
229 if account.is_none() {
230 account = StorageOptions::find_configured_storage_account(env_options);
231 }
232 let account = account.ok_or(Error::invalid_input("Unable to find object store prefix: no Azure account name in URI, and no storage account configured."))?;
233 (authority, account)
234 }
235 };
236 Ok(format!("{}${}@{}", url.scheme(), container, account))
237 }
238}
239
240#[async_trait::async_trait]
241impl ObjectStoreProvider for AzureBlobStoreProvider {
242 async fn new_store(&self, base_path: Url, params: &ObjectStoreParams) -> Result<ObjectStore> {
243 let scheme = base_path.scheme().to_string();
244 if scheme != "az" && scheme != "abfss" {
245 return Err(Error::invalid_input(format!(
246 "Unsupported Azure scheme '{}', expected 'az' or 'abfss'",
247 scheme
248 )));
249 }
250
251 let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
252 let mut storage_options =
253 StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
254 storage_options.with_env_azure();
255 let download_retry_count = storage_options.download_retry_count();
256
257 let use_opendal = storage_options
258 .0
259 .get("use_opendal")
260 .map(|v| str_is_truthy(v.as_str()))
261 .unwrap_or(false);
262
263 let accessor = params.get_accessor();
264
265 let store_prefix =
267 self.calculate_object_store_prefix(&base_path, params.storage_options())?;
268 let throttle_state = shared_throttle_state(&store_prefix, params)?;
269
270 let (inner, paginated_lister) = if use_opendal {
271 (
275 self.build_opendal_azure_store(&base_path, &storage_options)
276 .await?,
277 None,
278 )
279 } else {
280 let store = self
281 .build_microsoft_azure_store(
282 &base_path,
283 &storage_options,
284 accessor,
285 throttle_state.as_ref(),
286 )
287 .await?;
288 (
289 store.clone() as Arc<dyn OSObjectStore>,
290 Some(store as Arc<dyn PaginatedListStore>),
291 )
292 };
293 let (inner, paginated_lister) =
294 with_throttling(throttle_state, !use_opendal, inner, paginated_lister);
295
296 Ok(ObjectStore {
297 inner,
298 local_dir_operations: None,
299 scheme,
300 block_size,
301 max_iop_size: *DEFAULT_MAX_IOP_SIZE,
302 use_constant_size_upload_parts: false,
303 list_is_lexically_ordered: true,
304 io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
305 download_retry_count,
306 io_tracker: Default::default(),
307 store_prefix: self
308 .calculate_object_store_prefix(&base_path, params.storage_options())?,
309 paginated_lister,
310 })
311 }
312
313 fn calculate_object_store_prefix(
314 &self,
315 url: &Url,
316 storage_options: Option<&HashMap<String, String>>,
317 ) -> Result<String> {
318 Self::calculate_object_store_prefix_with_env(url, storage_options, &ENV_OPTIONS.0)
319 }
320}
321
322static ENV_OPTIONS: LazyLock<StorageOptions> = LazyLock::new(StorageOptions::from_env);
323
324impl StorageOptions {
325 fn from_env() -> Self {
327 let mut opts = HashMap::<String, String>::new();
328 for (os_key, os_value) in std::env::vars_os() {
329 if let (Some(key), Some(value)) = (os_key.to_str(), os_value.to_str())
330 && let Ok(config_key) = AzureConfigKey::from_str(&key.to_ascii_lowercase())
331 {
332 opts.insert(config_key.as_ref().to_string(), value.to_string());
333 }
334 }
335 Self(opts)
336 }
337
338 pub fn with_env_azure(&mut self) {
341 self.merge_env_options(&ENV_OPTIONS.0, |key| {
342 AzureConfigKey::from_str(key)
343 .ok()
344 .map(|key| key.as_ref().to_string())
345 });
346 }
347
348 pub fn as_azure_options(&self) -> HashMap<AzureConfigKey, String> {
367 self.as_cloud_options()
368 }
369
370 fn find_configured_storage_account(map: &HashMap<String, String>) -> Option<String> {
377 Self(map.clone())
378 .as_azure_options()
379 .remove(&AzureConfigKey::AccountName)
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::sync::Arc;
387
388 use crate::object_store::test_utils::StaticMockStorageOptionsProvider;
389 use crate::object_store::{ObjectStoreParams, StorageOptionsAccessor};
390 use std::collections::HashMap;
391
392 #[rstest::rstest]
393 #[case::canonical("azure_storage_account_name", "account_name")]
394 #[case::canonical_over_case_variant("azure_storage_account_name", "AZURE_STORAGE_ACCOUNT_NAME")]
395 #[case::canonical_case_tie("AZURE_STORAGE_ACCOUNT_NAME", "Azure_Storage_Account_Name")]
396 #[case::case_variant_over_alias("AZURE_STORAGE_ACCOUNT_NAME", "account_name")]
397 #[case::lexical_alias("ACCOUNT_NAME", "account_name")]
398 fn test_storage_options_alias_precedence(
399 #[case] preferred_key: &str,
400 #[case] conflicting_key: &str,
401 #[values("preferred", "")] preferred_value: &str,
402 #[values(false, true)] is_reversed: bool,
403 ) {
404 for _ in 0..16 {
406 let mut entries = [
407 (preferred_key.to_string(), preferred_value.to_string()),
408 (conflicting_key.to_string(), "conflicting".to_string()),
409 ("lance_custom_option".to_string(), "ignored".to_string()),
410 ];
411 if is_reversed {
412 entries.reverse();
413 }
414 let options = StorageOptions(HashMap::from(entries));
415 assert_eq!(
416 options.as_azure_options(),
417 HashMap::from([(AzureConfigKey::AccountName, preferred_value.to_string())])
418 );
419 assert_eq!(
421 StorageOptions::find_configured_storage_account(&options.0),
422 Some(preferred_value.to_string())
423 );
424 }
425 }
426
427 #[test]
428 fn test_azure_store_path() {
429 let provider = AzureBlobStoreProvider;
430
431 let url = Url::parse("az://bucket/path/to/file").unwrap();
432 let path = provider.extract_path(&url).unwrap();
433 let expected_path = object_store::path::Path::from("path/to/file");
434 assert_eq!(path, expected_path);
435 }
436
437 #[tokio::test]
438 async fn test_use_opendal_flag() {
439 let provider = AzureBlobStoreProvider;
440 let url = Url::parse("az://test-container/path").unwrap();
441 let params_with_flag = ObjectStoreParams {
442 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
443 HashMap::from([
444 ("use_opendal".to_string(), "true".to_string()),
445 ("account_name".to_string(), "test_account".to_string()),
446 (
447 "endpoint".to_string(),
448 "https://test_account.blob.core.windows.net".to_string(),
449 ),
450 (
451 "account_key".to_string(),
452 "dGVzdF9hY2NvdW50X2tleQ==".to_string(),
453 ),
454 ]),
455 ))),
456 ..Default::default()
457 };
458
459 let store = provider
460 .new_store(url.clone(), ¶ms_with_flag)
461 .await
462 .unwrap();
463 assert_eq!(store.scheme, "az");
464 let inner_desc = store.inner.to_string();
465 assert!(
466 inner_desc.contains("Opendal") && inner_desc.contains("azblob"),
467 "az:// with use_opendal=true should use OpenDAL Azblob, got: {}",
468 inner_desc
469 );
470 }
471
472 #[tokio::test]
473 async fn test_dynamic_azure_credentials_provider() {
474 let accessor = Arc::new(StorageOptionsAccessor::with_provider(Arc::new(
475 StaticMockStorageOptionsProvider {
476 options: HashMap::from([(
477 "azure_storage_sas_token".to_string(),
478 "?sv=2022-11-02&sp=rl&sig=test".to_string(),
479 )]),
480 },
481 )));
482
483 let credentials = build_dynamic_credential_provider::<AzureCredential>(Some(accessor))
484 .await
485 .expect("dynamic azure credentials should build")
486 .expect("expected credential provider")
487 .get_credential()
488 .await
489 .expect("expected azure credential");
490
491 match credentials.as_ref() {
492 AzureCredential::SASToken(pairs) => {
493 assert!(
494 pairs
495 .iter()
496 .any(|(key, value)| key == "sig" && value == "test")
497 );
498 }
499 other => panic!("expected SAS token, got {other:?}"),
500 }
501 }
502
503 #[test]
504 fn test_find_configured_storage_account() {
505 assert_eq!(
506 Some("myaccount".to_string()),
507 StorageOptions::find_configured_storage_account(&HashMap::from_iter(
508 [
509 ("access_key".to_string(), "myaccesskey".to_string()),
510 (
511 "azure_storage_account_name".to_string(),
512 "myaccount".to_string()
513 )
514 ]
515 .into_iter()
516 ))
517 );
518 }
519
520 #[rstest::rstest]
523 #[case::canonical_upper(&[("AZURE_STORAGE_ACCOUNT_NAME", "myaccount")])]
524 #[case::alias_upper(&[("ACCOUNT_NAME", "myaccount")])]
525 #[case::alias_mixed(&[("Account_Name", "myaccount")])]
526 fn test_find_configured_storage_account_ignores_key_case(#[case] options: &[(&str, &str)]) {
527 let options: HashMap<String, String> = options
528 .iter()
529 .map(|(key, value)| (key.to_string(), value.to_string()))
530 .collect();
531 assert_eq!(
532 Some("myaccount".to_string()),
533 StorageOptions::find_configured_storage_account(&options)
534 );
535 }
536
537 #[test]
538 fn test_calculate_object_store_prefix_from_url_and_options() {
539 let provider = AzureBlobStoreProvider;
540 let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]);
541 assert_eq!(
542 "az$container@bob",
543 provider
544 .calculate_object_store_prefix(
545 &Url::parse("az://container/path").unwrap(),
546 Some(&options)
547 )
548 .unwrap()
549 );
550 }
551
552 #[test]
553 fn test_calculate_object_store_prefix_from_url_and_ignored_options() {
554 let provider = AzureBlobStoreProvider;
555 let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]);
556 assert_eq!(
557 "az$container@account",
558 provider
559 .calculate_object_store_prefix(
560 &Url::parse("az://container@account.dfs.core.windows.net/path").unwrap(),
561 Some(&options)
562 )
563 .unwrap()
564 );
565 }
566
567 #[test]
568 fn test_calculate_object_store_prefix_from_url_short_account() {
569 let provider = AzureBlobStoreProvider;
570 let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]);
571 assert_eq!(
572 "az$container@account",
573 provider
574 .calculate_object_store_prefix(
575 &Url::parse("az://container@account/path").unwrap(),
576 Some(&options)
577 )
578 .unwrap()
579 );
580 }
581
582 #[test]
584 fn test_calculate_object_store_prefix_from_upper_case_account_key() {
585 let options = HashMap::from_iter([("ACCOUNT_NAME".to_string(), "bob".to_string())]);
586 assert_eq!(
587 "az$container@bob",
588 AzureBlobStoreProvider::calculate_object_store_prefix_with_env(
589 &Url::parse("az://container/path").unwrap(),
590 Some(&options),
591 &HashMap::new(),
592 )
593 .unwrap()
594 );
595 }
596
597 #[test]
598 fn test_fail_to_calculate_object_store_prefix_from_url() {
599 let options = HashMap::from_iter([("access_key".to_string(), "myaccesskey".to_string())]);
600 let expected = "Invalid user input: Unable to find object store prefix: no Azure account name in URI, and no storage account configured.";
601 let result = AzureBlobStoreProvider::calculate_object_store_prefix_with_env(
602 &Url::parse("az://container/path").unwrap(),
603 Some(&options),
604 &HashMap::new(),
605 )
606 .expect_err("expected error")
607 .to_string();
608 assert_eq!(expected, &result[..expected.len()]);
609 }
610
611 #[test]
614 fn test_abfss_extract_path() {
615 let provider = AzureBlobStoreProvider;
616 let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path/to/dataset.lance")
617 .unwrap();
618 let path = provider.extract_path(&url).unwrap();
619 assert_eq!(
620 path,
621 object_store::path::Path::from("path/to/dataset.lance")
622 );
623 }
624
625 #[test]
626 fn test_calculate_abfss_prefix() {
627 let provider = AzureBlobStoreProvider;
628 let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path/to/data").unwrap();
629 let prefix = provider.calculate_object_store_prefix(&url, None).unwrap();
630 assert_eq!(prefix, "abfss$myfs@myaccount");
631 }
632
633 #[test]
634 fn test_calculate_abfss_prefix_ignores_storage_options() {
635 let provider = AzureBlobStoreProvider;
636 let options =
637 HashMap::from_iter([("account_name".to_string(), "other_account".to_string())]);
638 let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path").unwrap();
639 let prefix = provider
640 .calculate_object_store_prefix(&url, Some(&options))
641 .unwrap();
642 assert_eq!(prefix, "abfss$myfs@myaccount");
643 }
644
645 #[tokio::test]
646 async fn test_abfss_default_uses_microsoft_builder() {
647 use crate::object_store::StorageOptionsAccessor;
648 let provider = AzureBlobStoreProvider;
649 let url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap();
650 let params = ObjectStoreParams {
651 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
652 HashMap::from([
653 ("account_name".to_string(), "testaccount".to_string()),
654 ("account_key".to_string(), "dGVzdA==".to_string()),
655 ]),
656 ))),
657 ..Default::default()
658 };
659
660 let store = provider.new_store(url, ¶ms).await.unwrap();
661 assert_eq!(store.scheme, "abfss");
662 assert!(!store.is_local());
663 assert!(store.is_cloud());
664 let inner_desc = store.inner.to_string();
665 assert!(
666 inner_desc.contains("MicrosoftAzure"),
667 "abfss:// without use_opendal should use MicrosoftAzureBuilder, got: {}",
668 inner_desc
669 );
670 assert!(
671 store.paginated_lister.is_some(),
672 "the native store pages an ADLS Gen2 account by continuation token"
673 );
674 }
675
676 #[tokio::test]
677 async fn test_a_blob_container_is_paged() {
678 use crate::object_store::StorageOptionsAccessor;
679 let provider = AzureBlobStoreProvider;
680 let url = Url::parse("az://container@testaccount.blob.core.windows.net/data").unwrap();
681 let params = ObjectStoreParams {
682 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
683 HashMap::from([
684 ("account_name".to_string(), "testaccount".to_string()),
685 ("account_key".to_string(), "dGVzdA==".to_string()),
686 ]),
687 ))),
688 ..Default::default()
689 };
690
691 let store = provider.new_store(url, ¶ms).await.unwrap();
692 assert!(store.paginated_lister.is_some());
693 }
694
695 #[tokio::test]
696 async fn test_unsupported_scheme_rejected() {
697 use crate::object_store::StorageOptionsAccessor;
698 let provider = AzureBlobStoreProvider;
699 let url = Url::parse("wasbs://container@myaccount.blob.core.windows.net/path").unwrap();
700 let params = ObjectStoreParams {
701 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
702 HashMap::from([
703 ("account_name".to_string(), "myaccount".to_string()),
704 ("account_key".to_string(), "dGVzdA==".to_string()),
705 ]),
706 ))),
707 ..Default::default()
708 };
709
710 let err = provider
711 .new_store(url, ¶ms)
712 .await
713 .expect_err("expected error for unsupported scheme");
714 assert!(
715 err.to_string().contains("Unsupported Azure scheme"),
716 "unexpected error: {}",
717 err
718 );
719 }
720
721 #[tokio::test]
722 async fn test_abfss_with_opendal_uses_azdls() {
723 use crate::object_store::StorageOptionsAccessor;
724 let provider = AzureBlobStoreProvider;
725 let url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap();
726 let params = ObjectStoreParams {
727 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
728 HashMap::from([
729 ("use_opendal".to_string(), "true".to_string()),
730 ("account_name".to_string(), "testaccount".to_string()),
731 ("account_key".to_string(), "dGVzdA==".to_string()),
732 ]),
733 ))),
734 ..Default::default()
735 };
736
737 let store = provider.new_store(url, ¶ms).await.unwrap();
738 assert_eq!(store.scheme, "abfss");
739 assert!(!store.is_local());
740 assert!(store.is_cloud());
741 let inner_desc = store.inner.to_string();
742 assert!(
743 inner_desc.contains("Opendal") && inner_desc.contains("azdls"),
744 "abfss:// with use_opendal=true should use OpenDAL Azdls, got: {}",
745 inner_desc
746 );
747 }
748
749 #[test]
750 fn test_azdls_capabilities_differ_from_azblob() {
751 let common_opts = StorageOptions(HashMap::from([
752 ("account_name".to_string(), "testaccount".to_string()),
753 ("account_key".to_string(), "dGVzdA==".to_string()),
754 (
755 "endpoint".to_string(),
756 "https://testaccount.blob.core.windows.net".to_string(),
757 ),
758 ]));
759
760 let az_url = Url::parse("az://test-container/path").unwrap();
762 let az_operator =
763 AzureBlobStoreProvider::build_opendal_operator(&az_url, &common_opts).unwrap();
764
765 let abfss_url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap();
767 let abfss_operator =
768 AzureBlobStoreProvider::build_opendal_operator(&abfss_url, &common_opts).unwrap();
769
770 let azblob_cap = az_operator.info().capability();
771 let azdls_cap = abfss_operator.info().capability();
772
773 assert!(azblob_cap.read);
775 assert!(azdls_cap.read);
776 assert!(azblob_cap.write);
777 assert!(azdls_cap.write);
778 assert!(azblob_cap.list);
779 assert!(azdls_cap.list);
780
781 assert!(azdls_cap.rename, "Azdls should support rename");
783 assert!(azdls_cap.create_dir, "Azdls should support create_dir");
784 assert!(!azblob_cap.rename, "Azblob should not support rename");
785 }
786}