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