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::{AimdThrottleConfig, AimdThrottleState, cloud_http_connector, 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(throttle_state, store_prefix));
196
197        Ok(Arc::new(builder.build()?))
198    }
199
200    fn calculate_object_store_prefix_with_env(
201        url: &Url,
202        storage_options: Option<&HashMap<String, String>>,
203        env_options: &HashMap<String, String>,
204    ) -> Result<String> {
205        let authority = url.authority();
206        let (container, account) = match authority.find("@") {
207            Some(at_index) => {
208                // The URI has an:
209                // - az:// schema type and is similar to 'az://container@account.dfs.core.windows.net/path-part/file
210                //         or possibly 'az://container@account/path-part/file' (the short version).
211                // - abfss:// schema type and is similar to 'abfss://filesystem@account.dfs.core.windows.net/path-part/file'.
212                let container = &authority[..at_index];
213                let account = &authority[at_index + 1..];
214                (
215                    container,
216                    account.split(".").next().unwrap_or_default().to_string(),
217                )
218            }
219            None => {
220                // The URI looks like 'az://container/path-part/file'.
221                // We must look at the storage options to find the account.
222                let mut account = match storage_options {
223                    Some(opts) => StorageOptions::find_configured_storage_account(opts),
224                    None => None,
225                };
226                if account.is_none() {
227                    account = StorageOptions::find_configured_storage_account(env_options);
228                }
229                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."))?;
230                (authority, account)
231            }
232        };
233        Ok(format!("{}${}@{}", url.scheme(), container, account))
234    }
235}
236
237#[async_trait::async_trait]
238impl ObjectStoreProvider for AzureBlobStoreProvider {
239    async fn new_store(&self, base_path: Url, params: &ObjectStoreParams) -> Result<ObjectStore> {
240        let scheme = base_path.scheme().to_string();
241        if scheme != "az" && scheme != "abfss" {
242            return Err(Error::invalid_input(format!(
243                "Unsupported Azure scheme '{}', expected 'az' or 'abfss'",
244                scheme
245            )));
246        }
247
248        let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
249        let mut storage_options =
250            StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
251        storage_options.with_env_azure();
252        let download_retry_count = storage_options.download_retry_count();
253
254        let use_opendal = storage_options
255            .0
256            .get("use_opendal")
257            .map(|v| str_is_truthy(v.as_str()))
258            .unwrap_or(false);
259
260        let accessor = params.get_accessor();
261
262        let throttle_config = AimdThrottleConfig::from_storage_options(params.storage_options())?;
263        let throttle_state = if throttle_config.is_disabled() {
264            None
265        } else {
266            Some(AimdThrottleState::new(throttle_config)?)
267        };
268
269        let (inner, paginated_lister) = if use_opendal {
270            // OpenDAL Azure intentionally uses static/environment-backed configuration only.
271            // Namespace-vended dynamic credentials are supported on the native object_store path.
272            // Listed in full: no paginated lister covers OpenDAL yet.
273            (
274                self.build_opendal_azure_store(&base_path, &storage_options)
275                    .await?,
276                None,
277            )
278        } else {
279            let store = self
280                .build_microsoft_azure_store(
281                    &base_path,
282                    &storage_options,
283                    accessor,
284                    throttle_state.as_ref(),
285                )
286                .await?;
287            (
288                store.clone() as Arc<dyn OSObjectStore>,
289                Some(store as Arc<dyn PaginatedListStore>),
290            )
291        };
292        let (inner, paginated_lister) =
293            with_throttling(throttle_state, !use_opendal, inner, paginated_lister);
294
295        Ok(ObjectStore {
296            inner,
297            local_dir_operations: None,
298            scheme,
299            block_size,
300            max_iop_size: *DEFAULT_MAX_IOP_SIZE,
301            use_constant_size_upload_parts: false,
302            list_is_lexically_ordered: true,
303            io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
304            download_retry_count,
305            io_tracker: Default::default(),
306            store_prefix: self
307                .calculate_object_store_prefix(&base_path, params.storage_options())?,
308            paginated_lister,
309        })
310    }
311
312    fn calculate_object_store_prefix(
313        &self,
314        url: &Url,
315        storage_options: Option<&HashMap<String, String>>,
316    ) -> Result<String> {
317        Self::calculate_object_store_prefix_with_env(url, storage_options, &ENV_OPTIONS.0)
318    }
319}
320
321static ENV_OPTIONS: LazyLock<StorageOptions> = LazyLock::new(StorageOptions::from_env);
322
323impl StorageOptions {
324    /// Iterate over all environment variables, looking for anything related to Azure.
325    fn from_env() -> Self {
326        let mut opts = HashMap::<String, String>::new();
327        for (os_key, os_value) in std::env::vars_os() {
328            if let (Some(key), Some(value)) = (os_key.to_str(), os_value.to_str())
329                && let Ok(config_key) = AzureConfigKey::from_str(&key.to_ascii_lowercase())
330            {
331                opts.insert(config_key.as_ref().to_string(), value.to_string());
332            }
333        }
334        Self(opts)
335    }
336
337    /// Add values from the environment to storage options
338    pub fn with_env_azure(&mut self) {
339        for (os_key, os_value) in &ENV_OPTIONS.0 {
340            if !self.0.contains_key(os_key) {
341                self.0.insert(os_key.clone(), os_value.clone());
342            }
343        }
344    }
345
346    /// Subset of options relevant for azure storage
347    pub fn as_azure_options(&self) -> HashMap<AzureConfigKey, String> {
348        self.0
349            .iter()
350            .filter_map(|(key, value)| {
351                let az_key = AzureConfigKey::from_str(&key.to_ascii_lowercase()).ok()?;
352                Some((az_key, value.clone()))
353            })
354            .collect()
355    }
356
357    #[allow(clippy::manual_map)]
358    fn find_configured_storage_account(map: &HashMap<String, String>) -> Option<String> {
359        if let Some(account) = map.get("azure_storage_account_name") {
360            Some(account.clone())
361        } else if let Some(account) = map.get("account_name") {
362            Some(account.clone())
363        } else {
364            None
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use std::sync::Arc;
373
374    use crate::object_store::test_utils::StaticMockStorageOptionsProvider;
375    use crate::object_store::{ObjectStoreParams, StorageOptionsAccessor};
376    use std::collections::HashMap;
377
378    #[test]
379    fn test_azure_store_path() {
380        let provider = AzureBlobStoreProvider;
381
382        let url = Url::parse("az://bucket/path/to/file").unwrap();
383        let path = provider.extract_path(&url).unwrap();
384        let expected_path = object_store::path::Path::from("path/to/file");
385        assert_eq!(path, expected_path);
386    }
387
388    #[tokio::test]
389    async fn test_use_opendal_flag() {
390        let provider = AzureBlobStoreProvider;
391        let url = Url::parse("az://test-container/path").unwrap();
392        let params_with_flag = ObjectStoreParams {
393            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
394                HashMap::from([
395                    ("use_opendal".to_string(), "true".to_string()),
396                    ("account_name".to_string(), "test_account".to_string()),
397                    (
398                        "endpoint".to_string(),
399                        "https://test_account.blob.core.windows.net".to_string(),
400                    ),
401                    (
402                        "account_key".to_string(),
403                        "dGVzdF9hY2NvdW50X2tleQ==".to_string(),
404                    ),
405                ]),
406            ))),
407            ..Default::default()
408        };
409
410        let store = provider
411            .new_store(url.clone(), &params_with_flag)
412            .await
413            .unwrap();
414        assert_eq!(store.scheme, "az");
415        let inner_desc = store.inner.to_string();
416        assert!(
417            inner_desc.contains("Opendal") && inner_desc.contains("azblob"),
418            "az:// with use_opendal=true should use OpenDAL Azblob, got: {}",
419            inner_desc
420        );
421    }
422
423    #[tokio::test]
424    async fn test_dynamic_azure_credentials_provider() {
425        let accessor = Arc::new(StorageOptionsAccessor::with_provider(Arc::new(
426            StaticMockStorageOptionsProvider {
427                options: HashMap::from([(
428                    "azure_storage_sas_token".to_string(),
429                    "?sv=2022-11-02&sp=rl&sig=test".to_string(),
430                )]),
431            },
432        )));
433
434        let credentials = build_dynamic_credential_provider::<AzureCredential>(Some(accessor))
435            .await
436            .expect("dynamic azure credentials should build")
437            .expect("expected credential provider")
438            .get_credential()
439            .await
440            .expect("expected azure credential");
441
442        match credentials.as_ref() {
443            AzureCredential::SASToken(pairs) => {
444                assert!(
445                    pairs
446                        .iter()
447                        .any(|(key, value)| key == "sig" && value == "test")
448                );
449            }
450            other => panic!("expected SAS token, got {other:?}"),
451        }
452    }
453
454    #[test]
455    fn test_find_configured_storage_account() {
456        assert_eq!(
457            Some("myaccount".to_string()),
458            StorageOptions::find_configured_storage_account(&HashMap::from_iter(
459                [
460                    ("access_key".to_string(), "myaccesskey".to_string()),
461                    (
462                        "azure_storage_account_name".to_string(),
463                        "myaccount".to_string()
464                    )
465                ]
466                .into_iter()
467            ))
468        );
469    }
470
471    #[test]
472    fn test_calculate_object_store_prefix_from_url_and_options() {
473        let provider = AzureBlobStoreProvider;
474        let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]);
475        assert_eq!(
476            "az$container@bob",
477            provider
478                .calculate_object_store_prefix(
479                    &Url::parse("az://container/path").unwrap(),
480                    Some(&options)
481                )
482                .unwrap()
483        );
484    }
485
486    #[test]
487    fn test_calculate_object_store_prefix_from_url_and_ignored_options() {
488        let provider = AzureBlobStoreProvider;
489        let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]);
490        assert_eq!(
491            "az$container@account",
492            provider
493                .calculate_object_store_prefix(
494                    &Url::parse("az://container@account.dfs.core.windows.net/path").unwrap(),
495                    Some(&options)
496                )
497                .unwrap()
498        );
499    }
500
501    #[test]
502    fn test_calculate_object_store_prefix_from_url_short_account() {
503        let provider = AzureBlobStoreProvider;
504        let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]);
505        assert_eq!(
506            "az$container@account",
507            provider
508                .calculate_object_store_prefix(
509                    &Url::parse("az://container@account/path").unwrap(),
510                    Some(&options)
511                )
512                .unwrap()
513        );
514    }
515
516    #[test]
517    fn test_fail_to_calculate_object_store_prefix_from_url() {
518        let options = HashMap::from_iter([("access_key".to_string(), "myaccesskey".to_string())]);
519        let expected = "Invalid user input: Unable to find object store prefix: no Azure account name in URI, and no storage account configured.";
520        let result = AzureBlobStoreProvider::calculate_object_store_prefix_with_env(
521            &Url::parse("az://container/path").unwrap(),
522            Some(&options),
523            &HashMap::new(),
524        )
525        .expect_err("expected error")
526        .to_string();
527        assert_eq!(expected, &result[..expected.len()]);
528    }
529
530    // --- abfss:// tests ---
531
532    #[test]
533    fn test_abfss_extract_path() {
534        let provider = AzureBlobStoreProvider;
535        let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path/to/dataset.lance")
536            .unwrap();
537        let path = provider.extract_path(&url).unwrap();
538        assert_eq!(
539            path,
540            object_store::path::Path::from("path/to/dataset.lance")
541        );
542    }
543
544    #[test]
545    fn test_calculate_abfss_prefix() {
546        let provider = AzureBlobStoreProvider;
547        let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path/to/data").unwrap();
548        let prefix = provider.calculate_object_store_prefix(&url, None).unwrap();
549        assert_eq!(prefix, "abfss$myfs@myaccount");
550    }
551
552    #[test]
553    fn test_calculate_abfss_prefix_ignores_storage_options() {
554        let provider = AzureBlobStoreProvider;
555        let options =
556            HashMap::from_iter([("account_name".to_string(), "other_account".to_string())]);
557        let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path").unwrap();
558        let prefix = provider
559            .calculate_object_store_prefix(&url, Some(&options))
560            .unwrap();
561        assert_eq!(prefix, "abfss$myfs@myaccount");
562    }
563
564    #[tokio::test]
565    async fn test_abfss_default_uses_microsoft_builder() {
566        use crate::object_store::StorageOptionsAccessor;
567        let provider = AzureBlobStoreProvider;
568        let url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap();
569        let params = ObjectStoreParams {
570            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
571                HashMap::from([
572                    ("account_name".to_string(), "testaccount".to_string()),
573                    ("account_key".to_string(), "dGVzdA==".to_string()),
574                ]),
575            ))),
576            ..Default::default()
577        };
578
579        let store = provider.new_store(url, &params).await.unwrap();
580        assert_eq!(store.scheme, "abfss");
581        assert!(!store.is_local());
582        assert!(store.is_cloud());
583        let inner_desc = store.inner.to_string();
584        assert!(
585            inner_desc.contains("MicrosoftAzure"),
586            "abfss:// without use_opendal should use MicrosoftAzureBuilder, got: {}",
587            inner_desc
588        );
589        assert!(
590            store.paginated_lister.is_some(),
591            "the native store pages an ADLS Gen2 account by continuation token"
592        );
593    }
594
595    #[tokio::test]
596    async fn test_a_blob_container_is_paged() {
597        use crate::object_store::StorageOptionsAccessor;
598        let provider = AzureBlobStoreProvider;
599        let url = Url::parse("az://container@testaccount.blob.core.windows.net/data").unwrap();
600        let params = ObjectStoreParams {
601            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
602                HashMap::from([
603                    ("account_name".to_string(), "testaccount".to_string()),
604                    ("account_key".to_string(), "dGVzdA==".to_string()),
605                ]),
606            ))),
607            ..Default::default()
608        };
609
610        let store = provider.new_store(url, &params).await.unwrap();
611        assert!(store.paginated_lister.is_some());
612    }
613
614    #[tokio::test]
615    async fn test_unsupported_scheme_rejected() {
616        use crate::object_store::StorageOptionsAccessor;
617        let provider = AzureBlobStoreProvider;
618        let url = Url::parse("wasbs://container@myaccount.blob.core.windows.net/path").unwrap();
619        let params = ObjectStoreParams {
620            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
621                HashMap::from([
622                    ("account_name".to_string(), "myaccount".to_string()),
623                    ("account_key".to_string(), "dGVzdA==".to_string()),
624                ]),
625            ))),
626            ..Default::default()
627        };
628
629        let err = provider
630            .new_store(url, &params)
631            .await
632            .expect_err("expected error for unsupported scheme");
633        assert!(
634            err.to_string().contains("Unsupported Azure scheme"),
635            "unexpected error: {}",
636            err
637        );
638    }
639
640    #[tokio::test]
641    async fn test_abfss_with_opendal_uses_azdls() {
642        use crate::object_store::StorageOptionsAccessor;
643        let provider = AzureBlobStoreProvider;
644        let url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap();
645        let params = ObjectStoreParams {
646            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
647                HashMap::from([
648                    ("use_opendal".to_string(), "true".to_string()),
649                    ("account_name".to_string(), "testaccount".to_string()),
650                    ("account_key".to_string(), "dGVzdA==".to_string()),
651                ]),
652            ))),
653            ..Default::default()
654        };
655
656        let store = provider.new_store(url, &params).await.unwrap();
657        assert_eq!(store.scheme, "abfss");
658        assert!(!store.is_local());
659        assert!(store.is_cloud());
660        let inner_desc = store.inner.to_string();
661        assert!(
662            inner_desc.contains("Opendal") && inner_desc.contains("azdls"),
663            "abfss:// with use_opendal=true should use OpenDAL Azdls, got: {}",
664            inner_desc
665        );
666    }
667
668    #[test]
669    fn test_azdls_capabilities_differ_from_azblob() {
670        let common_opts = StorageOptions(HashMap::from([
671            ("account_name".to_string(), "testaccount".to_string()),
672            ("account_key".to_string(), "dGVzdA==".to_string()),
673            (
674                "endpoint".to_string(),
675                "https://testaccount.blob.core.windows.net".to_string(),
676            ),
677        ]));
678
679        // Build az:// operator (uses Azblob backend)
680        let az_url = Url::parse("az://test-container/path").unwrap();
681        let az_operator =
682            AzureBlobStoreProvider::build_opendal_operator(&az_url, &common_opts).unwrap();
683
684        // Build abfss:// operator (uses Azdls backend)
685        let abfss_url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap();
686        let abfss_operator =
687            AzureBlobStoreProvider::build_opendal_operator(&abfss_url, &common_opts).unwrap();
688
689        let azblob_cap = az_operator.info().capability();
690        let azdls_cap = abfss_operator.info().capability();
691
692        // Both support basic operations
693        assert!(azblob_cap.read);
694        assert!(azdls_cap.read);
695        assert!(azblob_cap.write);
696        assert!(azdls_cap.write);
697        assert!(azblob_cap.list);
698        assert!(azdls_cap.list);
699
700        // Azdls supports rename and create_dir (HNS features); Azblob does not
701        assert!(azdls_cap.rename, "Azdls should support rename");
702        assert!(azdls_cap.create_dir, "Azdls should support create_dir");
703        assert!(!azblob_cap.rename, "Azblob should not support rename");
704    }
705}