1#![cfg_attr(docsrs, feature(doc_cfg))]
19#![deny(missing_docs)]
20use std::collections::HashMap;
27
28use http::Uri;
29use http::response::Parts;
30use opendal_core::{Error, ErrorKind, Result};
31
32#[derive(Debug, Default, Clone, PartialEq, Eq)]
34pub struct AzureStorageConfig {
35 pub account_name: Option<String>,
37 pub account_key: Option<String>,
39 pub sas_token: Option<String>,
41 pub endpoint: Option<String>,
43 pub client_id: Option<String>,
45 pub client_secret: Option<String>,
47 pub tenant_id: Option<String>,
49 pub authority_host: Option<String>,
51}
52
53enum AzureStorageCredential {
54 SharedAccessSignature(String),
55 SharedKey(String, String),
56}
57
58pub 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 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#[derive(PartialEq)]
101pub enum AzureStorageService {
102 Blob,
104
105 File,
107
108 Adls,
111}
112
113pub fn azure_account_name_from_endpoint(endpoint: &str) -> Option<String> {
117 const KNOWN_ENDPOINT_SUFFIXES: &[&str] = &[
119 "core.windows.net", "core.usgovcloudapi.net", "core.chinacloudapi.cn", ];
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
139fn 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 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; }
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
197struct DevelopmentStorageConfig {
199 account_name: String,
200 account_key: String,
201 endpoint: String,
202}
203
204fn 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 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 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), };
285
286 let protocol = key_values
287 .get("DefaultEndpointsProtocol")
288 .map(String::as_str)
289 .unwrap_or("https"); 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
302pub 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 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 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 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, ),
470 ("with invalid protocol",
471 (AzureStorageService::Blob, "DefaultEndpointsProtocol=ftp;AccountName=example;EndpointSuffix=core.windows.net",),
472 None, ),
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()), ));
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 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}