1use crate::cloud_browse::Environment;
13use crate::cloud_command::CommandError;
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::{Mutex, OnceLock};
17use std::time::{Duration, SystemTime};
18
19pub const STORAGE_SCOPE: &str = "https://storage.azure.com/.default";
21pub const MANAGEMENT_SCOPE: &str = "https://management.azure.com/.default";
23pub const API_VERSION: &str = "2023-11-03";
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub enum AzureAuth {
30 #[default]
32 None,
33 AzCli,
36 PowerShell,
38 ServicePrincipal(ServicePrincipal),
40 ManagedIdentity,
42 KeyCommand(String),
44 Bearer(String),
46 Key(String),
48 Sas(String),
50}
51
52#[derive(Clone, PartialEq, Eq, Default)]
55pub struct ServicePrincipal {
56 pub tenant: String,
57 pub client_id: String,
58 pub secret: Option<String>,
59 pub token_file: Option<PathBuf>,
60 pub authority: String,
62}
63
64impl std::fmt::Debug for ServicePrincipal {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("ServicePrincipal")
67 .field("tenant", &self.tenant)
68 .field("client_id", &self.client_id)
69 .field("secret", &self.secret.as_ref().map(|_| "…"))
70 .field("token_file", &self.token_file)
71 .finish()
72 }
73}
74
75impl AzureAuth {
76 pub fn is_identity(&self) -> bool {
78 matches!(
79 self,
80 AzureAuth::AzCli
81 | AzureAuth::PowerShell
82 | AzureAuth::ServicePrincipal(_)
83 | AzureAuth::ManagedIdentity
84 )
85 }
86
87 pub fn describe(&self) -> &'static str {
89 match self {
90 AzureAuth::None => "none",
91 AzureAuth::AzCli => "az login",
92 AzureAuth::PowerShell => "Azure PowerShell",
93 AzureAuth::ServicePrincipal(sp) if sp.token_file.is_some() => "workload identity",
94 AzureAuth::ServicePrincipal(_) => "service principal",
95 AzureAuth::ManagedIdentity => "managed identity",
96 AzureAuth::KeyCommand(_) => "secret_command",
97 AzureAuth::Bearer(_) => "token",
98 AzureAuth::Key(_) => "access key",
99 AzureAuth::Sas(_) => "SAS token",
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Default)]
106pub struct AzureSettings {
107 pub account: Option<String>,
110 pub auth: AzureAuth,
111 pub blob_endpoint: Option<String>,
113 pub use_emulator: bool,
115 pub identity: Option<AzureAuth>,
118}
119
120impl AzureSettings {
121 pub fn blob_endpoint_for(&self, account: &str) -> String {
123 match (&self.blob_endpoint, self.use_emulator) {
124 (Some(endpoint), _) => format!("{}/", endpoint.trim_end_matches('/')),
125 (None, true) => format!("http://127.0.0.1:10000/{account}/"),
126 (None, false) => format!("https://{account}.blob.core.windows.net/"),
127 }
128 }
129
130 pub fn with_token(mut self, env: &Environment<'_>) -> Result<Self, String> {
133 if let AzureAuth::KeyCommand(command) = &self.auth {
134 self.auth = AzureAuth::Key(crate::cloud_command::secret(command, env)?);
135 }
136 if self.auth.is_identity() {
137 let token = identity_token(&self.auth, STORAGE_SCOPE, env)?;
138 self.identity = Some(std::mem::replace(&mut self.auth, AzureAuth::Bearer(token)));
139 }
140 Ok(self)
141 }
142}
143
144const AZURITE_ACCOUNT: &str = "devstoreaccount1";
146const AZURITE_KEY: &str =
147 "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
148
149pub fn parse_connection_string(text: &str) -> Option<AzureSettings> {
153 let pairs: HashMap<String, String> = text
154 .split(';')
155 .filter_map(|pair| pair.split_once('='))
156 .map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string()))
157 .collect();
158 if pairs
159 .get("usedevelopmentstorage")
160 .is_some_and(|v| v.eq_ignore_ascii_case("true"))
161 {
162 return Some(AzureSettings {
163 account: Some(AZURITE_ACCOUNT.to_string()),
164 auth: AzureAuth::Key(AZURITE_KEY.to_string()),
165 blob_endpoint: None,
166 use_emulator: true,
167 identity: None,
168 });
169 }
170 let account = pairs.get("accountname").cloned();
171 let auth = match pairs.get("accountkey") {
172 Some(key) => AzureAuth::Key(key.clone()),
173 None => AzureAuth::Sas(
174 pairs
175 .get("sharedaccesssignature")?
176 .trim_start_matches('?')
177 .to_string(),
178 ),
179 };
180 let blob_endpoint = pairs.get("blobendpoint").cloned().or_else(|| {
181 let account = account.as_ref()?;
182 let suffix = pairs.get("endpointsuffix")?;
183 let protocol = pairs
184 .get("defaultendpointsprotocol")
185 .map(String::as_str)
186 .unwrap_or("https");
187 Some(format!("{protocol}://{account}.blob.{suffix}"))
188 });
189 let account = account.or_else(|| {
191 let endpoint = blob_endpoint.as_deref()?;
192 let host = endpoint.split_once("://")?.1.split('/').next()?;
193 host.split_once(".blob.").map(|(a, _)| a.to_string())
194 })?;
195 Some(AzureSettings {
196 account: Some(account),
197 auth,
198 blob_endpoint,
199 use_emulator: false,
200 identity: None,
201 })
202}
203
204pub fn from_environment(var: &dyn Fn(&str) -> Option<String>) -> Option<(AzureSettings, String)> {
207 let set = |key: &str| {
208 var(key)
209 .map(|v| v.trim().to_string())
210 .filter(|v| !v.is_empty())
211 };
212 if let Some(text) = set("AZURE_STORAGE_CONNECTION_STRING") {
213 return parse_connection_string(&text)
214 .map(|s| (s, "AZURE_STORAGE_CONNECTION_STRING".to_string()));
215 }
216 let account = set("AZURE_STORAGE_ACCOUNT_NAME");
217 let key = set("AZURE_STORAGE_ACCOUNT_KEY").or_else(|| set("AZURE_STORAGE_ACCESS_KEY"));
218 let sas = set("AZURE_STORAGE_SAS_TOKEN").or_else(|| set("AZURE_STORAGE_SAS_KEY"));
219 let (auth, origin) = match (&account, key, sas, service_principal(var)) {
220 (Some(_), Some(key), _, _) => (AzureAuth::Key(key), "AZURE_STORAGE_ACCOUNT_KEY"),
221 (Some(_), None, Some(sas), _) => (
222 AzureAuth::Sas(sas.trim_start_matches('?').to_string()),
223 "AZURE_STORAGE_SAS_TOKEN",
224 ),
225 (_, _, _, Some(sp)) => {
226 let origin = if sp.token_file.is_some() {
227 "AZURE_FEDERATED_TOKEN_FILE"
228 } else {
229 "AZURE_CLIENT_SECRET"
230 };
231 (AzureAuth::ServicePrincipal(sp), origin)
232 }
233 _ => return None,
234 };
235 Some((
236 AzureSettings {
237 account,
238 auth,
239 blob_endpoint: None,
240 use_emulator: false,
241 identity: None,
242 },
243 origin.to_string(),
244 ))
245}
246
247pub fn service_principal(var: &dyn Fn(&str) -> Option<String>) -> Option<ServicePrincipal> {
251 let set = |key: &str| {
252 var(key)
253 .map(|v| v.trim().to_string())
254 .filter(|v| !v.is_empty())
255 };
256 let tenant = set("AZURE_TENANT_ID")?;
257 let client_id = set("AZURE_CLIENT_ID")?;
258 let secret = set("AZURE_CLIENT_SECRET");
259 let token_file = set("AZURE_FEDERATED_TOKEN_FILE").map(PathBuf::from);
260 if secret.is_none() && token_file.is_none() {
261 return None;
262 }
263 Some(ServicePrincipal {
264 tenant,
265 client_id,
266 secret,
267 token_file,
268 authority: set("AZURE_AUTHORITY_HOST")
269 .unwrap_or_else(|| "https://login.microsoftonline.com".to_string()),
270 })
271}
272
273pub fn az_login_evidence(env: &Environment<'_>) -> bool {
276 let dir = match (env.var)("AZURE_CONFIG_DIR").filter(|v| !v.trim().is_empty()) {
277 Some(dir) => PathBuf::from(dir.trim()),
278 None => match &env.home {
279 Some(home) => home.join(".azure"),
280 None => return false,
281 },
282 };
283 (env.exists)(&dir)
284}
285
286pub fn powershell_login_evidence(env: &Environment<'_>) -> bool {
288 powershell_context(env).is_some_and(|path| (env.exists)(&path))
289}
290
291fn powershell_context(env: &Environment<'_>) -> Option<PathBuf> {
292 Some(
295 env.home
296 .as_ref()?
297 .join(".Azure")
298 .join("AzureRmContext.json"),
299 )
300}
301
302pub fn not_signed_in(env: &Environment<'_>) -> Option<String> {
305 if az_login_evidence(env) || powershell_login_evidence(env) {
306 return None;
307 }
308 let on_path = |names: &[&str]| {
309 (env.var)("PATH").is_some_and(|path| {
310 std::env::split_paths(&path)
311 .any(|dir| names.iter().any(|name| (env.exists)(&dir.join(name))))
312 })
313 };
314 let az = if env.windows {
315 on_path(&["az.cmd", "az.exe"])
316 } else {
317 on_path(&["az"])
318 };
319 let az_accounts = (env.var)("PSModulePath").is_some_and(|paths| {
320 std::env::split_paths(&paths).any(|dir| (env.exists)(&dir.join("Az.Accounts")))
321 });
322 match (az, az_accounts) {
323 (false, false) => None,
324 (true, false) => Some("not signed in: run az login".to_string()),
325 (false, true) => Some("not signed in: run Connect-AzAccount in PowerShell".to_string()),
326 (true, true) => {
327 Some("not signed in: run az login, or Connect-AzAccount in PowerShell".to_string())
328 }
329 }
330}
331
332pub fn identity_token(
334 auth: &AzureAuth,
335 scope: &str,
336 env: &Environment<'_>,
337) -> Result<String, String> {
338 match auth {
339 AzureAuth::AzCli => match token(scope, env) {
340 Ok(token) => Ok(token),
341 Err(az) if powershell_login_evidence(env) => {
344 powershell_token(scope, env).map_err(|ps| format!("{az}; {ps}"))
345 }
346 Err(e) => Err(e),
347 },
348 AzureAuth::PowerShell => powershell_token(scope, env),
349 AzureAuth::ServicePrincipal(sp) => service_principal_token(sp, scope, env),
350 AzureAuth::ManagedIdentity => managed_identity_token(scope, env),
351 AzureAuth::Bearer(token) => Ok(token.clone()),
352 AzureAuth::None | AzureAuth::Key(_) | AzureAuth::Sas(_) | AzureAuth::KeyCommand(_) => {
353 Err("this login has no tokens".to_string())
354 }
355 }
356}
357
358type TokenCache = Mutex<HashMap<String, (String, Option<SystemTime>)>>;
361
362fn tokens() -> &'static TokenCache {
363 static TOKENS: OnceLock<TokenCache> = OnceLock::new();
364 TOKENS.get_or_init(Default::default)
365}
366
367pub fn token(scope: &str, env: &Environment<'_>) -> Result<String, String> {
369 if let Some((token, _)) = tokens().lock().ok().and_then(|t| {
370 t.get(scope).cloned().filter(|(_, expires)| {
371 expires.is_none_or(|at| at > SystemTime::now() + Duration::from_secs(5 * 60))
372 })
373 }) {
374 return Ok(token);
375 }
376 let output = (env.run)(
377 "az",
378 &[
379 "account",
380 "get-access-token",
381 "--scope",
382 scope,
383 "--output",
384 "json",
385 ],
386 )
387 .map_err(|e| match e {
388 CommandError::Missing(_) => "needs the Azure CLI".to_string(),
389 CommandError::Failed(message) if message.contains("az login") => {
390 format!("not logged in: run az login ({})", message.trim())
391 }
392 other => other.to_string(),
393 })?;
394 let (token, expires) =
395 parse_token(&output).ok_or_else(|| "az returned no token".to_string())?;
396 crate::logging::keep_out_of_log(&token);
397 if let Ok(mut cached) = tokens().lock() {
398 cached.insert(scope.to_string(), (token.clone(), expires));
399 }
400 Ok(token)
401}
402
403pub fn parse_token(text: &str) -> Option<(String, Option<SystemTime>)> {
407 let value: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
408 let token = value.get("accessToken")?.as_str()?.to_string();
409 if token.is_empty() {
410 return None;
411 }
412 let expires = value
413 .get("expires_on")
414 .and_then(|v| v.as_u64().or_else(|| v.as_str()?.parse().ok()))
415 .map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs));
416 Some((token, expires))
417}
418
419const POWERSHELL_SCRIPT: &str = "$ErrorActionPreference = 'Stop'; \
424Import-Module Az.Accounts; \
425$out = @{}; \
426foreach ($r in @('https://storage.azure.com/', 'https://management.azure.com/')) { \
427 $t = Get-AzAccessToken -ResourceUrl $r; \
428 $tok = $t.Token; \
429 if ($tok -is [System.Security.SecureString]) { $tok = [System.Net.NetworkCredential]::new('', $tok).Password }; \
430 $out[$r] = @{ token = $tok; expires = $t.ExpiresOn.ToUnixTimeSeconds() } \
431}; \
432$out | ConvertTo-Json -Compress";
433
434fn powershell_token(scope: &str, env: &Environment<'_>) -> Result<String, String> {
437 let key = |scope: &str| format!("powershell {scope}");
438 if let Some(token) = cached_token(&key(scope)) {
439 return Ok(token);
440 }
441 let args = [
442 "-NoProfile",
443 "-NonInteractive",
444 "-Command",
445 POWERSHELL_SCRIPT,
446 ];
447 let mut output = (env.run)("pwsh", &args);
448 if env.windows && matches!(output, Err(CommandError::Missing(_))) {
449 output = (env.run)("powershell", &args);
450 }
451 let output = output.map_err(|e| match e {
452 CommandError::Missing(_) => "needs PowerShell".to_string(),
453 CommandError::Failed(message) if message.contains("Connect-AzAccount") => {
454 format!("not signed in: run Connect-AzAccount ({})", message.trim())
455 }
456 CommandError::Failed(message) if message.contains("Az.Accounts") => {
457 "needs the Az.Accounts PowerShell module".to_string()
458 }
459 other => other.to_string(),
460 })?;
461 let tokens = parse_powershell_tokens(&output)
462 .ok_or_else(|| "Azure PowerShell returned no token".to_string())?;
463 let mut wanted = None;
464 for (resource, token, expires) in tokens {
465 let scope_of = format!("{resource}.default");
466 if scope_of == scope {
467 wanted = Some(token.clone());
468 }
469 cache_token(&key(&scope_of), token, Some(expires));
470 }
471 wanted.ok_or_else(|| format!("Azure PowerShell returned no token for {scope}"))
472}
473
474pub fn parse_powershell_tokens(text: &str) -> Option<Vec<(String, String, SystemTime)>> {
476 let value: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
477 let tokens: Vec<_> = value
478 .as_object()?
479 .iter()
480 .filter_map(|(resource, entry)| {
481 let token = entry.get("token")?.as_str()?.to_string();
482 let expires = entry.get("expires")?.as_u64()?;
483 (!token.is_empty()).then(|| {
484 (
485 resource.clone(),
486 token,
487 SystemTime::UNIX_EPOCH + Duration::from_secs(expires),
488 )
489 })
490 })
491 .collect();
492 (!tokens.is_empty()).then_some(tokens)
493}
494
495fn service_principal_token(
498 sp: &ServicePrincipal,
499 scope: &str,
500 env: &Environment<'_>,
501) -> Result<String, String> {
502 let key = format!("sp {} {} {scope}", sp.tenant, sp.client_id);
503 if let Some(token) = cached_token(&key) {
504 return Ok(token);
505 }
506 let mut form: Vec<(&str, String)> = vec![
507 ("client_id", sp.client_id.clone()),
508 ("scope", scope.to_string()),
509 ("grant_type", "client_credentials".to_string()),
510 ];
511 match (&sp.token_file, &sp.secret) {
512 (Some(file), _) => {
513 let assertion = (env.read)(file).ok_or_else(|| {
514 format!("cannot read AZURE_FEDERATED_TOKEN_FILE {}", file.display())
515 })?;
516 form.push((
517 "client_assertion_type",
518 "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".to_string(),
519 ));
520 form.push(("client_assertion", assertion.trim().to_string()));
521 }
522 (None, Some(secret)) => form.push(("client_secret", secret.clone())),
523 (None, None) => return Err("the service principal has no secret".to_string()),
524 }
525 let body = form
526 .iter()
527 .map(|(k, v)| format!("{k}={}", crate::cloud_browse::urlencode(v)))
528 .collect::<Vec<_>>()
529 .join("&");
530 let url = format!(
531 "{}/{}/oauth2/v2.0/token",
532 sp.authority.trim_end_matches('/'),
533 crate::cloud_browse::urlencode(&sp.tenant)
534 );
535 let mut response = crate::cloud_browse::http_agent()
536 .post(&url)
537 .config()
538 .http_status_as_error(false)
539 .build()
540 .header("Content-Type", "application/x-www-form-urlencoded")
541 .send(body)
542 .map_err(|e| format!("{e}"))?;
543 let status = response.status().as_u16();
544 let text = response
545 .body_mut()
546 .read_to_string()
547 .map_err(|e| format!("could not read the response: {e}"))?;
548 let (token, expires) = parse_entra_token(&text).ok_or_else(|| {
549 let value: Option<serde_json::Value> = serde_json::from_str(&text).ok();
550 let description = value
551 .as_ref()
552 .and_then(|v| v.get("error_description"))
553 .and_then(|d| d.as_str())
554 .and_then(|d| d.lines().next())
555 .unwrap_or("no token in the response");
556 format!("not logged in: the service principal was refused ({status}): {description}")
557 })?;
558 cache_token(&key, token.clone(), Some(expires));
559 Ok(token)
560}
561
562fn managed_identity_token(scope: &str, env: &Environment<'_>) -> Result<String, String> {
566 let key = format!("managed {scope}");
567 if let Some(token) = cached_token(&key) {
568 return Ok(token);
569 }
570 let resource = crate::cloud_browse::urlencode(scope.trim_end_matches(".default"));
571 let client = (env.var)("AZURE_CLIENT_ID")
572 .map(|id| format!("&client_id={}", crate::cloud_browse::urlencode(&id)))
573 .unwrap_or_default();
574 let set = |k: &str| (env.var)(k).filter(|v| !v.trim().is_empty());
575 let (url, header) = if let (Some(endpoint), Some(secret)) =
576 (set("IDENTITY_ENDPOINT"), set("IDENTITY_HEADER"))
577 {
578 (
579 format!("{endpoint}?api-version=2019-08-01&resource={resource}{client}"),
580 ("X-IDENTITY-HEADER", secret),
581 )
582 } else if let Some(endpoint) = set("MSI_ENDPOINT") {
583 (
586 format!("{endpoint}?api-version=2017-09-01&resource={resource}{client}"),
587 match set("MSI_SECRET") {
588 Some(secret) => ("secret", secret),
589 None => ("Metadata", "true".to_string()),
590 },
591 )
592 } else {
593 (
594 format!(
595 "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={resource}{client}"
596 ),
597 ("Metadata", "true".to_string()),
598 )
599 };
600 let mut response = crate::user_agent::ureq_config()
601 .timeout_global(Some(Duration::from_secs(5)))
602 .http_status_as_error(false)
603 .build()
604 .new_agent()
605 .get(&url)
606 .header(header.0, &header.1)
607 .call()
608 .map_err(|e| format!("no managed identity answered: {e}"))?;
609 let status = response.status().as_u16();
610 let text = response
611 .body_mut()
612 .read_to_string()
613 .map_err(|e| format!("could not read the response: {e}"))?;
614 let (token, expires) = parse_entra_token(&text)
615 .ok_or_else(|| format!("the managed identity returned no token ({status})"))?;
616 cache_token(&key, token.clone(), Some(expires));
617 Ok(token)
618}
619
620pub fn parse_entra_token(text: &str) -> Option<(String, SystemTime)> {
622 let value: serde_json::Value = serde_json::from_str(text).ok()?;
623 let token = value.get("access_token")?.as_str()?.to_string();
624 let number = |key: &str| {
625 value
626 .get(key)
627 .and_then(|v| v.as_u64().or_else(|| v.as_str()?.parse().ok()))
628 };
629 let expires = match (number("expires_on"), number("expires_in")) {
631 (Some(on), _) => SystemTime::UNIX_EPOCH + Duration::from_secs(on),
632 (None, Some(within)) => SystemTime::now() + Duration::from_secs(within),
633 (None, None) => SystemTime::now() + Duration::from_secs(300),
634 };
635 (!token.is_empty()).then_some((token, expires))
636}
637
638fn cached_token(key: &str) -> Option<String> {
639 tokens().lock().ok().and_then(|t| {
640 t.get(key)
641 .filter(|(_, expires)| {
642 expires.is_none_or(|at| at > SystemTime::now() + Duration::from_secs(5 * 60))
643 })
644 .map(|(token, _)| token.clone())
645 })
646}
647
648fn cache_token(key: &str, token: String, expires: Option<SystemTime>) {
649 crate::logging::keep_out_of_log(&token);
650 if let Ok(mut cached) = tokens().lock() {
651 cached.insert(key.to_string(), (token, expires));
652 }
653}
654
655fn account_keys() -> &'static Mutex<HashMap<String, String>> {
658 static KEYS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
659 KEYS.get_or_init(Default::default)
660}
661
662#[cfg(test)]
663pub(crate) fn remember_key_for_test(account: &str, key: &str) {
664 if let Ok(mut keys) = account_keys().lock() {
665 keys.insert(account.to_string(), key.to_string());
666 }
667}
668
669pub fn remembered_key(account: &str) -> Option<String> {
672 account_keys().lock().ok()?.get(account).cloned()
673}
674
675fn token_readable_accounts() -> &'static Mutex<std::collections::HashSet<String>> {
676 static ACCOUNTS: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
677 ACCOUNTS.get_or_init(Default::default)
678}
679
680pub fn token_reads(account: &str) -> bool {
682 token_readable_accounts()
683 .lock()
684 .is_ok_and(|accounts| accounts.contains(account))
685}
686
687pub fn remember_token_reads(account: &str) {
689 if let Ok(mut accounts) = token_readable_accounts().lock() {
690 accounts.insert(account.to_string());
691 }
692}
693
694pub fn check_read(
697 account: &str,
698 container: &str,
699 path: &str,
700 settings: &AzureSettings,
701) -> Result<(), String> {
702 let directory = match path.trim_start_matches('/').rsplit_once('/') {
703 Some((directory, _)) => format!("{directory}/"),
704 None => String::new(),
705 };
706 let url = format!(
707 "{}{}?restype=container&comp=list&maxresults=1&prefix={}",
708 settings.blob_endpoint_for(account),
709 crate::cloud_browse::urlencode(container),
710 crate::cloud_browse::urlencode(&directory)
711 );
712 send_signed(&url, account, settings).map(|_| ())
713}
714
715pub fn is_permission_mismatch(error: &str) -> bool {
717 error.contains("AuthorizationPermissionMismatch")
718}
719
720pub fn fetch_account_key(
724 account: &str,
725 identity: &AzureAuth,
726 env: &Environment<'_>,
727) -> Result<String, String> {
728 if let Some(key) = remembered_key(account) {
729 return Ok(key);
730 }
731 let management = identity_token(identity, MANAGEMENT_SCOPE, env)?;
732 let query = format!(
733 "resources | where type =~ 'microsoft.storage/storageaccounts' and name =~ '{}' \
734 | project id, name, sharedKey=properties.allowSharedKeyAccess",
735 account.replace('\'', "")
736 );
737 let body = serde_json::json!({ "query": query });
738 let text = crate::cloud_browse::http_agent()
739 .post("https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2024-04-01")
740 .header("Authorization", &format!("Bearer {management}"))
741 .header("Content-Type", "application/json")
742 .send(body.to_string())
743 .map_err(|e| format!("{e}"))?
744 .body_mut()
745 .read_to_string()
746 .map_err(|e| format!("could not read the response: {e}"))?;
747 let (id, shared_key) = parse_account_id(&text)
748 .ok_or_else(|| format!("{account} is not an account this login can manage"))?;
749 if !shared_key {
750 return Err("shared-key access is disabled on the account".to_string());
751 }
752 let mut response = crate::cloud_browse::http_agent()
753 .post(&format!(
754 "https://management.azure.com{id}/listKeys?api-version=2023-01-01"
755 ))
756 .config()
757 .http_status_as_error(false)
758 .build()
759 .header("Authorization", &format!("Bearer {management}"))
760 .header("Content-Length", "0")
761 .send_empty()
762 .map_err(|e| format!("{e}"))?;
763 let status = response.status().as_u16();
764 let text = response
765 .body_mut()
766 .read_to_string()
767 .map_err(|e| format!("could not read the response: {e}"))?;
768 if status != 200 {
769 return Err(format!(
770 "the login may not list the account's keys ({status})"
771 ));
772 }
773 let key = parse_keys(&text).ok_or_else(|| "the account returned no keys".to_string())?;
774 crate::logging::keep_out_of_log(&key);
775 if let Ok(mut keys) = account_keys().lock() {
776 keys.insert(account.to_string(), key.clone());
777 }
778 Ok(key)
779}
780
781pub fn parse_account_id(text: &str) -> Option<(String, bool)> {
784 let value: serde_json::Value = serde_json::from_str(text).ok()?;
785 let row = value.get("data")?.as_array()?.first()?;
786 let id = row.get("id")?.as_str()?.to_string();
787 let shared_key = row.get("sharedKey").and_then(|v| v.as_bool()) != Some(false);
788 Some((id, shared_key))
789}
790
791pub fn parse_keys(text: &str) -> Option<String> {
793 let value: serde_json::Value = serde_json::from_str(text).ok()?;
794 value
795 .get("keys")?
796 .as_array()?
797 .iter()
798 .find_map(|k| k.get("value")?.as_str().map(str::to_string))
799 .filter(|k| !k.is_empty())
800}
801
802pub fn with_account_key(
805 account: &str,
806 settings: &AzureSettings,
807 refusal: &str,
808 enabled: bool,
809 env: &Environment<'_>,
810) -> Result<AzureSettings, String> {
811 let identity = match &settings.identity {
812 Some(identity) if enabled && is_permission_mismatch(refusal) => identity,
813 _ => return Err(refusal.to_string()),
814 };
815 match fetch_account_key(account, identity, env) {
816 Ok(key) => Ok(AzureSettings {
817 auth: AzureAuth::Key(key),
818 ..settings.clone()
819 }),
820 Err(why) => Err(format!(
821 "{refusal}. The account's keys were not used: {why}"
822 )),
823 }
824}
825
826#[derive(Debug, Clone, PartialEq, Eq, Default)]
828pub struct Account {
829 pub name: String,
830 pub subscription: Option<String>,
831 pub location: Option<String>,
832 pub hierarchical_namespace: bool,
833 pub blob_endpoint: Option<String>,
834 pub private_network: bool,
836 pub shared_key_access: bool,
837}
838
839const ACCOUNTS_QUERY: &str = "resources \
841| where type =~ 'microsoft.storage/storageaccounts' \
842| join kind=leftouter (resourcecontainers \
843 | where type =~ 'microsoft.resources/subscriptions' \
844 | project subscriptionId, subscription=name) on subscriptionId \
845| project id, name, subscription, location, \
846 hns=properties.isHnsEnabled, \
847 blob=properties.primaryEndpoints.blob, \
848 publicNetworkAccess=properties.publicNetworkAccess, \
849 defaultAction=properties.networkAcls.defaultAction, \
850 sharedKey=properties.allowSharedKeyAccess \
851| order by name asc";
852
853const MAX_ACCOUNT_PAGES: usize = 20;
856
857pub fn discover_accounts(
859 identity: &AzureAuth,
860 env: &Environment<'_>,
861) -> Result<Vec<Account>, String> {
862 let token = identity_token(identity, MANAGEMENT_SCOPE, env)?;
863 let mut accounts = Vec::new();
864 let mut skip_token: Option<String> = None;
865 for _ in 0..MAX_ACCOUNT_PAGES {
866 let mut options = serde_json::json!({ "$top": 1000 });
867 if let Some(skip) = &skip_token {
868 options["$skipToken"] = serde_json::Value::String(skip.clone());
869 }
870 let body = serde_json::json!({ "query": ACCOUNTS_QUERY, "options": options });
871 let text = crate::cloud_browse::http_agent()
872 .post("https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2024-04-01")
873 .header("Authorization", &format!("Bearer {token}"))
874 .header("Content-Type", "application/json")
875 .send(body.to_string())
876 .map_err(|e| format!("{e}"))?
877 .body_mut()
878 .read_to_string()
879 .map_err(|e| format!("could not read the response: {e}"))?;
880 let (page, next) = parse_accounts(&text)?;
881 accounts.extend(page);
882 match next {
883 Some(next) => skip_token = Some(next),
884 None => break,
885 }
886 }
887 Ok(accounts)
888}
889
890pub fn parse_accounts(text: &str) -> Result<(Vec<Account>, Option<String>), String> {
892 let value: serde_json::Value =
893 serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
894 if let Some(message) = value
895 .get("error")
896 .and_then(|e| e.get("message"))
897 .and_then(|m| m.as_str())
898 {
899 return Err(message.to_string());
900 }
901 let rows = value
902 .get("data")
903 .and_then(|d| d.as_array())
904 .ok_or_else(|| "no data in the response".to_string())?;
905 let text_of = |row: &serde_json::Value, key: &str| {
906 row.get(key)
907 .and_then(|v| v.as_str())
908 .map(str::to_string)
909 .filter(|v| !v.is_empty())
910 };
911 let accounts = rows
912 .iter()
913 .filter_map(|row| {
914 Some(Account {
915 name: text_of(row, "name")?,
916 subscription: text_of(row, "subscription"),
917 location: text_of(row, "location"),
918 hierarchical_namespace: row.get("hns").and_then(|v| v.as_bool()) == Some(true),
919 blob_endpoint: text_of(row, "blob"),
920 private_network: text_of(row, "publicNetworkAccess").as_deref() == Some("Disabled")
921 || text_of(row, "defaultAction").as_deref() == Some("Deny"),
922 shared_key_access: row.get("sharedKey").and_then(|v| v.as_bool()) != Some(false),
924 })
925 })
926 .collect();
927 let next = value
928 .get("$skipToken")
929 .and_then(|v| v.as_str())
930 .map(str::to_string);
931 Ok((accounts, next))
932}
933
934pub fn list_containers(account: &str, settings: &AzureSettings) -> Result<Vec<String>, String> {
936 let endpoint = settings.blob_endpoint_for(account);
937 let mut names = Vec::new();
938 let mut marker: Option<String> = None;
939 for _ in 0..MAX_ACCOUNT_PAGES {
940 let mut url = format!("{endpoint}?comp=list&maxresults=5000");
941 if let Some(marker) = &marker {
942 url.push_str(&format!(
943 "&marker={}",
944 crate::cloud_browse::urlencode(marker)
945 ));
946 }
947 let text = send_signed(&url, account, settings)?;
948 let (page, next) = parse_containers(&text)?;
949 names.extend(page);
950 match next {
951 Some(next) => marker = Some(next),
952 None => break,
953 }
954 }
955 names.sort();
956 Ok(names)
957}
958
959fn send_signed(url: &str, account: &str, settings: &AzureSettings) -> Result<String, String> {
962 let url = match &settings.auth {
963 AzureAuth::Sas(sas) => format!("{url}&{sas}"),
964 _ => url.to_string(),
965 };
966 let mut request = http::Request::builder()
967 .method("GET")
968 .uri(&url)
969 .header("x-ms-version", API_VERSION)
972 .body(object_store::client::HttpRequestBody::empty())
973 .map_err(|e| format!("could not build the request: {e}"))?;
974 match &settings.auth {
975 AzureAuth::Bearer(token) => {
976 let credential = object_store::azure::AzureCredential::BearerToken(token.clone());
977 object_store::azure::AzureAuthorizer::new(&credential, account).authorize(&mut request);
978 }
979 AzureAuth::Key(key) => {
980 let key = object_store::azure::AzureAccessKey::try_new(key)
981 .map_err(|e| format!("the account key is not valid: {e}"))?;
982 let credential = object_store::azure::AzureCredential::AccessKey(key);
983 object_store::azure::AzureAuthorizer::new(&credential, account).authorize(&mut request);
984 }
985 AzureAuth::Sas(_)
986 | AzureAuth::None
987 | AzureAuth::AzCli
988 | AzureAuth::PowerShell
989 | AzureAuth::ServicePrincipal(_)
990 | AzureAuth::ManagedIdentity
991 | AzureAuth::KeyCommand(_) => {}
992 }
993 let mut call = crate::cloud_browse::http_agent()
994 .get(&url)
995 .config()
996 .http_status_as_error(false)
997 .build();
998 for (name, value) in request.headers() {
999 if let Ok(value) = value.to_str() {
1000 call = call.header(name.as_str(), value);
1001 }
1002 }
1003 let mut response = call.call().map_err(|e| format!("{e}"))?;
1004 let status = response.status();
1005 let text = response
1006 .body_mut()
1007 .read_to_string()
1008 .map_err(|e| format!("could not read the response: {e}"))?;
1009 if status.is_success() {
1010 Ok(text)
1011 } else {
1012 Err(describe_error(status.as_u16(), &text))
1013 }
1014}
1015
1016pub fn describe_error(status: u16, body: &str) -> String {
1019 let field = |name: &str| {
1020 let open = format!("<{name}>");
1021 let close = format!("</{name}>");
1022 let start = body.find(&open)? + open.len();
1023 let end = body[start..].find(&close)? + start;
1024 Some(body[start..end].trim().to_string())
1025 };
1026 let code = field("Code").unwrap_or_default();
1027 let message = field("Message")
1028 .and_then(|m| m.lines().next().map(str::to_string))
1029 .unwrap_or_default();
1030 let mut text = format!("{status} {code}");
1031 if !message.is_empty() {
1032 text.push_str(&format!(": {message}"));
1033 }
1034 if code == "AuthorizationPermissionMismatch" {
1035 text.push_str(
1036 ". Reading blobs with a sign-in needs the Storage Blob Data Reader role on the \
1037 account, or an ACL on the path when it has hierarchical namespace",
1038 );
1039 }
1040 text
1041}
1042
1043pub fn parse_containers(text: &str) -> Result<(Vec<String>, Option<String>), String> {
1045 let mut names = Vec::new();
1046 let mut rest = text;
1047 while let Some(start) = rest.find("<Container>") {
1048 rest = &rest[start + "<Container>".len()..];
1049 let end = rest.find("</Container>").unwrap_or(rest.len());
1050 let block = &rest[..end];
1051 if let Some(name_start) = block.find("<Name>") {
1052 let after = &block[name_start + "<Name>".len()..];
1053 if let Some(name_end) = after.find("</Name>") {
1054 let name = after[..name_end].trim();
1055 if !name.is_empty() {
1056 names.push(name.to_string());
1057 }
1058 }
1059 }
1060 rest = &rest[end..];
1061 }
1062 if names.is_empty() && !text.contains("<EnumerationResults") {
1063 return Err("not a container listing".to_string());
1064 }
1065 let next = text
1066 .find("<NextMarker>")
1067 .and_then(|start| {
1068 let after = &text[start + "<NextMarker>".len()..];
1069 after
1070 .find("</NextMarker>")
1071 .map(|end| after[..end].trim().to_string())
1072 })
1073 .filter(|m| !m.is_empty());
1074 Ok((names, next))
1075}
1076
1077pub fn store(
1079 account: &str,
1080 container: &str,
1081 settings: &AzureSettings,
1082) -> Result<std::sync::Arc<dyn object_store::ObjectStore>, String> {
1083 Ok(std::sync::Arc::new(build(account, container, settings)?))
1084}
1085
1086pub fn paginated_store(
1088 account: &str,
1089 container: &str,
1090 settings: &AzureSettings,
1091) -> Result<std::sync::Arc<dyn object_store::list::PaginatedListStore>, String> {
1092 Ok(std::sync::Arc::new(build(account, container, settings)?))
1093}
1094
1095fn build(
1096 account: &str,
1097 container: &str,
1098 settings: &AzureSettings,
1099) -> Result<object_store::azure::MicrosoftAzure, String> {
1100 let mut builder = object_store::azure::MicrosoftAzureBuilder::new()
1101 .with_account(account)
1102 .with_container_name(container)
1103 .with_config(
1104 object_store::azure::AzureConfigKey::Client(crate::user_agent::CLIENT_KEY),
1105 crate::user_agent::get(),
1106 );
1107 if settings.use_emulator {
1108 builder = builder.with_use_emulator(true);
1109 }
1110 if let Some(endpoint) = &settings.blob_endpoint {
1111 builder = builder
1112 .with_endpoint(endpoint.trim_end_matches('/').to_string())
1113 .with_allow_http(endpoint.starts_with("http://"));
1114 }
1115 builder = match &settings.auth {
1116 AzureAuth::Bearer(token) => builder.with_bearer_token_authorization(token.clone()),
1117 AzureAuth::Key(key) => builder.with_access_key(key.clone()),
1118 AzureAuth::Sas(sas) => {
1119 builder.with_config(object_store::azure::AzureConfigKey::SasKey, sas.clone())
1120 }
1121 AzureAuth::None
1122 | AzureAuth::AzCli
1123 | AzureAuth::PowerShell
1124 | AzureAuth::ServicePrincipal(_)
1125 | AzureAuth::ManagedIdentity
1126 | AzureAuth::KeyCommand(_) => builder.with_skip_signature(true),
1127 };
1128 builder
1129 .build()
1130 .map_err(|e| format!("Azure is not configured: {e}"))
1131}
1132
1133pub fn polars_options(
1135 account: &str,
1136 settings: &AzureSettings,
1137) -> Vec<(object_store::azure::AzureConfigKey, String)> {
1138 use object_store::azure::AzureConfigKey;
1139 let mut options = vec![(AzureConfigKey::AccountName, account.to_string())];
1140 if settings.use_emulator {
1141 options.push((AzureConfigKey::UseEmulator, "true".to_string()));
1142 }
1143 if let Some(endpoint) = &settings.blob_endpoint {
1144 options.push((AzureConfigKey::Endpoint, endpoint.clone()));
1145 }
1146 match &settings.auth {
1147 AzureAuth::Bearer(token) => options.push((AzureConfigKey::Token, token.clone())),
1148 AzureAuth::Key(key) => options.push((AzureConfigKey::AccessKey, key.clone())),
1149 AzureAuth::Sas(sas) => options.push((AzureConfigKey::SasKey, sas.clone())),
1150 AzureAuth::None
1151 | AzureAuth::AzCli
1152 | AzureAuth::PowerShell
1153 | AzureAuth::ServicePrincipal(_)
1154 | AzureAuth::ManagedIdentity
1155 | AzureAuth::KeyCommand(_) => {
1156 options.push((AzureConfigKey::SkipSignature, "true".to_string()))
1157 }
1158 }
1159 options
1160}
1161
1162pub fn is_folder_marker(name: &str, size: u64, prefixes: &[String]) -> bool {
1166 size == 0 && prefixes.iter().any(|p| p.trim_end_matches('/') == name)
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172 use std::path::Path;
1173
1174 fn env_with<'a>(
1175 vars: &'a HashMap<&'a str, String>,
1176 files: &'a HashMap<PathBuf, String>,
1177 run: &'a crate::cloud_command::Runner<'a>,
1178 body: impl FnOnce(&Environment<'_>),
1179 ) {
1180 let var = |k: &str| vars.get(k).cloned();
1181 let exists = |p: &Path| files.contains_key(p);
1182 let read = |p: &Path| files.get(p).cloned();
1183 let all_vars = Vec::new;
1184 let list = |_: &Path| Vec::new();
1185 body(&Environment {
1186 var: &var,
1187 exists: &exists,
1188 read: &read,
1189 home: Some(PathBuf::from("/home/u")),
1190 windows: false,
1191 run,
1192 all_vars: &all_vars,
1193 list: &list,
1194 });
1195 }
1196
1197 #[test]
1198 fn service_principals_from_the_environment() {
1199 let vars: HashMap<&str, String> = [
1200 ("AZURE_TENANT_ID", "t"),
1201 ("AZURE_CLIENT_ID", "c"),
1202 ("AZURE_FEDERATED_TOKEN_FILE", "/var/run/token"),
1203 ]
1204 .into_iter()
1205 .map(|(k, v)| (k, v.to_string()))
1206 .collect();
1207 let (settings, origin) = from_environment(&|k| vars.get(k).cloned()).unwrap();
1208 assert_eq!(origin, "AZURE_FEDERATED_TOKEN_FILE");
1209 assert_eq!(settings.account, None, "finds its accounts");
1210 assert!(settings.auth.is_identity());
1211 assert_eq!(settings.auth.describe(), "workload identity");
1212 let mut keyed = vars.clone();
1214 keyed.insert("AZURE_STORAGE_ACCOUNT_NAME", "acct".to_string());
1215 keyed.insert("AZURE_STORAGE_ACCOUNT_KEY", "a2V5".to_string());
1216 let (settings, _) = from_environment(&|k| keyed.get(k).cloned()).unwrap();
1217 assert_eq!(settings.auth, AzureAuth::Key("a2V5".to_string()));
1218 let mut half = vars.clone();
1220 half.remove("AZURE_FEDERATED_TOKEN_FILE");
1221 assert!(from_environment(&|k| half.get(k).cloned()).is_none());
1222 let debug = format!(
1223 "{:?}",
1224 ServicePrincipal {
1225 secret: Some("hunter2".to_string()),
1226 ..Default::default()
1227 }
1228 );
1229 assert!(!debug.contains("hunter2"), "{debug}");
1230 }
1231
1232 #[test]
1233 fn a_service_principal_token_from_entra_id() {
1234 use std::io::{Read, Write};
1235 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1236 let authority = format!("http://{}", listener.local_addr().unwrap());
1237 let (tx, rx) = std::sync::mpsc::channel();
1238 std::thread::spawn(move || {
1239 for stream in listener.incoming() {
1240 let mut stream = stream.unwrap();
1241 let mut request = Vec::new();
1242 let mut buf = [0u8; 4096];
1243 loop {
1244 let n = stream.read(&mut buf).unwrap_or(0);
1245 request.extend_from_slice(&buf[..n]);
1246 let text = String::from_utf8_lossy(&request);
1247 if n == 0
1248 || text.find("\r\n\r\n").is_some_and(|end| {
1249 let length = text
1250 .lines()
1251 .find_map(|l| {
1252 l.to_ascii_lowercase()
1253 .strip_prefix("content-length:")
1254 .map(|v| v.trim().parse::<usize>().unwrap_or(0))
1255 })
1256 .unwrap_or(0);
1257 request.len() >= end + 4 + length
1258 })
1259 {
1260 break;
1261 }
1262 }
1263 tx.send(String::from_utf8_lossy(&request).into_owned())
1264 .unwrap();
1265 let body = r#"{"token_type":"Bearer","expires_in":3599,"access_token":"eyJ.sp"}"#;
1266 let _ = write!(
1267 stream,
1268 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1269 body.len()
1270 );
1271 }
1272 });
1273 let sp = ServicePrincipal {
1274 tenant: "tenant-1".to_string(),
1275 client_id: "client-1".to_string(),
1276 secret: None,
1277 token_file: Some(PathBuf::from("/var/run/federated")),
1278 authority,
1279 };
1280 let vars = HashMap::new();
1281 let files: HashMap<PathBuf, String> = [(
1282 PathBuf::from("/var/run/federated"),
1283 "jwt-from-aks\n".to_string(),
1284 )]
1285 .into();
1286 let run = |p: &str, _: &[&str]| Err(CommandError::Missing(p.to_string()));
1287 env_with(&vars, &files, &run, |env| {
1288 let token = identity_token(
1289 &AzureAuth::ServicePrincipal(sp.clone()),
1290 "https://storage.azure.com/.default",
1291 env,
1292 )
1293 .unwrap();
1294 assert_eq!(token, "eyJ.sp");
1295 });
1296 let request = rx.recv().unwrap();
1297 assert!(
1298 request.starts_with("POST /tenant-1/oauth2/v2.0/token"),
1299 "{request}"
1300 );
1301 assert!(
1302 request.contains("client_assertion=jwt-from-aks"),
1303 "{request}"
1304 );
1305 assert!(
1306 request.contains("grant_type=client_credentials"),
1307 "{request}"
1308 );
1309 }
1310
1311 #[test]
1312 fn a_managed_identity_token_from_the_platform() {
1313 use std::io::{Read, Write};
1314 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1315 let endpoint = format!("http://{}/msi/token", listener.local_addr().unwrap());
1316 let (tx, rx) = std::sync::mpsc::channel();
1317 std::thread::spawn(move || {
1318 for stream in listener.incoming() {
1319 let mut stream = stream.unwrap();
1320 let mut buf = [0u8; 4096];
1321 let n = stream.read(&mut buf).unwrap_or(0);
1322 tx.send(String::from_utf8_lossy(&buf[..n]).into_owned())
1323 .unwrap();
1324 let body = r#"{"access_token":"eyJ.mi","expires_on":"4102444800","resource":"https://storage.azure.com/","token_type":"Bearer"}"#;
1325 let _ = write!(
1326 stream,
1327 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1328 body.len()
1329 );
1330 }
1331 });
1332 let vars: HashMap<&str, String> = [
1333 ("IDENTITY_ENDPOINT", endpoint),
1334 ("IDENTITY_HEADER", "header-secret".to_string()),
1335 ]
1336 .into();
1337 let files = HashMap::new();
1338 let run = |p: &str, _: &[&str]| Err(CommandError::Missing(p.to_string()));
1339 env_with(&vars, &files, &run, |env| {
1340 let token = identity_token(
1341 &AzureAuth::ManagedIdentity,
1342 "https://management.azure.com/.default",
1343 env,
1344 )
1345 .unwrap();
1346 assert_eq!(token, "eyJ.mi");
1347 });
1348 let request = rx.recv().unwrap().to_ascii_lowercase();
1349 assert!(request.contains("api-version=2019-08-01"), "{request}");
1350 assert!(
1351 request.contains("resource=https%3a%2f%2fmanagement.azure.com%2f"),
1352 "{request}"
1353 );
1354 assert!(
1355 request.contains("x-identity-header: header-secret"),
1356 "{request}"
1357 );
1358 }
1359
1360 #[test]
1361 fn powershell_tokens() {
1362 let tokens = parse_powershell_tokens(
1363 r#"{"https://storage.azure.com/":{"token":"st","expires":1893553445},"https://management.azure.com/":{"token":"mg","expires":1893553445}}"#,
1364 )
1365 .unwrap();
1366 assert_eq!(tokens.len(), 2);
1367 assert!(
1368 tokens
1369 .iter()
1370 .any(|(r, t, _)| r == "https://storage.azure.com/" && t == "st")
1371 );
1372 assert!(parse_powershell_tokens("WARNING: something").is_none());
1373 assert!(!POWERSHELL_SCRIPT.contains('"'));
1375 }
1376
1377 #[test]
1378 fn powershell_is_asked_when_it_is_the_one_signed_in() {
1379 let vars = HashMap::new();
1380 let files: HashMap<PathBuf, String> = [(
1381 PathBuf::from("/home/u/.Azure/AzureRmContext.json"),
1382 "{}".to_string(),
1383 )]
1384 .into();
1385 let run = |program: &str, args: &[&str]| match program {
1386 "az" => Err(CommandError::Failed(
1387 "Please run 'az login' to setup account.".to_string(),
1388 )),
1389 "pwsh" => {
1390 assert_eq!(&args[..3], ["-NoProfile", "-NonInteractive", "-Command"]);
1391 Ok(r#"{"https://storage.azure.com/":{"token":"from-pwsh","expires":4102444800},"https://management.azure.com/":{"token":"mg","expires":4102444800}}"#.to_string())
1392 }
1393 other => Err(CommandError::Missing(other.to_string())),
1394 };
1395 env_with(&vars, &files, &run, |env| {
1396 assert!(powershell_login_evidence(env));
1397 let token = identity_token(&AzureAuth::AzCli, STORAGE_SCOPE, env).unwrap();
1398 assert_eq!(token, "from-pwsh");
1399 });
1400 }
1401
1402 #[test]
1403 fn not_signed_in_needs_azure_tooling() {
1404 let path = std::env::join_paths(["/opt/az/bin", "/usr/bin"]).unwrap();
1407 let vars: HashMap<&str, String> = [
1408 ("PATH", path.to_string_lossy().into_owned()),
1409 (
1410 "PSModulePath",
1411 "/home/u/.local/share/powershell/Modules".to_string(),
1412 ),
1413 ]
1414 .into();
1415 let run = |p: &str, _: &[&str]| Err(CommandError::Missing(p.to_string()));
1416 let az_only: HashMap<PathBuf, String> =
1417 [(PathBuf::from("/opt/az/bin/az"), String::new())].into();
1418 env_with(&vars, &az_only, &run, |env| {
1419 assert_eq!(
1420 not_signed_in(env).as_deref(),
1421 Some("not signed in: run az login")
1422 );
1423 });
1424 let both: HashMap<PathBuf, String> = [
1425 (PathBuf::from("/opt/az/bin/az"), String::new()),
1426 (
1427 PathBuf::from("/home/u/.local/share/powershell/Modules/Az.Accounts"),
1428 String::new(),
1429 ),
1430 ]
1431 .into();
1432 env_with(&vars, &both, &run, |env| {
1433 assert!(not_signed_in(env).unwrap().contains("Connect-AzAccount"));
1434 });
1435 let nothing = HashMap::new();
1436 env_with(&vars, ¬hing, &run, |env| {
1437 assert_eq!(not_signed_in(env), None)
1438 });
1439 let signed_in: HashMap<PathBuf, String> = [
1440 (PathBuf::from("/opt/az/bin/az"), String::new()),
1441 (PathBuf::from("/home/u/.azure"), String::new()),
1442 ]
1443 .into();
1444 env_with(&vars, &signed_in, &run, |env| {
1445 assert_eq!(not_signed_in(env), None)
1446 });
1447 }
1448
1449 #[test]
1450 fn account_keys_only_after_a_missing_data_role() {
1451 let vars = HashMap::new();
1452 let files = HashMap::new();
1453 let run = |p: &str, _: &[&str]| Err(CommandError::Missing(p.to_string()));
1454 let signed_in = AzureSettings {
1455 auth: AzureAuth::Bearer("t".to_string()),
1456 identity: Some(AzureAuth::AzCli),
1457 ..Default::default()
1458 };
1459 let mismatch = "403 AuthorizationPermissionMismatch: This request is not authorized";
1460 env_with(&vars, &files, &run, |env| {
1461 let off = with_account_key("keysoff", &signed_in, mismatch, false, env).unwrap_err();
1463 assert_eq!(off, mismatch);
1464 let other = with_account_key(
1466 "keysother",
1467 &signed_in,
1468 "403 AuthorizationFailure",
1469 true,
1470 env,
1471 )
1472 .unwrap_err();
1473 assert_eq!(other, "403 AuthorizationFailure");
1474 let keyed = AzureSettings {
1476 auth: AzureAuth::Key("k".to_string()),
1477 ..Default::default()
1478 };
1479 assert_eq!(
1480 with_account_key("keyskeyed", &keyed, mismatch, true, env).unwrap_err(),
1481 mismatch
1482 );
1483 let failed = with_account_key("keysnoaz", &signed_in, mismatch, true, env).unwrap_err();
1485 assert!(
1486 failed.starts_with(mismatch) && failed.contains("needs the Azure CLI"),
1487 "{failed}"
1488 );
1489 remember_key_for_test("keyshad", "a2V5");
1491 let used = with_account_key("keyshad", &signed_in, mismatch, true, env).unwrap();
1492 assert_eq!(used.auth, AzureAuth::Key("a2V5".to_string()));
1493 assert_eq!(used.identity, Some(AzureAuth::AzCli));
1494 });
1495 assert_eq!(
1496 parse_account_id(
1497 r#"{"data":[{"id":"/subscriptions/s/resourceGroups/g/providers/Microsoft.Storage/storageAccounts/a","sharedKey":false}]}"#
1498 ),
1499 Some((
1500 "/subscriptions/s/resourceGroups/g/providers/Microsoft.Storage/storageAccounts/a"
1501 .to_string(),
1502 false
1503 ))
1504 );
1505 assert_eq!(parse_account_id(r#"{"data":[]}"#), None);
1506 assert_eq!(
1507 parse_keys(r#"{"keys":[{"keyName":"key1","value":"k1","permissions":"FULL"},{"keyName":"key2","value":"k2"}]}"#).as_deref(),
1508 Some("k1")
1509 );
1510 }
1511
1512 #[test]
1513 fn connection_strings_in_their_common_shapes() {
1514 let key = parse_connection_string(
1515 "DefaultEndpointsProtocol=https;AccountName=datalake001;AccountKey=a2V5;EndpointSuffix=core.windows.net",
1516 )
1517 .unwrap();
1518 assert_eq!(key.account.as_deref(), Some("datalake001"));
1519 assert_eq!(key.auth, AzureAuth::Key("a2V5".to_string()));
1520 assert_eq!(
1521 key.blob_endpoint.as_deref(),
1522 Some("https://datalake001.blob.core.windows.net")
1523 );
1524
1525 let sas = parse_connection_string(
1526 "BlobEndpoint=https://datalake001.blob.core.windows.net/;SharedAccessSignature=sv=2022-11-02&sig=abc",
1527 )
1528 .unwrap();
1529 assert_eq!(sas.account.as_deref(), Some("datalake001"));
1530 assert_eq!(
1531 sas.auth,
1532 AzureAuth::Sas("sv=2022-11-02&sig=abc".to_string())
1533 );
1534
1535 let azurite = parse_connection_string("UseDevelopmentStorage=true").unwrap();
1536 assert!(azurite.use_emulator);
1537 assert_eq!(
1538 azurite.blob_endpoint_for("devstoreaccount1"),
1539 "http://127.0.0.1:10000/devstoreaccount1/"
1540 );
1541
1542 assert!(
1543 parse_connection_string("AccountName=x").is_none(),
1544 "no key or SAS"
1545 );
1546 }
1547
1548 #[test]
1549 fn the_environment_names_an_account_with_a_key_or_sas() {
1550 let vars = |pairs: &'static [(&'static str, &'static str)]| {
1551 move |key: &str| {
1552 pairs
1553 .iter()
1554 .find(|(k, _)| *k == key)
1555 .map(|(_, v)| v.to_string())
1556 }
1557 };
1558 let (settings, origin) = from_environment(&vars(&[
1559 ("AZURE_STORAGE_ACCOUNT_NAME", "acct"),
1560 ("AZURE_STORAGE_SAS_TOKEN", "?sv=1&sig=2"),
1561 ]))
1562 .unwrap();
1563 assert_eq!(settings.auth, AzureAuth::Sas("sv=1&sig=2".to_string()));
1564 assert_eq!(origin, "AZURE_STORAGE_SAS_TOKEN");
1565 assert!(from_environment(&vars(&[("AZURE_STORAGE_ACCOUNT_NAME", "acct")])).is_none());
1566 }
1567
1568 #[test]
1569 fn a_token_with_and_without_an_expiry() {
1570 let (token, expires) = parse_token(
1571 r#"{"accessToken": "eyJ0", "expiresOn": "2026-09-16 16:32:37.000000", "expires_on": 1789600000, "tokenType": "Bearer"}"#,
1572 )
1573 .unwrap();
1574 assert_eq!(token, "eyJ0");
1575 assert_eq!(
1576 expires,
1577 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_600_000))
1578 );
1579 let (_, expires) =
1580 parse_token(r#"{"accessToken": "eyJ0", "expiresOn": "2026-09-16 16:32:37.000000"}"#)
1581 .unwrap();
1582 assert_eq!(expires, None);
1583 assert!(parse_token(r#"{"accessToken": ""}"#).is_none());
1584 }
1585
1586 #[test]
1587 fn accounts_from_resource_graph() {
1588 let (accounts, next) = parse_accounts(
1589 r#"{"totalRecords": 2, "$skipToken": "page2", "data": [
1590 {"name": "datalake001", "subscription": "Azure subscription 1", "location": "eastus",
1591 "hns": true, "blob": "https://datalake001.blob.core.windows.net/",
1592 "publicNetworkAccess": "Enabled", "defaultAction": "Allow", "sharedKey": null},
1593 {"name": "locked001", "hns": false, "publicNetworkAccess": "Disabled", "sharedKey": false}
1594 ]}"#,
1595 )
1596 .unwrap();
1597 assert_eq!(next.as_deref(), Some("page2"));
1598 assert_eq!(accounts[0].name, "datalake001");
1599 assert!(accounts[0].hierarchical_namespace && accounts[0].shared_key_access);
1600 assert!(!accounts[0].private_network);
1601 assert!(accounts[1].private_network && !accounts[1].shared_key_access);
1602
1603 let err = parse_accounts(r#"{"error": {"code": "AuthorizationFailed", "message": "no"}}"#)
1604 .unwrap_err();
1605 assert_eq!(err, "no");
1606 }
1607
1608 #[test]
1609 fn containers_and_the_next_page() {
1610 let (names, next) = parse_containers(
1611 r#"<?xml version="1.0" encoding="utf-8"?><EnumerationResults ServiceEndpoint="https://a.blob.core.windows.net/"><Containers><Container><Name>datui-test</Name><Properties/></Container><Container><Name>raw</Name></Container></Containers><NextMarker>/a/raw</NextMarker></EnumerationResults>"#,
1612 )
1613 .unwrap();
1614 assert_eq!(names, ["datui-test", "raw"]);
1615 assert_eq!(next.as_deref(), Some("/a/raw"));
1616 let (none, next) = parse_containers(
1617 "<EnumerationResults><Containers /><NextMarker /></EnumerationResults>",
1618 )
1619 .unwrap();
1620 assert!(none.is_empty() && next.is_none());
1621 }
1622
1623 #[test]
1624 fn a_permission_error_says_how_to_get_permission() {
1625 let text = describe_error(
1626 403,
1627 "<?xml version=\"1.0\"?><Error><Code>AuthorizationPermissionMismatch</Code><Message>This request is not authorized to perform this operation using this permission.\nRequestId:x</Message></Error>",
1628 );
1629 assert!(text.starts_with("403 AuthorizationPermissionMismatch: This request"));
1630 assert!(text.contains("Storage Blob Data Reader"));
1631 assert!(!text.contains("RequestId"));
1632 }
1633
1634 #[test]
1635 fn folder_markers_are_not_data() {
1636 let prefixes = vec!["demo/".to_string()];
1637 assert!(is_folder_marker("demo", 0, &prefixes));
1638 assert!(!is_folder_marker("demo", 10, &prefixes));
1639 assert!(!is_folder_marker("empty.csv", 0, &prefixes));
1640 }
1641}