1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3use std::str::FromStr;
13use std::sync::Arc;
14
15use faucet_core::FaucetError;
16use object_store::ObjectStore;
17use object_store::azure::{AzureConfigKey, MicrosoftAzureBuilder};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
28#[serde(tag = "type", content = "config", rename_all = "snake_case")]
29pub enum AzureCredentials {
30 AccountKey {
32 account_key: String,
34 },
35 SasToken {
37 sas_token: String,
39 },
40 ConnectionString {
43 connection_string: String,
45 },
46 ManagedIdentity {
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 client_id: Option<String>,
52 },
53 ServicePrincipal {
55 client_id: String,
57 client_secret: String,
59 tenant_id: String,
61 },
62 #[default]
66 Default,
67}
68
69impl AzureCredentials {
70 pub fn config_entries(&self) -> Vec<(&'static str, String)> {
79 match self {
80 AzureCredentials::AccountKey { account_key } => {
81 vec![("azure_storage_access_key", account_key.clone())]
82 }
83 AzureCredentials::SasToken { sas_token } => {
84 vec![("azure_storage_sas_key", sas_token.clone())]
85 }
86 AzureCredentials::ConnectionString { connection_string } => {
90 parse_connection_string(connection_string)
91 }
92 AzureCredentials::ManagedIdentity { client_id } => match client_id {
96 Some(id) => vec![("azure_storage_client_id", id.clone())],
97 None => Vec::new(),
98 },
99 AzureCredentials::ServicePrincipal {
100 client_id,
101 client_secret,
102 tenant_id,
103 } => vec![
104 ("azure_storage_client_id", client_id.clone()),
105 ("azure_storage_client_secret", client_secret.clone()),
106 ("azure_storage_tenant_id", tenant_id.clone()),
107 ],
108 AzureCredentials::Default => Vec::new(),
109 }
110 }
111}
112
113fn parse_connection_string(cs: &str) -> Vec<(&'static str, String)> {
123 let mut entries: Vec<(&'static str, String)> = Vec::new();
124 for segment in cs.split(';') {
125 let segment = segment.trim();
126 if segment.is_empty() {
127 continue;
128 }
129 let Some((key, value)) = segment.split_once('=') else {
130 continue;
131 };
132 let value = value.trim().to_string();
133 if value.is_empty() {
134 continue;
135 }
136 match key.trim() {
139 "AccountName" => entries.push(("azure_storage_account_name", value)),
140 "AccountKey" => entries.push(("azure_storage_access_key", value)),
141 "SharedAccessSignature" => entries.push(("azure_storage_sas_key", value)),
142 "BlobEndpoint" => entries.push(("azure_storage_endpoint", value)),
143 _ => {}
144 }
145 }
146 entries
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
155pub struct AzureConnection {
156 pub container: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub account: Option<String>,
162 #[serde(default)]
165 pub auth: AzureCredentials,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub endpoint: Option<String>,
169 #[serde(default)]
171 pub allow_http: bool,
172 #[serde(default)]
175 pub use_emulator: bool,
176}
177
178impl AzureConnection {
179 pub fn new(container: impl Into<String>) -> Self {
181 Self {
182 container: container.into(),
183 account: None,
184 auth: AzureCredentials::default(),
185 endpoint: None,
186 allow_http: false,
187 use_emulator: false,
188 }
189 }
190
191 pub fn account(mut self, account: impl Into<String>) -> Self {
193 self.account = Some(account.into());
194 self
195 }
196
197 pub fn auth(mut self, auth: AzureCredentials) -> Self {
199 self.auth = auth;
200 self
201 }
202
203 pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
205 self.endpoint = Some(endpoint.into());
206 self
207 }
208
209 pub fn allow_http(mut self, allow: bool) -> Self {
211 self.allow_http = allow;
212 self
213 }
214
215 pub fn use_emulator(mut self, use_emulator: bool) -> Self {
217 self.use_emulator = use_emulator;
218 self
219 }
220}
221
222pub fn build_store(conn: &AzureConnection) -> Result<Arc<dyn ObjectStore>, FaucetError> {
228 if conn.container.trim().is_empty() {
229 return Err(FaucetError::Config(
230 "azure: container name must not be empty".into(),
231 ));
232 }
233
234 let mut builder = MicrosoftAzureBuilder::from_env().with_container_name(&conn.container);
235
236 if let Some(account) = &conn.account {
237 builder = builder.with_account(account);
238 }
239 if conn.use_emulator {
240 builder = builder.with_use_emulator(true);
241 }
242 if let Some(endpoint) = &conn.endpoint {
243 builder = builder.with_endpoint(endpoint.clone());
244 }
245 if conn.allow_http {
246 builder = builder.with_allow_http(true);
247 }
248
249 for (key, value) in conn.auth.config_entries() {
250 let config_key = AzureConfigKey::from_str(key)
251 .map_err(|e| FaucetError::Config(format!("azure: unknown config key '{key}': {e}")))?;
252 builder = builder.with_config(config_key, value);
253 }
254
255 let store = builder
256 .build()
257 .map_err(|e| FaucetError::Config(format!("azure: failed to build client: {e}")))?;
258 Ok(Arc::new(store))
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use serde_json::json;
265
266 #[test]
267 fn credentials_default_is_default_variant() {
268 assert_eq!(AzureCredentials::default(), AzureCredentials::Default);
269 }
270
271 #[test]
272 fn default_credential_has_no_config_entries() {
273 assert!(AzureCredentials::Default.config_entries().is_empty());
274 }
275
276 #[test]
277 fn account_key_sets_access_key() {
278 let creds = AzureCredentials::AccountKey {
279 account_key: "abc123".into(),
280 };
281 assert_eq!(
282 creds.config_entries(),
283 vec![("azure_storage_access_key", "abc123".to_string())]
284 );
285 }
286
287 #[test]
288 fn sas_token_sets_sas_key() {
289 let creds = AzureCredentials::SasToken {
290 sas_token: "sv=2021".into(),
291 };
292 assert_eq!(
293 creds.config_entries(),
294 vec![("azure_storage_sas_key", "sv=2021".to_string())]
295 );
296 }
297
298 #[test]
299 fn connection_string_parses_into_account_and_key() {
300 let creds = AzureCredentials::ConnectionString {
301 connection_string:
302 "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=a2V5==;EndpointSuffix=core.windows.net"
303 .into(),
304 };
305 let entries = creds.config_entries();
306 assert!(entries.contains(&("azure_storage_account_name", "acct".to_string())));
307 assert!(entries.contains(&("azure_storage_access_key", "a2V5==".to_string())));
309 assert_eq!(entries.len(), 2);
311 }
312
313 #[test]
314 fn connection_string_parses_sas_and_endpoint() {
315 let creds = AzureCredentials::ConnectionString {
316 connection_string:
317 "SharedAccessSignature=sv=2021&sig=abc;BlobEndpoint=http://127.0.0.1:10000/acct"
318 .into(),
319 };
320 let entries = creds.config_entries();
321 assert!(entries.contains(&("azure_storage_sas_key", "sv=2021&sig=abc".to_string())));
322 assert!(entries.contains(&(
323 "azure_storage_endpoint",
324 "http://127.0.0.1:10000/acct".to_string()
325 )));
326 }
327
328 #[test]
329 fn connection_string_ignores_blank_and_unknown_segments() {
330 assert!(parse_connection_string("").is_empty());
331 assert!(parse_connection_string(";;Foo=bar;").is_empty());
332 assert!(parse_connection_string("AccountKey=").is_empty());
333 }
334
335 #[test]
336 fn managed_identity_user_assigned_sets_client_id() {
337 let creds = AzureCredentials::ManagedIdentity {
338 client_id: Some("mi-client".into()),
339 };
340 assert_eq!(
341 creds.config_entries(),
342 vec![("azure_storage_client_id", "mi-client".to_string())]
343 );
344 }
345
346 #[test]
347 fn managed_identity_system_assigned_sets_no_config() {
348 let creds = AzureCredentials::ManagedIdentity { client_id: None };
349 assert!(creds.config_entries().is_empty());
350 }
351
352 #[test]
353 fn service_principal_sets_all_three_fields() {
354 let creds = AzureCredentials::ServicePrincipal {
355 client_id: "cid".into(),
356 client_secret: "secret".into(),
357 tenant_id: "tid".into(),
358 };
359 let entries = creds.config_entries();
360 assert!(entries.contains(&("azure_storage_client_id", "cid".to_string())));
361 assert!(entries.contains(&("azure_storage_client_secret", "secret".to_string())));
362 assert!(entries.contains(&("azure_storage_tenant_id", "tid".to_string())));
363 }
364
365 #[test]
366 fn credentials_serde_account_key_round_trip() {
367 let creds = AzureCredentials::AccountKey {
368 account_key: "k".into(),
369 };
370 let v = serde_json::to_value(&creds).unwrap();
371 assert_eq!(
372 v,
373 json!({"type": "account_key", "config": {"account_key": "k"}})
374 );
375 let back: AzureCredentials = serde_json::from_value(v).unwrap();
376 assert_eq!(back, creds);
377 }
378
379 #[test]
380 fn credentials_serde_default_round_trip() {
381 let v = serde_json::to_value(AzureCredentials::Default).unwrap();
382 assert_eq!(v, json!({"type": "default"}));
383 let back: AzureCredentials = serde_json::from_value(v).unwrap();
384 assert_eq!(back, AzureCredentials::Default);
385 }
386
387 #[test]
388 fn credentials_serde_service_principal_round_trip() {
389 let creds = AzureCredentials::ServicePrincipal {
390 client_id: "cid".into(),
391 client_secret: "sec".into(),
392 tenant_id: "tid".into(),
393 };
394 let v = serde_json::to_value(&creds).unwrap();
395 assert_eq!(v["type"], "service_principal");
396 let back: AzureCredentials = serde_json::from_value(v).unwrap();
397 assert_eq!(back, creds);
398 }
399
400 #[test]
401 fn connection_builder_sets_fields() {
402 let conn = AzureConnection::new("data")
403 .account("acct")
404 .auth(AzureCredentials::AccountKey {
405 account_key: "k".into(),
406 })
407 .endpoint("http://127.0.0.1:10000/devstoreaccount1")
408 .allow_http(true)
409 .use_emulator(true);
410 assert_eq!(conn.container, "data");
411 assert_eq!(conn.account.as_deref(), Some("acct"));
412 assert!(conn.allow_http);
413 assert!(conn.use_emulator);
414 assert_eq!(
415 conn.endpoint.as_deref(),
416 Some("http://127.0.0.1:10000/devstoreaccount1")
417 );
418 }
419
420 #[test]
421 fn build_store_rejects_empty_container() {
422 let conn = AzureConnection::new(" ");
423 let err = build_store(&conn).unwrap_err();
424 assert!(matches!(err, FaucetError::Config(_)));
425 }
426
427 #[test]
428 fn build_store_succeeds_lazily_with_account_key() {
429 let conn = AzureConnection::new("data")
433 .account("devstoreaccount1")
434 .auth(AzureCredentials::AccountKey {
435 account_key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==".into(),
436 })
437 .endpoint("http://127.0.0.1:10000/devstoreaccount1")
438 .allow_http(true);
439 assert!(build_store(&conn).is_ok());
440 }
441
442 #[test]
443 fn build_store_succeeds_lazily_with_emulator_and_default_creds() {
444 let conn = AzureConnection::new("data")
445 .use_emulator(true)
446 .allow_http(true);
447 assert!(build_store(&conn).is_ok());
448 }
449
450 #[test]
451 fn build_store_succeeds_lazily_with_service_principal() {
452 let conn =
453 AzureConnection::new("data")
454 .account("acct")
455 .auth(AzureCredentials::ServicePrincipal {
456 client_id: "cid".into(),
457 client_secret: "sec".into(),
458 tenant_id: "tid".into(),
459 });
460 assert!(build_store(&conn).is_ok());
461 }
462}