Skip to main content

opendal_service_azure_common/
lib.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18#![cfg_attr(docsrs, feature(doc_cfg))]
19#![deny(missing_docs)]
20//! Azure Storage helpers.
21//!
22//! This module provides utilities and shared abstractions for services built
23//! on Azure Storage, such as Azure Blob Storage (`services-azblob`) or
24//! Azure Data Lake Storage (`services-azdls`).
25
26use std::collections::HashMap;
27
28use http::Uri;
29use http::response::Parts;
30use opendal_core::{Error, ErrorKind, Result};
31
32/// Configuration parsed from Azure storage connection string.
33#[derive(Debug, Default, Clone, PartialEq, Eq)]
34pub struct AzureStorageConfig {
35    /// Storage account name.
36    pub account_name: Option<String>,
37    /// Storage account shared key.
38    pub account_key: Option<String>,
39    /// Shared access signature token.
40    pub sas_token: Option<String>,
41    /// Service endpoint.
42    pub endpoint: Option<String>,
43    /// OAuth client id.
44    pub client_id: Option<String>,
45    /// OAuth client secret.
46    pub client_secret: Option<String>,
47    /// OAuth tenant id.
48    pub tenant_id: Option<String>,
49    /// OAuth authority host.
50    pub authority_host: Option<String>,
51}
52
53enum AzureStorageCredential {
54    SharedAccessSignature(String),
55    SharedKey(String, String),
56}
57
58/// Parses an [Azure connection string][1] into a configuration object.
59///
60/// The connection string doesn't have to specify all required parameters
61/// because the user is still allowed to set them later directly on the object.
62///
63/// The function takes an AzureStorageService parameter because it determines
64/// the fields used to parse the endpoint.
65///
66/// [1]: https://learn.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string
67pub fn azure_config_from_connection_string(
68    conn_str: &str,
69    storage: AzureStorageService,
70) -> Result<AzureStorageConfig> {
71    let key_values = parse_connection_string(conn_str)?;
72
73    if storage == AzureStorageService::Blob {
74        // Try to read development storage configuration.
75        if let Some(development_config) = collect_blob_development_config(&key_values, &storage) {
76            return Ok(AzureStorageConfig {
77                account_name: Some(development_config.account_name),
78                account_key: Some(development_config.account_key),
79                endpoint: Some(development_config.endpoint),
80                ..Default::default()
81            });
82        }
83    }
84
85    let mut config = AzureStorageConfig {
86        account_name: key_values.get("AccountName").cloned(),
87        endpoint: collect_endpoint(&key_values, &storage)?,
88        ..Default::default()
89    };
90
91    if let Some(creds) = collect_credentials(&key_values) {
92        set_credentials(&mut config, creds);
93    };
94
95    Ok(config)
96}
97
98/// The service that a connection string refers to. The type influences
99/// interpretation of endpoint-related fields during parsing.
100#[derive(PartialEq)]
101pub enum AzureStorageService {
102    /// Azure Blob Storage.
103    Blob,
104
105    /// Azure File Storage.
106    File,
107
108    /// Azure Data Lake Storage Gen2.
109    /// Backed by Blob Storage but exposed through a different endpoint (`dfs`).
110    Adls,
111}
112
113/// Try to extract the storage account name from an Azure endpoint.
114///
115/// Returns `None` if the endpoint doesn't match known Azure Storage suffixes.
116pub fn azure_account_name_from_endpoint(endpoint: &str) -> Option<String> {
117    /// Known Azure Storage endpoint suffixes.
118    const KNOWN_ENDPOINT_SUFFIXES: &[&str] = &[
119        "core.windows.net",       // Azure public cloud
120        "core.usgovcloudapi.net", // Azure US Government
121        "core.chinacloudapi.cn",  // Azure China
122    ];
123
124    let endpoint: &str = endpoint
125        .strip_prefix("http://")
126        .or_else(|| endpoint.strip_prefix("https://"))
127        .unwrap_or(endpoint);
128
129    let (account_name, service_endpoint) = endpoint.split_once('.')?;
130    let (_storage_service, endpoint_suffix) = service_endpoint.split_once('.')?;
131
132    if KNOWN_ENDPOINT_SUFFIXES.contains(&endpoint_suffix.trim_end_matches('/')) {
133        Some(account_name.to_string())
134    } else {
135        None
136    }
137}
138
139/// Takes a semicolon-delimited Azure Storage connection string and returns
140/// key-value pairs split from it.
141fn parse_connection_string(conn_str: &str) -> Result<HashMap<String, String>> {
142    conn_str
143        .trim()
144        .replace("\n", "")
145        .split(';')
146        .filter(|&field| !field.is_empty())
147        .map(|field| {
148            let (key, value) = field.trim().split_once('=').ok_or(Error::new(
149                ErrorKind::ConfigInvalid,
150                format!("Invalid connection string, expected '=' in field: {field}"),
151            ))?;
152            Ok((key.to_string(), value.to_string()))
153        })
154        .collect()
155}
156
157fn collect_blob_development_config(
158    key_values: &HashMap<String, String>,
159    storage: &AzureStorageService,
160) -> Option<DevelopmentStorageConfig> {
161    debug_assert!(
162        storage == &AzureStorageService::Blob,
163        "Azurite Development Storage only supports Blob Storage"
164    );
165
166    // Azurite defaults.
167    const AZURITE_DEFAULT_STORAGE_ACCOUNT_NAME: &str = "devstoreaccount1";
168    const AZURITE_DEFAULT_STORAGE_ACCOUNT_KEY: &str =
169        "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
170
171    const AZURITE_DEFAULT_BLOB_URI: &str = "http://127.0.0.1:10000";
172
173    if key_values.get("UseDevelopmentStorage") != Some(&"true".to_string()) {
174        return None; // Not using development storage
175    }
176
177    let account_name = key_values
178        .get("AccountName")
179        .cloned()
180        .unwrap_or(AZURITE_DEFAULT_STORAGE_ACCOUNT_NAME.to_string());
181    let account_key = key_values
182        .get("AccountKey")
183        .cloned()
184        .unwrap_or(AZURITE_DEFAULT_STORAGE_ACCOUNT_KEY.to_string());
185    let development_proxy_uri = key_values
186        .get("DevelopmentStorageProxyUri")
187        .cloned()
188        .unwrap_or(AZURITE_DEFAULT_BLOB_URI.to_string());
189
190    Some(DevelopmentStorageConfig {
191        endpoint: format!("{development_proxy_uri}/{account_name}"),
192        account_name,
193        account_key,
194    })
195}
196
197/// Helper struct to hold development storage aka Azurite configuration.
198struct DevelopmentStorageConfig {
199    account_name: String,
200    account_key: String,
201    endpoint: String,
202}
203
204/// Parses an endpoint from the key-value pairs if possible.
205///
206/// Users are still able to later supplement configuration with an endpoint,
207/// so endpoint-related fields aren't enforced.
208fn collect_endpoint(
209    key_values: &HashMap<String, String>,
210    storage: &AzureStorageService,
211) -> Result<Option<String>> {
212    match storage {
213        AzureStorageService::Blob => collect_or_build_endpoint(key_values, "BlobEndpoint", "blob"),
214        AzureStorageService::File => collect_or_build_endpoint(key_values, "FileEndpoint", "file"),
215        AzureStorageService::Adls => {
216            // ADLS doesn't have a dedicated endpoint field and we can only
217            // build it from parts.
218            if let Some(dfs_endpoint) = collect_endpoint_from_parts(key_values, "dfs")? {
219                Ok(Some(dfs_endpoint.clone()))
220            } else {
221                Ok(None)
222            }
223        }
224    }
225}
226
227fn collect_credentials(key_values: &HashMap<String, String>) -> Option<AzureStorageCredential> {
228    if let Some(sas_token) = key_values.get("SharedAccessSignature") {
229        Some(AzureStorageCredential::SharedAccessSignature(
230            sas_token.clone(),
231        ))
232    } else if let (Some(account_name), Some(account_key)) =
233        (key_values.get("AccountName"), key_values.get("AccountKey"))
234    {
235        Some(AzureStorageCredential::SharedKey(
236            account_name.clone(),
237            account_key.clone(),
238        ))
239    } else {
240        // We default to no authentication. This is not an error because e.g.
241        // Azure Active Directory configuration is typically not passed via
242        // connection strings.
243        // Users may also set credentials manually on the configuration.
244        None
245    }
246}
247
248fn set_credentials(config: &mut AzureStorageConfig, creds: AzureStorageCredential) {
249    match creds {
250        AzureStorageCredential::SharedAccessSignature(sas_token) => {
251            config.sas_token = Some(sas_token);
252        }
253        AzureStorageCredential::SharedKey(account_name, account_key) => {
254            config.account_name = Some(account_name);
255            config.account_key = Some(account_key);
256        }
257    }
258}
259
260fn collect_or_build_endpoint(
261    key_values: &HashMap<String, String>,
262    endpoint_key: &str,
263    service_name: &str,
264) -> Result<Option<String>> {
265    if let Some(endpoint) = key_values.get(endpoint_key) {
266        Ok(Some(endpoint.clone()))
267    } else if let Some(built_endpoint) = collect_endpoint_from_parts(key_values, service_name)? {
268        Ok(Some(built_endpoint.clone()))
269    } else {
270        Ok(None)
271    }
272}
273
274fn collect_endpoint_from_parts(
275    key_values: &HashMap<String, String>,
276    storage_endpoint_name: &str,
277) -> Result<Option<String>> {
278    let (account_name, endpoint_suffix) = match (
279        key_values.get("AccountName"),
280        key_values.get("EndpointSuffix"),
281    ) {
282        (Some(name), Some(suffix)) => (name, suffix),
283        _ => return Ok(None), // Can't build an endpoint if one of them is missing
284    };
285
286    let protocol = key_values
287        .get("DefaultEndpointsProtocol")
288        .map(String::as_str)
289        .unwrap_or("https"); // Default to HTTPS if not specified
290    if protocol != "http" && protocol != "https" {
291        return Err(Error::new(
292            ErrorKind::ConfigInvalid,
293            format!("Invalid DefaultEndpointsProtocol: {protocol}"),
294        ));
295    }
296
297    Ok(Some(format!(
298        "{protocol}://{account_name}.{storage_endpoint_name}.{endpoint_suffix}"
299    )))
300}
301
302/// Add response context to error.
303///
304/// This helper function will:
305///
306/// - remove sensitive or useless headers from parts.
307/// - fetch uri if parts extensions contains `Uri`.
308/// - censor sensitive SAS URI query parameters
309pub fn with_azure_error_response_context(mut err: Error, mut parts: Parts) -> Error {
310    if let Some(uri) = parts.extensions.get::<Uri>() {
311        err = err.with_context("uri", censor_sas_uri(uri));
312    }
313
314    // The following headers may contains sensitive information.
315    parts.headers.remove("Set-Cookie");
316    parts.headers.remove("WWW-Authenticate");
317    parts.headers.remove("Proxy-Authenticate");
318
319    err = err.with_context("response", format!("{parts:?}"));
320
321    err
322}
323
324fn censor_sas_uri(uri: &Uri) -> String {
325    if let Some(query) = uri.query() {
326        // There is a large set of query parameters specified for SAS URIs.
327        // Some of them may be useful to an attacker, but the most important part is the signature.
328        // Without a signature, an attacker will not be able to replay the request.
329        // For now, just remove the signature.
330        //
331        // https://learn.microsoft.com/en-us/rest/api/storageservices/create-account-sas
332        // https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas
333        // https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas
334        //
335        let path = uri.path();
336        let new_query: String = query
337            .split("&")
338            .filter(|p| !p.starts_with("sig="))
339            .collect::<Vec<_>>()
340            .join("&");
341        let mut parts = uri.clone().into_parts();
342        parts.path_and_query = Some(format!("{path}?{new_query}").try_into().unwrap());
343        Uri::from_parts(parts).unwrap().to_string()
344    } else {
345        uri.to_string()
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use http::Uri;
352
353    use super::censor_sas_uri;
354
355    use super::{
356        AzureStorageConfig, AzureStorageService, azure_account_name_from_endpoint,
357        azure_config_from_connection_string,
358    };
359
360    #[test]
361    fn test_azure_config_from_connection_string() {
362        #[allow(unused_mut)]
363        let mut test_cases = vec![
364            ("minimal fields",
365                (AzureStorageService::Blob, "BlobEndpoint=https://testaccount.blob.core.windows.net/"),
366                Some(AzureStorageConfig{
367                    endpoint: Some("https://testaccount.blob.core.windows.net/".to_string()),
368                    ..Default::default()
369                }),
370            ),
371            ("basic creds and blob endpoint",
372                (AzureStorageService::Blob, "AccountName=testaccount;AccountKey=testkey;BlobEndpoint=https://testaccount.blob.core.windows.net/"),
373                Some(AzureStorageConfig{
374                    account_name: Some("testaccount".to_string()),
375                    account_key: Some("testkey".to_string()),
376                    endpoint: Some("https://testaccount.blob.core.windows.net/".to_string()),
377                     ..Default::default()
378                    }),
379            ),
380            ("SAS token",
381                (AzureStorageService::Blob, "SharedAccessSignature=blablabla"),
382                Some(AzureStorageConfig{
383                    sas_token: Some("blablabla".to_string()),
384                    ..Default::default()
385                }),
386            ),
387            ("endpoint from parts",
388                (AzureStorageService::Blob, "AccountName=testaccount;EndpointSuffix=core.windows.net;DefaultEndpointsProtocol=https"),
389                Some(AzureStorageConfig{
390                    endpoint: Some("https://testaccount.blob.core.windows.net".to_string()),
391                    account_name: Some("testaccount".to_string()),
392                    ..Default::default()
393                }),
394            ),
395            ("endpoint from parts and no protocol",
396                (AzureStorageService::Blob, "AccountName=testaccount;EndpointSuffix=core.windows.net"),
397                Some(AzureStorageConfig{
398                    // Defaults to https
399                    endpoint: Some("https://testaccount.blob.core.windows.net".to_string()),
400                    account_name: Some("testaccount".to_string()),
401                    ..Default::default()
402                }),
403            ),
404            ("prefers sas over key",
405                (AzureStorageService::Blob, "AccountName=testaccount;AccountKey=testkey;SharedAccessSignature=sas_token"),
406                Some(AzureStorageConfig{
407                    sas_token: Some("sas_token".to_string()),
408                    account_name: Some("testaccount".to_string()),
409                    ..Default::default()
410                }),
411            ),
412            ("development storage",
413                (AzureStorageService::Blob, "UseDevelopmentStorage=true",),
414                Some(AzureStorageConfig{
415                    account_name: Some("devstoreaccount1".to_string()),
416                    account_key: Some("Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==".to_string()),
417                    endpoint: Some("http://127.0.0.1:10000/devstoreaccount1".to_string()),
418                    ..Default::default()
419                }),
420            ),
421            ("development storage with custom account values",
422                (AzureStorageService::Blob, "UseDevelopmentStorage=true;AccountName=myAccount;AccountKey=myKey"),
423                Some(AzureStorageConfig {
424                    endpoint: Some("http://127.0.0.1:10000/myAccount".to_string()),
425                    account_name: Some("myAccount".to_string()),
426                    account_key: Some("myKey".to_string()),
427                    ..Default::default()
428                }),
429            ),
430            ("development storage with custom uri",
431                (AzureStorageService::Blob, "UseDevelopmentStorage=true;DevelopmentStorageProxyUri=http://127.0.0.1:12345"),
432                Some(AzureStorageConfig {
433                    endpoint: Some("http://127.0.0.1:12345/devstoreaccount1".to_string()),
434                    account_name: Some("devstoreaccount1".to_string()),
435                    account_key: Some("Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==".to_string()),
436                    ..Default::default()
437                }),
438            ),
439            ("unknown key is ignored",
440                (AzureStorageService::Blob, "SomeUnknownKey=123;BlobEndpoint=https://testaccount.blob.core.windows.net/"),
441                Some(AzureStorageConfig{
442                    endpoint: Some("https://testaccount.blob.core.windows.net/".to_string()),
443                    ..Default::default()
444                }),
445            ),
446            ("leading and trailing `;`",
447                (AzureStorageService::Blob, ";AccountName=testaccount;"),
448                Some(AzureStorageConfig {
449                    account_name: Some("testaccount".to_string()),
450                    ..Default::default()
451                }),
452            ),
453            ("line breaks",
454                (AzureStorageService::Blob, r#"
455                    AccountName=testaccount;
456                    AccountKey=testkey;
457                    EndpointSuffix=core.windows.net;
458                    DefaultEndpointsProtocol=https"#),
459                Some(AzureStorageConfig {
460                    account_name: Some("testaccount".to_string()),
461                    account_key: Some("testkey".to_string()),
462                    endpoint: Some("https://testaccount.blob.core.windows.net".to_string()),
463                    ..Default::default()
464                }),
465            ),
466            ("missing equals",
467                (AzureStorageService::Blob, "AccountNameexample;AccountKey=example;EndpointSuffix=core.windows.net;DefaultEndpointsProtocol=https",),
468                None, // This should fail due to missing '='
469            ),
470            ("with invalid protocol",
471                (AzureStorageService::Blob, "DefaultEndpointsProtocol=ftp;AccountName=example;EndpointSuffix=core.windows.net",),
472                None, // This should fail due to invalid protocol
473            ),
474        ];
475
476        test_cases.push(
477            ("adls endpoint from parts",
478                (AzureStorageService::Adls, "AccountName=testaccount;EndpointSuffix=core.windows.net;DefaultEndpointsProtocol=https"),
479                Some(AzureStorageConfig{
480                    account_name: Some("testaccount".to_string()),
481                    endpoint: Some("https://testaccount.dfs.core.windows.net".to_string()),
482                    ..Default::default()
483                }),
484            )
485        );
486
487        test_cases.extend(vec![
488            (
489                "file endpoint from field",
490                (
491                    AzureStorageService::File,
492                    "FileEndpoint=https://testaccount.file.core.windows.net",
493                ),
494                Some(AzureStorageConfig {
495                    endpoint: Some("https://testaccount.file.core.windows.net".to_string()),
496                    ..Default::default()
497                }),
498            ),
499            (
500                "file endpoint from parts",
501                (
502                    AzureStorageService::File,
503                    "AccountName=testaccount;EndpointSuffix=core.windows.net",
504                ),
505                Some(AzureStorageConfig {
506                    account_name: Some("testaccount".to_string()),
507                    endpoint: Some("https://testaccount.file.core.windows.net".to_string()),
508                    ..Default::default()
509                }),
510            ),
511        ]);
512
513        test_cases.push((
514            "azdls development storage",
515            (AzureStorageService::Adls, "UseDevelopmentStorage=true"),
516            Some(AzureStorageConfig::default()), // Azurite doesn't support ADLSv2, so we ignore this case
517        ));
518
519        for (name, (storage, conn_str), expected) in test_cases {
520            let actual = azure_config_from_connection_string(conn_str, storage);
521
522            if let Some(expected) = expected {
523                assert_azure_storage_config_eq(&actual.expect(name), &expected, name);
524            } else {
525                assert!(actual.is_err(), "Expected error for case: {name}");
526            }
527        }
528    }
529
530    #[test]
531    fn test_azure_account_name_from_endpoint() {
532        let test_cases = vec![
533            ("https://account.blob.core.windows.net", Some("account")),
534            (
535                "https://account.blob.core.usgovcloudapi.net",
536                Some("account"),
537            ),
538            (
539                "https://account.blob.core.chinacloudapi.cn",
540                Some("account"),
541            ),
542            ("https://account.dfs.core.windows.net", Some("account")),
543            ("https://account.blob.core.windows.net/", Some("account")),
544            ("https://account.blob.unknown.suffix.com", None),
545            ("http://blob.core.windows.net", None),
546        ];
547        for (endpoint, expected_account_name) in test_cases {
548            let account_name = azure_account_name_from_endpoint(endpoint);
549            assert_eq!(
550                account_name,
551                expected_account_name.map(|s| s.to_string()),
552                "Endpoint: {endpoint}"
553            );
554        }
555    }
556
557    #[test]
558    fn test_azure_uri_context_removes_sig() {
559        let uri: Uri = "https://foo.bar/path?foo=foo&sig=SENSITIVE&bar=bar"
560            .parse()
561            .unwrap();
562        let expected = "https://foo.bar/path?foo=foo&bar=bar";
563        assert_eq!(censor_sas_uri(&uri), expected);
564    }
565
566    /// Helper function to compare AzureStorageConfig fields manually.
567    fn assert_azure_storage_config_eq(
568        actual: &AzureStorageConfig,
569        expected: &AzureStorageConfig,
570        name: &str,
571    ) {
572        assert_eq!(
573            actual.account_name, expected.account_name,
574            "account_name mismatch: {name}"
575        );
576        assert_eq!(
577            actual.account_key, expected.account_key,
578            "account_key mismatch: {name}"
579        );
580        assert_eq!(
581            actual.endpoint, expected.endpoint,
582            "endpoint mismatch: {name}"
583        );
584        assert_eq!(
585            actual.sas_token, expected.sas_token,
586            "sas_token mismatch: {name}"
587        );
588    }
589}