Skip to main content

datui_lib/
azure.rs

1//! Azure Blob Storage: logins, finding storage accounts, and listing what is in them.
2//!
3//! A signed-in `az` is asked for tokens, as object_store itself does, rather than its
4//! token cache being read: the cache is an internal format, encrypted to the user on
5//! Windows, and asking keeps MFA and conditional access working without datui knowing
6//! about either. One Resource Graph query finds every storage account the login can
7//! see across its subscriptions, so nobody has to name an account to browse it.
8//!
9//! Everything here that touches the network or runs `az` blocks, and is only called
10//! from a worker.
11
12use 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
19/// The scope of a token for reading blobs.
20pub const STORAGE_SCOPE: &str = "https://storage.azure.com/.default";
21/// The scope of a token for Resource Graph, which finds storage accounts.
22pub const MANAGEMENT_SCOPE: &str = "https://management.azure.com/.default";
23/// Sent on every request. Without it, anonymous requests are refused with
24/// `FeatureVersionMismatch`, and newer response fields are left out.
25pub const API_VERSION: &str = "2023-11-03";
26
27/// How a request to one account is authorized.
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub enum AzureAuth {
30    /// Nothing known yet.
31    #[default]
32    None,
33    /// A signed-in `az`, asked for a token when one is needed. When `az` is not signed
34    /// in and Azure PowerShell is, PowerShell is asked instead.
35    AzCli,
36    /// A signed-in Azure PowerShell (`Connect-AzAccount`).
37    PowerShell,
38    /// A service principal or workload identity from `AZURE_CLIENT_ID` and friends.
39    ServicePrincipal(ServicePrincipal),
40    /// The managed identity of the VM, App Service or Function datui runs on.
41    ManagedIdentity,
42    /// The account key, printed by a `secret_command`.
43    KeyCommand(String),
44    /// An Entra ID token.
45    Bearer(String),
46    /// The account's shared key.
47    Key(String),
48    /// A shared access signature, without the leading `?`.
49    Sas(String),
50}
51
52/// An application's identity in Entra ID: a client secret, or a federated token file
53/// (AKS workload identity) exchanged for a token each time.
54#[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    /// `AZURE_AUTHORITY_HOST`, for sovereign clouds.
61    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    /// A login that is asked for tokens: it may reach any account it can see.
77    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    /// Where the login is from, in a word or two.
88    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/// How to reach Azure Blob Storage for one source.
105#[derive(Debug, Clone, PartialEq, Eq, Default)]
106pub struct AzureSettings {
107    /// The one account a key, SAS or connection string names. A signed-in identity
108    /// reaches every account it can see, so it has none.
109    pub account: Option<String>,
110    pub auth: AzureAuth,
111    /// A blob endpoint other than `https://<account>.blob.core.windows.net/`.
112    pub blob_endpoint: Option<String>,
113    /// Azurite, the local emulator.
114    pub use_emulator: bool,
115    /// The login a token in `auth` came from, kept for what needs a token of another
116    /// scope: finding accounts, fetching an account's keys.
117    pub identity: Option<AzureAuth>,
118}
119
120impl AzureSettings {
121    /// The blob service endpoint for `account`, ending in `/`.
122    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    /// These settings with a token in place of an identity. Runs `az` or PowerShell, or
131    /// asks Entra ID.
132    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
144/// The Azurite account and key everyone uses; they are published in its documentation.
145const AZURITE_ACCOUNT: &str = "devstoreaccount1";
146const AZURITE_KEY: &str =
147    "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
148
149/// The settings a connection string describes: `AccountName`, `AccountKey`,
150/// `SharedAccessSignature`, `BlobEndpoint`, `EndpointSuffix`,
151/// `DefaultEndpointsProtocol`, or `UseDevelopmentStorage=true` for Azurite.
152pub 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    // An account can be left out when the blob endpoint names it.
190    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
204/// What the environment says about Azure, if anything: a connection string, an account
205/// with a key or SAS token, or a service principal (with an account, or to find them).
206pub 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
247/// A service principal from `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and either
248/// `AZURE_CLIENT_SECRET` or `AZURE_FEDERATED_TOKEN_FILE`, the variables the Azure SDKs
249/// and AKS workload identity set.
250pub 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
273/// Whether `az` has been used on this machine: its config directory exists. Not proof the
274/// login is still good, which only asking can tell.
275pub 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
286/// Whether Azure PowerShell has been signed in on this machine: its context file exists.
287pub 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    // `~/.Azure` on every platform; on Windows it is the same directory as `az`'s
293    // `.azure`.
294    Some(
295        env.home
296            .as_ref()?
297            .join(".Azure")
298            .join("AzureRmContext.json"),
299    )
300}
301
302/// Azure tooling on this machine with no sign-in to show for it: `az` on `PATH`, or the
303/// Az.Accounts PowerShell module installed. The fix, naming what is there, when so.
304pub 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
332/// A token for `scope` from an identity: `az`, Azure PowerShell or a service principal.
333pub 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            // `az` is preferred, since it starts faster; PowerShell is asked when it is
342            // the one signed in.
343            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
358/// Tokens by scope, kept until shortly before they expire so `az`, which is slow to
359/// start, runs once an hour rather than once per request.
360type 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
367/// A token for `scope` from the signed-in `az`.
368pub 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
403/// The token and expiry from `az account get-access-token --output json`. Newer `az`
404/// gives `expires_on` in seconds; older gives only `expiresOn` in local time, which is
405/// not worth guessing at, so such a token is refreshed on the next request.
406pub 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
419/// The script Azure PowerShell runs: both scopes' tokens and expiries as JSON. A
420/// constant, with nothing interpolated into it. The token is a `SecureString` in recent
421/// Az.Accounts, and `NetworkCredential` turns it back into text in PowerShell 5.1 and 7
422/// alike.
423const 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
434/// A token for `scope` from Azure PowerShell. One process fetches both scopes, and both
435/// are cached.
436fn 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
474/// `(resource, token, expiry)` for each scope in the PowerShell script's output.
475pub 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
495/// A token for `scope` for a service principal, from Entra ID's token endpoint: a client
496/// secret, or the federated token file read afresh, since AKS rotates it.
497fn 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
562/// A token for `scope` from the platform's managed identity: App Service and Functions'
563/// `IDENTITY_ENDPOINT` (or the older `MSI_ENDPOINT`), else the VM's instance metadata
564/// service. Only reached when the platform or `instance_identity` allows it.
565fn 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        // App Service's older endpoint takes a secret; Cloud Shell's takes none and
584        // wants the metadata header instead.
585        (
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
620/// The access token and expiry from an Entra ID token response.
621pub 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    // Managed identity endpoints give `expires_on` in seconds since the epoch.
630    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
655/// Account keys fetched after a 403, by account. In memory only: never cached to disk,
656/// logged or shown.
657fn 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
669/// The key this session reads `account` with, when a token was refused and its keys
670/// were fetched.
671pub 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
680/// Whether a sign-in's token has read from `account` this session.
681pub fn token_reads(account: &str) -> bool {
682    token_readable_accounts()
683        .lock()
684        .is_ok_and(|accounts| accounts.contains(account))
685}
686
687/// Note that a sign-in's token read from `account`.
688pub 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
694/// Whether `settings` may read at `path` in `container`: one listing of at most one
695/// blob, which needs the same data permission a read does.
696pub 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
715/// Whether a refusal is the one account keys get past: a sign-in with no data role.
716pub fn is_permission_mismatch(error: &str) -> bool {
717    error.contains("AuthorizationPermissionMismatch")
718}
719
720/// The first access key of `account`, as the Portal fetches them for someone with Owner
721/// or Contributor and no data role: Resource Graph for the account's ID and whether it
722/// allows shared keys, then `listKeys` with a management token from `identity`.
723pub 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
781/// The resource ID and shared-key setting of the one account a Resource Graph query
782/// found.
783pub 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
791/// The first key from a `listKeys` response.
792pub 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
802/// After a 403 on a sign-in's token: the same settings with the account's key, when
803/// the fallback is on and the account allows it, else the refusal with the reason.
804pub 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/// One storage account, as Resource Graph describes it.
827#[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    /// Public network access is disabled, or the firewall denies by default.
835    pub private_network: bool,
836    pub shared_key_access: bool,
837}
838
839/// The Resource Graph query for storage accounts, with each one's subscription name.
840const 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
853/// A Resource Graph response has more than this many pages only for a tenant nobody
854/// browses by scrolling.
855const MAX_ACCOUNT_PAGES: usize = 20;
856
857/// Every storage account the signed-in identity can see, across its subscriptions.
858pub 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
890/// The accounts in one Resource Graph response, and the token for the next page.
891pub 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                // Unset means allowed.
923                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
934/// Containers in one account. `settings` must already hold a token or key, not `AzCli`.
935pub 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
959/// A signed GET, returning the body of a successful response and the service's own
960/// error code and message otherwise.
961fn 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        // No `x-ms-date` of datui's own: the authorizer adds `Date` and signs it, and a
970        // second date header makes a shared-key signature one the service rejects.
971        .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
1016/// `403 AuthorizationPermissionMismatch: ...` from an error document, with what fixes
1017/// the one that trips up people who manage their storage in the Portal.
1018pub 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
1043/// Container names from a `List Containers` response, and the marker for the next page.
1044pub 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
1077/// An object store for one container. `settings` must already hold a token or key.
1078pub 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
1086/// The same store, for one page of a listing at a time.
1087pub 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
1133/// Polars' view of the same settings, for `scan_parquet` on an `abfss://` URL.
1134pub 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
1162/// Whether a listed object is only a folder marker. Accounts with hierarchical
1163/// namespace list every directory twice, once as a prefix and once as an empty blob of
1164/// the same name; the blob is not data.
1165pub 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        // A key beside the account still wins.
1213        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        // No secret, no principal.
1219        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        // The script is a constant with nothing from outside in it.
1374        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        // Joined with the host's separator, which is what `split_paths` splits on:
1405        // `:` is one long directory on Windows.
1406        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, &nothing, &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            // Turned off: the refusal as it was.
1462            let off = with_account_key("keysoff", &signed_in, mismatch, false, env).unwrap_err();
1463            assert_eq!(off, mismatch);
1464            // Another refusal: nothing to get past.
1465            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            // A key or SAS was refused: no identity to fetch keys with.
1475            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            // The identity cannot fetch keys: the refusal, and why no key was used.
1484            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            // Fetched before: used, with the identity kept.
1490            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}