1#![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#[derive(Debug, Default, Clone, PartialEq, Eq)]
31pub struct AzureStorageConfig {
32 pub account_name: Option<String>,
34 pub account_key: Option<String>,
36 pub sas_token: Option<String>,
38 pub endpoint: Option<String>,
40 pub client_id: Option<String>,
42 pub client_secret: Option<String>,
44 pub tenant_id: Option<String>,
46 pub authority_host: Option<String>,
48}
49
50enum AzureStorageCredential {
51 SharedAccessSignature(String),
52 SharedKey(String, String),
53}
54
55pub 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 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#[derive(PartialEq)]
98pub enum AzureStorageService {
99 Blob,
101
102 File,
104
105 Adls,
108}
109
110pub fn azure_account_name_from_endpoint(endpoint: &str) -> Option<String> {
114 const KNOWN_ENDPOINT_SUFFIXES: &[&str] = &[
116 "core.windows.net", "core.usgovcloudapi.net", "core.chinacloudapi.cn", ];
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
136fn 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 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; }
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
194struct DevelopmentStorageConfig {
196 account_name: String,
197 account_key: String,
198 endpoint: String,
199}
200
201fn 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 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 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), };
282
283 let protocol = key_values
284 .get("DefaultEndpointsProtocol")
285 .map(String::as_str)
286 .unwrap_or("https"); 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
299pub 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 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 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 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, ),
467 ("with invalid protocol",
468 (AzureStorageService::Blob, "DefaultEndpointsProtocol=ftp;AccountName=example;EndpointSuffix=core.windows.net",),
469 None, ),
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()), ));
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 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}