Skip to main content

lance_io/object_store/providers/
azure.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use 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    /// Normalize Azure storage options for OpenDAL, resolving aliases for
36    /// well-known keys while passing through all other options (e.g.
37    /// `client_id`, `tenant_id`, `encryption_key`, etc.) so that OpenDAL
38    /// can use them directly.
39    fn normalize_opendal_azure_options(
40        options: &HashMap<String, String>,
41    ) -> HashMap<String, String> {
42        // Start with all options so unknown keys are forwarded to OpenDAL.
43        let mut config_map = options.clone();
44
45        // Normalize well-known aliases into canonical OpenDAL key names.
46        // Remove the alias after resolving to avoid duplicate/conflicting entries.
47        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                // Canonical key exists; remove aliases to avoid conflicts.
80                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        // Start with all storage options as the config map
94        // OpenDAL will handle environment variables through its default credentials chain
95        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        // Concrete rather than `dyn`, so the caller keeps the handle a paginated listing
169        // needs: `PaginatedListStore` is a separate trait from `ObjectStore`.
170    ) -> Result<Arc<MicrosoftAzure>> {
171        // Use a low retry count since the AIMD throttle layer handles
172        // throttle recovery with its own retry loop.
173        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                // The URI has an:
212                // - az:// schema type and is similar to 'az://container@account.dfs.core.windows.net/path-part/file
213                //         or possibly 'az://container@account/path-part/file' (the short version).
214                // - abfss:// schema type and is similar to 'abfss://filesystem@account.dfs.core.windows.net/path-part/file'.
215                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                // The URI looks like 'az://container/path-part/file'.
224                // We must look at the storage options to find the account.
225                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        // Keyed like the registry cache so per-dataset stores share the bucket's budget.
266        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            // OpenDAL Azure intentionally uses static/environment-backed configuration only.
272            // Namespace-vended dynamic credentials are supported on the native object_store path.
273            // Listed in full: no paginated lister covers OpenDAL yet.
274            (
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    /// Iterate over all environment variables, looking for anything related to Azure.
326    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    /// Add values from the environment without overriding explicit options,
339    /// including aliases, case variants, and empty-string values.
340    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    /// Return options recognized by [`AzureConfigKey`], ignoring unknown keys.
349    ///
350    /// When aliases conflict, the exact canonical name (such as
351    /// `azure_storage_account_name`) wins, followed by case variants of that name.
352    /// Remaining ties use the lexicographically smallest original key. Empty
353    /// values are preserved.
354    ///
355    /// ```
356    /// use std::collections::HashMap;
357    /// use lance_io::object_store::StorageOptions;
358    /// use object_store::azure::AzureConfigKey;
359    ///
360    /// let options = StorageOptions(HashMap::from([
361    ///     ("account_name".into(), "alias".into()),
362    ///     ("azure_storage_account_name".into(), "canonical".into()),
363    /// ]));
364    /// assert_eq!(options.as_azure_options()[&AzureConfigKey::AccountName], "canonical");
365    /// ```
366    pub fn as_azure_options(&self) -> HashMap<AzureConfigKey, String> {
367        self.as_cloud_options()
368    }
369
370    /// The account name as the rest of the stack reads it.
371    ///
372    /// Resolving it through [`Self::as_azure_options`] is what keeps the
373    /// prefix naming the account the store connects to: that resolver takes
374    /// keys in any case and settles conflicting spellings in the order it
375    /// documents, and a prefix derived any other way can disagree with it.
376    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        // Exercise fresh hash seeds as well as both insertion orders.
405        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            // The store prefix has to name the account the store connects to.
420            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(), &params_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    /// Mixed-case keys name an account for every other parser, so the prefix
521    /// has to resolve one from them as well.
522    #[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    /// A prefix for a URI without an account in it, from an upper-case key.
583    #[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    // --- abfss:// tests ---
612
613    #[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, &params).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, &params).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, &params)
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, &params).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        // Build az:// operator (uses Azblob backend)
761        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        // Build abfss:// operator (uses Azdls backend)
766        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        // Both support basic operations
774        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        // Azdls supports rename and create_dir (HNS features); Azblob does not
782        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}